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:
+332
-65
@@ -1,24 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of the header drag
|
||||
|
||||
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
|
||||
/// Lane, "no separate grip").
|
||||
///
|
||||
/// The gesture lives on the header — that is where the design puts it — but the two things it needs
|
||||
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
|
||||
/// and what a release means (a proposal computed against the live lane order, then a write). Both
|
||||
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
|
||||
/// body-evaluation time.
|
||||
@MainActor
|
||||
struct LaneHeaderDrag {
|
||||
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
|
||||
/// for its duration (`LaneReorderSession.startCentre`).
|
||||
let startCentre: () -> CGFloat
|
||||
/// Commit the reorder at whatever the current proposal is, and end the session.
|
||||
let commit: () -> Void
|
||||
}
|
||||
|
||||
// MARK: - LaneView
|
||||
|
||||
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||||
@@ -29,9 +11,11 @@ struct LaneHeaderDrag {
|
||||
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
|
||||
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
|
||||
/// a click selects the lane — toggling off on a repeat, exactly as empty space does
|
||||
/// (04-interactions.md § Selection, settled) — and movement past a small threshold begins a reorder
|
||||
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
|
||||
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
|
||||
/// (04-interactions.md § Selection, settled) — and movement begins a **system drag session**
|
||||
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
|
||||
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
|
||||
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
|
||||
/// which sits in an overlay outside it.
|
||||
///
|
||||
/// ### The lane's one context menu
|
||||
///
|
||||
@@ -60,10 +44,14 @@ struct LaneView: View {
|
||||
/// override it (see `BoardView.laneSlot`).
|
||||
let columns: Int
|
||||
|
||||
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
|
||||
let reorder: LaneReorderSession
|
||||
/// This lane's resting slot width — the replica's width, so the image under the cursor is the
|
||||
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
|
||||
let slotWidth: CGFloat
|
||||
|
||||
let headerDrag: LaneHeaderDrag
|
||||
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
|
||||
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
|
||||
/// goes through (`BoardDropContext`).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
|
||||
/// face registers its frame into the same registry (`MarqueeControl`).
|
||||
@@ -88,6 +76,14 @@ struct LaneView: View {
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
|
||||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||||
/// tall as the strip gives it.
|
||||
@State private var measuredHeight: CGFloat = 0
|
||||
|
||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||
@State private var autoScroller = DragAutoScroller()
|
||||
|
||||
var body: some View {
|
||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
@@ -101,6 +97,14 @@ struct LaneView: View {
|
||||
}
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||||
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
|
||||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||||
// the strip's logic — because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
//
|
||||
// m5-finder-drops: external Finder file sessions join this same target and this same
|
||||
// routing; the type list grows by `.fileURL` and the delegate by one branch.
|
||||
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -110,7 +114,20 @@ struct LaneView: View {
|
||||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||||
// including the empty stretch between the badge and the button.
|
||||
.contentShape(Rectangle())
|
||||
.gesture(headerGesture)
|
||||
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
|
||||
// click on the already-selected lane's header unselects, one lane-click behavior
|
||||
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
|
||||
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
|
||||
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
|
||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
}
|
||||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
.contextMenu { laneMenu }
|
||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||
@@ -272,40 +289,106 @@ struct LaneView: View {
|
||||
.disabled(store.isReadOnly || store.isEditingInline)
|
||||
}
|
||||
|
||||
/// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split:
|
||||
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
|
||||
// MARK: - The lane drag
|
||||
|
||||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the
|
||||
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
|
||||
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
|
||||
private var headerGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
|
||||
// The focused-editor rule holds a drag off too: a reorder is a board command.
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !reorder.isDragging(lane.id) {
|
||||
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
|
||||
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
|
||||
}
|
||||
reorder.update(translation: value.translation.width)
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in board order —
|
||||
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
|
||||
/// macOS targeting.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
|
||||
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
|
||||
/// and the image snaps back.
|
||||
private func startLaneDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
&& selection.ids.contains(lane.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [lane.id]
|
||||
|
||||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .lanes,
|
||||
side: .live,
|
||||
items: members.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
.onEnded { _ in
|
||||
if reorder.isDragging(lane.id) {
|
||||
headerDrag.commit()
|
||||
} else {
|
||||
// **The header toggles like empty space** (04-interactions.md § Selection,
|
||||
// settled): "a click on the already-selected lane's header unselects, one
|
||||
// lane-click behavior everywhere, so a full lane keeps a pointer path out of
|
||||
// selection". Hence the same `togglesOnRepeat` the empty space passes — the two
|
||||
// surfaces differ only in where they are.
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
)
|
||||
drops.session.beginLanes(
|
||||
members.map(\.id),
|
||||
folders: payload.folders,
|
||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||
// (03-board-ui.md § Motion).
|
||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
|
||||
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
|
||||
/// a count badge for a multi-drag.
|
||||
///
|
||||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||
/// past the lane's height is clipped anyway.
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||||
return selection.ids.count
|
||||
}
|
||||
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
@@ -343,10 +426,16 @@ struct LaneView: View {
|
||||
store: store,
|
||||
card: card,
|
||||
registry: marquee.registry,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
)
|
||||
case .placeholder:
|
||||
NewCardStubView(store: store, openCard: openCard)
|
||||
case let .shadow(_, height):
|
||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
@@ -363,6 +452,30 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
|
||||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||||
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
|
||||
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
||||
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
||||
// re-derived, so the zones and the drawn grid cannot disagree.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
drops.registry.update(
|
||||
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
|
||||
for: lane.id
|
||||
)
|
||||
}
|
||||
.onDisappear { drops.registry.removeGrid(lane.id) }
|
||||
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
||||
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
||||
// the proposal through the same shared retarget the drop delegate uses, because the
|
||||
// cursor is stationary while the content moves under it.
|
||||
.background {
|
||||
DragAutoScrollAnchor(scroller: autoScroller) {
|
||||
drops.retargetCards(inLane: lane.id)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
||||
// consumed as two singles.
|
||||
@@ -387,22 +500,51 @@ struct LaneView: View {
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
}
|
||||
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
|
||||
// session is in flight at all, so it is cancelled the moment the session ends — and
|
||||
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
|
||||
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
|
||||
// engagement rect simply scrolls nothing.
|
||||
.task(id: drops.session.isDraggingCards) {
|
||||
guard drops.session.isDraggingCards else { return }
|
||||
await autoScroller.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
|
||||
/// is the one being created into.
|
||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere — the
|
||||
/// shadow run's position, and the reflow's narrow animation key.
|
||||
private var cardProposal: Int? {
|
||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
||||
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
||||
///
|
||||
/// The overlay is inserted **at the position the card will actually take** — after its anchor
|
||||
/// The placeholder is inserted **at the position the card will actually take** — after its anchor
|
||||
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
|
||||
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
|
||||
/// cannot appear anywhere but where the real card lands.
|
||||
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
|
||||
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
|
||||
/// it, so neither displaces the other.
|
||||
private var slots: [LaneSlot] {
|
||||
var result = renderedCards.map(LaneSlot.card)
|
||||
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
|
||||
return result
|
||||
|
||||
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
|
||||
var placeholderPosition: Int?
|
||||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
||||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
?? result.count
|
||||
}
|
||||
|
||||
let heights = drops.session.cardHeights
|
||||
if let shadowPosition {
|
||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||
}
|
||||
if var position = placeholderPosition {
|
||||
if let shadowPosition, position >= shadowPosition { position += heights.count }
|
||||
result.insert(.placeholder, at: min(position, result.count))
|
||||
}
|
||||
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
result.insert(.placeholder, at: position ?? result.count)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -411,10 +553,17 @@ struct LaneView: View {
|
||||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||
/// tombstoned lane at all.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||
///
|
||||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||||
/// honest for free — see `countBadge`.
|
||||
private var renderedCards: [Card] {
|
||||
lane.cards.filter { !$0.isDeleted }
|
||||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
@@ -464,6 +613,8 @@ struct LaneView: View {
|
||||
private enum LaneSlot: Identifiable {
|
||||
case card(Card)
|
||||
case placeholder
|
||||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||
case shadow(index: Int, height: CGFloat)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -471,6 +622,9 @@ private enum LaneSlot: Identifiable {
|
||||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||||
case .placeholder: "placeholder"
|
||||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||
// rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +672,10 @@ private struct CardFaceView: View {
|
||||
/// here and takes it out again when it leaves — see `View.marqueeTarget`.
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@@ -572,6 +730,17 @@ private struct CardFaceView: View {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startCardDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`).
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
@@ -579,6 +748,104 @@ private struct CardFaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String?] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .live,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id] ?? nil
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
side: .live,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers.
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
|
||||
|
||||
Reference in New Issue
Block a user