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:
@@ -133,6 +133,15 @@ public final class BoardStore {
|
||||
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
|
||||
public private(set) var snapshot: BoardModel
|
||||
|
||||
/// How many snapshots this store has applied, ever — a counter, not a version.
|
||||
///
|
||||
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
|
||||
/// is the event, not change. A reload that produced an identical model still ends the round trip
|
||||
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
|
||||
/// exactly when the write turned out to be a no-op.
|
||||
public private(set) var snapshotGeneration: Int = 0
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
/// describe the tree currently on screen.
|
||||
@@ -449,6 +458,7 @@ public final class BoardStore {
|
||||
reduced: Motion.prefersReducedMotion
|
||||
)) {
|
||||
snapshot = result.model
|
||||
snapshotGeneration += 1
|
||||
loadWarnings = result.warnings
|
||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
||||
// the new one, because a view woken by the snapshot's change must never observe a
|
||||
@@ -1133,7 +1143,7 @@ public final class BoardStore {
|
||||
|
||||
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
|
||||
/// counted **with the dragged lane itself removed** — which is the index
|
||||
/// `LaneReorderMath.proposedIndex` produces.
|
||||
/// `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
|
||||
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
|
||||
@@ -1177,6 +1187,57 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
|
||||
/// position `index` among the board's live lanes, counted with the dragged run removed — the
|
||||
/// index `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
|
||||
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
|
||||
/// commits): one app-mediated reload, and on git boards one commit rather than N.
|
||||
///
|
||||
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
|
||||
/// no other relative order to preserve.
|
||||
///
|
||||
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
|
||||
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
|
||||
/// commit is minted.
|
||||
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
|
||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let members = lanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let remaining = lanes.filter { !ids.contains($0.id) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
@@ -1758,52 +1819,77 @@ public final class BoardStore {
|
||||
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
|
||||
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
|
||||
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
|
||||
}),
|
||||
let card = source.cards.first(where: { $0.id == cardID })
|
||||
else { return }
|
||||
restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index)
|
||||
}
|
||||
|
||||
/// The multi-drag face of the same gesture: N trash rows dropped over one live lane land
|
||||
/// contiguously at `index`, **in drop order** — the order the payload carries, which is the
|
||||
/// trash's own sorted order (03-board-ui.md § Trash ▸ Contents).
|
||||
///
|
||||
/// It is the plural rather than a loop over the singular for `moveLanes`' reason: one
|
||||
/// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit
|
||||
/// (DRAG-REORDER.md § The drop commits). Every rule above holds per member — the same-lane
|
||||
/// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation,
|
||||
/// which is what makes a row dropped back where it already belonged come back exactly there.
|
||||
public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
|
||||
// A row exists only for a card whose *own* flag is set under a *live* lane — the trash's
|
||||
// absolute ancestor walk (03-board-ui.md § Trash ▸ Contents). Anything else in the list names
|
||||
// nothing draggable and is silently skipped, which is this method's standing posture.
|
||||
let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in
|
||||
guard let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted }
|
||||
}),
|
||||
let card = source.cards.first(where: { $0.id == id })
|
||||
else { return nil }
|
||||
return (source.id, card)
|
||||
}
|
||||
guard !rows.isEmpty else { return }
|
||||
|
||||
let root = rootURL
|
||||
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
let crossesLanes = source.id != laneID
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let recordedOrder = card.order
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: rendered.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: rendered.count), at: target)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: rows.count
|
||||
)
|
||||
}
|
||||
guard let rank else { return }
|
||||
guard let ranks else { return }
|
||||
|
||||
guard crossesLanes else {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: cardFolder,
|
||||
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
|
||||
operation: .restore(title: nil)
|
||||
) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
if rank != recordedOrder {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
for (row, rank) in zip(rows, ranks) {
|
||||
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
|
||||
|
||||
guard row.laneID != laneID else {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: cardFolder,
|
||||
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
|
||||
operation: .restore(title: nil)
|
||||
) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
if rank != row.card.order {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user