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
117 lines
5.0 KiB
Swift
117 lines
5.0 KiB
Swift
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
|
|
}
|
|
}
|