Implement drag & drop with the locality model
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
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user