import AppKit import Foundation import UniformTypeIdentifiers // MARK: - The drag types // // `UTType.laneworkCards` — a drag carrying board **cards**, live faces or trash rows // (04-interactions.md ▸ Drag and drop, ▸ The trash) — and `.laneworkLanes`, the strip's own reorder // and the cross-board lane transfer. Declared in `Info.plist` beside the clipboard type, because a // system drag session is what crosses window boundaries, shows the copy badge, and gives the // full-size replica preview (DRAG-REORDER.md § Cross-board sessions). The constants themselves live // with the clipboard type in `PasteboardTypes.swift`, exported once by the app that owns them. // MARK: - What a drag carries /// Which of the board's two levels a drag session carries. The selection is homogeneous by kind /// (04-interactions.md § Selection), so a session is one or the other and never both. enum DragKind: String, Codable, Sendable, Equatable { case cards case lanes var type: UTType { switch self { case .cards: .laneworkCards case .lanes: .laneworkLanes } } } /// The JSON a drag session puts on the pasteboard. /// /// **Same-app transfer is the only real consumer.** Both boards are open in this app, so the hover /// path reads identity from `DragSession` — captured synchronously at drag start — and never decodes /// anything mid-flight; the payload is the formal drop data, and what makes the session a *system* /// session at all (which is what crosses windows and draws the badge). It carries folder URLs /// because that is what the destination store's commits take (`receiveCards`, `receiveLanes`), and /// the source board root beside them because locality — the Finder volume model — is a comparison of /// roots (04-interactions.md ▸ Drag and drop). /// /// A **plain-text secondary representation** (the dragged titles, newline-joined) rides along so a /// stray drop into a text editor does something sane rather than nothing. struct DragPayload: Codable, Sendable, Equatable { /// One dragged item: its UUID, its folder on disk, and its title for the text representation. struct Item: Codable, Sendable, Equatable { var id: String var folder: String var title: String? } /// The **source** board's root folder — the left-hand side of the locality comparison. var boardRoot: String var kind: DragKind /// Which container the drag started in — a trash card's drag is a card drag from `.trash`, which /// is the whole of what makes its within-board drop a restore (04-interactions.md ▸ The trash). /// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard. var container: ItemContainer /// The dragged items **in flatten order** — "lane `order` first, then card `order`" /// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than /// re-deriving it, because a cross-board destination has no flatten order for items it does not /// hold. var items: [Item] var ids: [ItemID] { items.map { ItemID(rawValue: $0.id) } } var folders: [URL] { items.map { URL(fileURLWithPath: $0.folder, isDirectory: true) } } var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) } /// The secondary representation: one title per line, untitled items rendered as they are on the /// board (03-board-ui.md § Card face — "Untitled" is a rendering, never a value). var plainText: String { items.map { $0.title ?? "Untitled" }.joined(separator: "\n") } // MARK: Coding func encoded() -> Data? { try? JSONEncoder().encode(self) } init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) { self.boardRoot = boardRoot.path self.kind = kind self.container = container self.items = items } init?(data: Data) { guard let decoded = try? JSONDecoder().decode(DragPayload.self, from: data) else { return nil } self = decoded } /// The item provider a `.onDrag` hands back: the JSON under this session's own type, plus the /// plain-text fallback. /// /// The custom type is registered `.ownProcess` deliberately — the payload names folders inside /// the user's boards, and no other app has any business reading it; the *text* is the /// representation other apps get. func itemProvider() -> NSItemProvider { let provider = NSItemProvider() if let data = encoded() { provider.registerDataRepresentation( forTypeIdentifier: kind.type.identifier, visibility: .ownProcess ) { completion in completion(data, nil) return nil } } let text = Data(plainText.utf8) provider.registerDataRepresentation( forTypeIdentifier: UTType.utf8PlainText.identifier, visibility: .all ) { completion in completion(text, nil) return nil } return provider } }