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,116 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Drives edge-autoscroll for one lane's scroll view across one drag session — the *driver* half of
|
||||
/// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`.
|
||||
///
|
||||
/// Three constraints shape it, and each is a rule from that section:
|
||||
///
|
||||
/// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly
|
||||
/// because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is
|
||||
/// exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs
|
||||
/// no events at all.
|
||||
/// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space
|
||||
/// while the *content* moves under it, so without this the shadow would freeze at whatever slot the
|
||||
/// last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it
|
||||
/// goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses —
|
||||
/// one shared retarget, so the two can never disagree.
|
||||
/// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it
|
||||
/// is cancelled the moment the session ends — and `DragSession`'s watchdog guarantees that flag
|
||||
/// clears no matter how the drag finished.
|
||||
@MainActor
|
||||
final class DragAutoScroller {
|
||||
|
||||
/// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
|
||||
/// drive, and its window converts the physical cursor. Weak — the view belongs to the hierarchy.
|
||||
fileprivate weak var anchor: NSView?
|
||||
|
||||
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
||||
fileprivate var didScroll: (() -> Void)?
|
||||
|
||||
private var lastTick: CFTimeInterval?
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
/// Ticks at display rate until the owning task is cancelled.
|
||||
func run() async {
|
||||
lastTick = nil
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(16))
|
||||
guard !Task.isCancelled else { break }
|
||||
step()
|
||||
}
|
||||
lastTick = nil
|
||||
}
|
||||
|
||||
private func step() {
|
||||
let now = CACurrentMediaTime()
|
||||
let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
|
||||
lastTick = now
|
||||
guard elapsed > 0,
|
||||
let anchor,
|
||||
let window = anchor.window,
|
||||
let scrollView = anchor.enclosingScrollView
|
||||
else { return }
|
||||
|
||||
let clip = scrollView.contentView
|
||||
let visible = clip.bounds
|
||||
guard visible.width > 0, visible.height > 0 else { return }
|
||||
|
||||
// Physical cursor → window → the clip view's (scrolled) coordinates, then relative to the
|
||||
// visible area's top-left corner, which is the space `DragAutoScrollMath` is written in.
|
||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||
let inClip = clip.convert(inWindow, from: nil)
|
||||
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
|
||||
|
||||
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
||||
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
||||
|
||||
let document = scrollView.documentView?.frame ?? .zero
|
||||
var proposed = visible
|
||||
proposed.origin.x = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minX, maxOffset: document.maxX - visible.width)
|
||||
proposed.origin.y = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minY, maxOffset: document.maxY - visible.height)
|
||||
// The clip view has the final say (content insets, magnification).
|
||||
let target = clip.constrainBoundsRect(proposed).origin
|
||||
guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return }
|
||||
|
||||
clip.scroll(to: target)
|
||||
scrollView.reflectScrolledClipView(clip)
|
||||
didScroll?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds a `DragAutoScroller` to the scroll view it should drive.
|
||||
///
|
||||
/// **Must be placed inside the scroll view's content** (as its `.background`), so
|
||||
/// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view
|
||||
/// and would find nothing.
|
||||
struct DragAutoScrollAnchor: NSViewRepresentable {
|
||||
|
||||
let scroller: DragAutoScroller
|
||||
|
||||
/// Refreshed on every view update, so the callback always closes over the current context rather
|
||||
/// than the one the session started with.
|
||||
let didScroll: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
bind(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
bind(view)
|
||||
}
|
||||
|
||||
private func bind(_ view: NSView) {
|
||||
scroller.anchor = view
|
||||
scroller.didScroll = didScroll
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user