The second half: system drag sessions over phase 1's model, per DRAG-REORDER.md and 04-interactions.md § Drag & drop. - Card faces, lane headers, and trash rows drag as NSItemProvider sessions (two exported UTTypes, JSON payload in flatten order, plain-text titles as the secondary representation) — replacing m4's custom lane-reorder gesture and trash drag-out wholesale; the app-wide DragSession carries the members, the frozen dragged sizes, the live proposal, and the effective operation. - Three drop delegates (lane masonry, strip, window fallback), each accepting both types and routing internally per the single-target-dispatch rule; the cursor is the physical mouse converted to strip space; proposals come from DropSlotMath with hysteresis threaded through, and the lane-strip proposal clamps in front of the shown trash. - Locality picks the default — move within a board, copy across, the badge tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘ forces move; trash rows restore within their board (positional), copy out across boards by default, ⌘ forcing the true restore-move. - N contiguous shadows with reflow keyed on the proposal; the committed-overlay hold renders the dropped arrangement until the reload echo lands (1.5 s dissolution deadline for refused writes); the re-grounding trio: geometry re-derives per render, proposals re-validate by liveness at release, an emptied drag cancels itself. - Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per step), the mouse-up-gated late-event cleanup, and the polling watchdog — the pathfinder's lifecycle traps, ported. - Store: moveLanes and multi-card restoreByDrag join the one-bracket drop commits. 784 unit tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
140 lines
5.3 KiB
Swift
140 lines
5.3 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - The drag types
|
|
|
|
extension UTType {
|
|
|
|
/// A drag carrying board **cards** — live card faces or trash rows (04-interactions.md ▸ Drag
|
|
/// and drop, ▸ The trash). Declared as an exported type in `Info.plist`, 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).
|
|
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
|
|
|
|
/// A drag carrying board **lanes** — the strip's own reorder and the cross-board lane transfer.
|
|
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
|
|
}
|
|
|
|
// 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 {
|
|
|
|
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
|
|
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
|
|
/// (restore within the board, copy-out across boards).
|
|
enum Side: String, Codable, Sendable, Equatable {
|
|
case live
|
|
case trashed
|
|
|
|
init(_ liveness: Liveness) {
|
|
self = liveness == .live ? .live : .trashed
|
|
}
|
|
|
|
var liveness: Liveness { self == .live ? .live : .trashed }
|
|
}
|
|
|
|
/// 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
|
|
var side: Side
|
|
|
|
/// 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, side: Liveness, items: [Item]) {
|
|
self.boardRoot = boardRoot.path
|
|
self.kind = kind
|
|
self.side = Side(side)
|
|
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
|
|
}
|
|
}
|