From b4803a731b2c104d3592fdf75f426a20ca8f96c5 Mon Sep 17 00:00:00 2001 From: devansh nigam Date: Thu, 1 Oct 2026 17:39:12 +0530 Subject: [PATCH] fix: file dragged cards into pinboard tabs again Dragging a card onto a pinboard tab did nothing: the card slid back with no highlight. The shelf's SwiftUI .onDrop installs a full-size drop view registered for public.item, so it claimed every drag over the shelf, and since the tab reordering change its delegate rejected card drags. The shelf now has no SwiftUI .onDrop. ShelfHostingView, the shelf's NSHostingView, is the only drag destination and handles both drags in AppKit: cards are filed into the tab under the pointer (which then opens), and tabs are reordered. Both resolve the tab from the frames the tabs publish. Dragging a card that isn't selected now selects it first, as in Finder. While a card drag is over a tab, a small callout hangs from that tab naming the board it will land in, follows the drag along the row, and confirms the drop for a moment afterwards. It replaces the "Added to" HUD; a failed drop still shows the HUD. Signed-off-by: devansh nigam --- Copy/App/AppCoordinator.swift | 18 +- Copy/Shelf/PinboardDropCatcher.swift | 248 +++++++++++++++++--------- Copy/Shelf/ShelfPanelController.swift | 98 ++++++++++ Copy/Shelf/ShelfRootView.swift | 68 +++---- Copy/Shelf/ShelfViewModel.swift | 144 +++++++++++++-- 5 files changed, 441 insertions(+), 135 deletions(-) diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 5ae67ce..d6f8d98 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -55,7 +55,23 @@ final class AppCoordinator { theme: settings.shelfTheme, floatingShelf: settings.floatingShelf) { [weak self] in guard let self else { return NSView() } - return NSHostingView(rootView: ShelfRootView(viewModel: self.shelfViewModel)) + let host = ShelfHostingView(rootView: ShelfRootView(viewModel: self.shelfViewModel)) + // Drags onto the pinboard tabs (cards to file, tabs to reorder) are tracked by + // the host in AppKit and resolved against the tab frames the view model holds + // (see `ShelfHostingView`). + host.activeShelfDrag = { [weak self] in self?.shelfViewModel.activeShelfDrag } + host.onDragMoved = { [weak self] kind, point in + self?.shelfViewModel.shelfDragMoved(kind, to: point) ?? false + } + host.onDragExited = { [weak self] in self?.shelfViewModel.shelfDragExited() } + host.onDragEnded = { [weak self] in self?.shelfViewModel.shelfDragEnded() } + host.onCardDrop = { [weak self] point, uuids in + self?.shelfViewModel.fileDroppedCards(uuids, at: point) ?? false + } + host.onPinboardDrop = { [weak self] point, id in + self?.shelfViewModel.moveDroppedPinboard(id, at: point) ?? false + } + return host } controller.onDidHide = { [weak self] in self?.shelfViewModel.clearTransientState() diff --git a/Copy/Shelf/PinboardDropCatcher.swift b/Copy/Shelf/PinboardDropCatcher.swift index 8121c49..0317374 100644 --- a/Copy/Shelf/PinboardDropCatcher.swift +++ b/Copy/Shelf/PinboardDropCatcher.swift @@ -1,15 +1,18 @@ +import CopyCore import SwiftUI import UniformTypeIdentifiers /// Frames of each pinboard tab, keyed by pinboard id, in the shelf's `"shelfRoot"` /// coordinate space. Each `TabPill` publishes its own frame; `ShelfRootView` collects them -/// so the shelf-level `PinboardDropDelegate` can tell which tab a drop landed on. +/// so `ShelfHostingView` can tell which tab a drag is over. /// -/// Pinboard-related drops can't be caught reliably by a per-tab `.onDrop`: inside the shelf's -/// borderless, non-activating Liquid Glass panel, a small nested pill never establishes a -/// working SwiftUI drop region (a `.onDrop` on the whole shelf, by contrast, fires -/// reliably). So the shelf handles the drop once at its root and maps the drop location to -/// a tab via these frames. +/// Drops onto the tabs are not SwiftUI `.onDrop`s. A per-tab `.onDrop` never establishes a +/// working drop region on the small pills inside the shelf's borderless, non-activating +/// glass panel. A shelf-level `.onDrop` puts a full-size SwiftUI drop view over the whole +/// shelf that claims every drag (it registers for `public.item`), so AppKit never hands a +/// card drag to anything else, and SwiftUI stops updating a drag that starts in the card +/// row once it crosses into the header. So the shelf's hosting view takes both drags in +/// AppKit and maps the pointer to a tab via these frames. struct PinboardTabFramesKey: PreferenceKey { static var defaultValue: [Int64: CGRect] { [:] } static func reduce(value: inout [Int64: CGRect], nextValue: () -> [Int64: CGRect]) { @@ -17,23 +20,10 @@ struct PinboardTabFramesKey: PreferenceKey { } } -/// Shelf-level drop target for both card filing and pinboard reordering. Small per-tab -/// drop targets don't work reliably in the shelf panel, so both paths resolve the tab -/// under the pointer from the frames published by `PinboardTabFramesKey`. -struct PinboardDropDelegate: DropDelegate { - /// Pinboard tab frames in the same coordinate space this delegate's `.onDrop` uses. - var tabFrames: () -> [Int64: CGRect] - /// Reports the card-filing target for the existing full-tab highlight. - var onFileTargetChange: (Int64?) -> Void - /// Reports the reorder target and whether the insertion point is after its midpoint. - var onReorderTargetChange: (Int64?, Bool) -> Void - /// Files the dragged card uuid(s) into the given pinboard. - var onFile: (Int64, [String]) -> Void - /// Moves one dragged pinboard before or after the target pinboard. - var onMove: (Int64, Int64, Bool) -> Void - - private func pinboard(at point: CGPoint) -> Int64? { - let frames = tabFrames() +/// Maps a point in the shelf's top-left coordinate space (`"shelfRoot"`, which is also the +/// hosting view's) to the pinboard tab under it, using the frames each tab publishes. +enum PinboardTabHitTest { + static func pinboard(at point: CGPoint, in frames: [Int64: CGRect]) -> Int64? { guard !frames.isEmpty else { return nil } // Exact hit first (cursor squarely inside a pill). if let hit = frames.first(where: { $0.value.contains(point) })?.key { return hit } @@ -50,83 +40,179 @@ struct PinboardDropDelegate: DropDelegate { } return frames.min(by: { abs($0.value.midX - point.x) < abs($1.value.midX - point.x) })?.key } +} + +/// A drag that started in the shelf, as recorded when it began. +enum ShelfDrag { + /// One card, or a multi-selection's uuids in order. + case cards([String]) + /// A pinboard tab being reordered. + case pinboard(Int64) + + enum Kind { case cards, pinboard } - func validateDrop(info: DropInfo) -> Bool { - let hasSupportedPayload = info.hasItemsConforming(to: [UTType.copyPinboard]) - || info.hasItemsConforming(to: [UTType.copyItem]) - return hasSupportedPayload && pinboard(at: info.location) != nil + var kind: Kind { + switch self { + case .cards: .cards + case .pinboard: .pinboard + } } +} - func dropEntered(info: DropInfo) { - updateTarget(for: info) +/// What the drop callout under the pinboard tabs shows (see `PinboardDropCalloutLayer`). +struct PinboardDropCallout: Equatable { + enum Phase { + /// A card drag is over the tab: names the board it will be filed into. + case targeting + /// The drop landed: a short confirmation before the callout folds away. + case filed } - func dropUpdated(info: DropInfo) -> DropProposal? { - let target = pinboard(at: info.location) - updateTarget(for: info) - let operation: DropOperation = info.hasItemsConforming(to: [UTType.copyPinboard]) - ? .move - : .copy - return DropProposal(operation: target != nil ? operation : .cancel) + var pinboardID: Int64 + /// Cards being dragged, or cards actually added once filed. + var count: Int + var phase: Phase + /// Tells two otherwise equal callouts apart, so a confirmation's timer only clears + /// the confirmation it started. + let token = UUID() +} + +/// The callout that pops out of the pinboard tab under a card drag, naming the board the +/// card will land in (the tab alone is small, and the drag chip sits on top of it), then +/// briefly confirms the drop. It glides from tab to tab as the +/// drag moves along the row. Laid over the whole shelf in the `"shelfRoot"` space, where +/// the tab frames are measured, so it can hang below the header over the cards. +struct PinboardDropCalloutLayer: View { + let callout: PinboardDropCallout? + let pinboards: [Pinboard] + let tabFrames: [Int64: CGRect] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + if let callout, + let pinboard = pinboards.first(where: { $0.id == callout.pinboardID }), + let tab = tabFrames[callout.pinboardID] { + PinboardDropCalloutView(callout: callout, pinboard: pinboard, + arrowX: tab.width / 2 + Self.leadingInset) + // Positioned by alignment guides, not `.offset`, so the view's real frame + // sits under the tab: the pop-out transition then scales from the tab, + // and moving to another tab animates as a layout change. + .alignmentGuide(.leading) { _ in -(tab.minX - Self.leadingInset) } + .alignmentGuide(.top) { _ in -(tab.maxY + 3) } + .transition(reduceMotion + ? .opacity + : .scale(scale: 0.35, anchor: UnitPoint(x: 0.12, y: 0)).combined(with: .opacity)) + } + } + .allowsHitTesting(false) + .animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(response: 0.34, dampingFraction: 0.72), + value: callout) } - func dropExited(info: DropInfo) { - clearTargets() + /// How far the callout starts left of its tab, so short names still read as hanging + /// from the tab rather than starting exactly at its edge. + private static let leadingInset: CGFloat = 6 +} + +private struct PinboardDropCalloutView: View { + let callout: PinboardDropCallout + let pinboard: Pinboard + /// The arrow's x position, from the callout's leading edge. + let arrowX: CGFloat + + private var boardColor: Color { + pinboard.tint.isEmpty ? .accentColor : Tokens.color(fromHex: pinboard.tint) } - func performDrop(info: DropInfo) -> Bool { - guard let id = pinboard(at: info.location) else { return false } - - if let provider = info.itemProviders(for: [UTType.copyPinboard]).first { - let placeAfterTarget = placesAfterTarget(id, at: info.location) - clearTargets() - provider.loadDataRepresentation(forTypeIdentifier: UTType.copyPinboard.identifier) { data, _ in - guard let data, - let rawID = String(data: data, encoding: .utf8), - let sourceID = Int64(rawID) else { return } - DispatchQueue.main.async { - onMove(sourceID, id, placeAfterTarget) - clearTargets() - } - } - return true + private var isFiled: Bool { callout.phase == .filed } + + private var caption: String { + switch (callout.phase, callout.count) { + case (.targeting, 1): "Add to" + case (.targeting, let count): "Add \(count) items to" + case (.filed, 1): "Added to" + case (.filed, let count): "Added \(count) items to" } + } - clearTargets() - guard let provider = info.itemProviders(for: [UTType.copyItem]).first else { return false } - provider.loadDataRepresentation(forTypeIdentifier: UTType.copyItem.identifier) { data, _ in - guard let data, let payload = String(data: data, encoding: .utf8) else { return } - let uuids = payload.split(separator: "\n").map(String.init) - guard !uuids.isEmpty else { return } - DispatchQueue.main.async { - onFile(id, uuids) - // Clear the highlight again after any trailing dropUpdated: SwiftUI doesn't - // call dropExited after a successful drop, so without this the filed-into - // tab keeps its drop border. - clearTargets() + var body: some View { + HStack(spacing: 8) { + badge + VStack(alignment: .leading, spacing: 0) { + Text(caption) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + Text(pinboard.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) } + .contentTransition(.interpolate) } - return true + .padding(.leading, 8) + .padding(.trailing, 12) + .padding(.vertical, 6) + .padding(.top, CalloutShape.arrowHeight) + .fixedSize() + // Plain system material with a hairline, like a native popover: the board's color + // lives only in the badge, the same small dose of it the tab itself carries. + .background { + let shape = CalloutShape(arrowX: arrowX) + shape.fill(.regularMaterial) + shape.stroke(Color.primary.opacity(0.12), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.15), radius: 8, y: 3) + .accessibilityElement(children: .combine) } - private func updateTarget(for info: DropInfo) { - let target = pinboard(at: info.location) - if info.hasItemsConforming(to: [UTType.copyPinboard]) { - onFileTargetChange(nil) - onReorderTargetChange(target, target.map { placesAfterTarget($0, at: info.location) } ?? false) - } else { - onReorderTargetChange(nil, false) - onFileTargetChange(target) + /// The board's emoji, else its color, in a small disc; a check once the drop lands. + @ViewBuilder private var badge: some View { + ZStack { + Circle().fill(boardColor.opacity(isFiled ? 0.9 : 0.22)) + if isFiled { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .transition(.scale(scale: 0.2).combined(with: .opacity)) + } else if let emoji = pinboard.emoji, !emoji.isEmpty { + Text(emoji).font(.system(size: 13)) + } else { + Image(systemName: "tray.and.arrow.down.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(boardColor) + } } + .frame(width: 24, height: 24) } +} + +/// A rounded bubble with a small arrow on top, pointing up at the tab. +private struct CalloutShape: Shape { + static let arrowHeight: CGFloat = 6 + private static let arrowWidth: CGFloat = 14 + private static let cornerRadius: CGFloat = 10 + + var arrowX: CGFloat - private func placesAfterTarget(_ id: Int64, at point: CGPoint) -> Bool { - guard let frame = tabFrames()[id] else { return false } - return point.x >= frame.midX + var animatableData: CGFloat { + get { arrowX } + set { arrowX = newValue } } - private func clearTargets() { - onFileTargetChange(nil) - onReorderTargetChange(nil, false) + func path(in rect: CGRect) -> Path { + let body = CGRect(x: rect.minX, y: rect.minY + Self.arrowHeight, + width: rect.width, height: rect.height - Self.arrowHeight) + let bubble = Path(roundedRect: body, cornerRadius: Self.cornerRadius, style: .continuous) + let halfWidth = Self.arrowWidth / 2 + let x = min(max(rect.minX + arrowX, body.minX + Self.cornerRadius + halfWidth), + body.maxX - Self.cornerRadius - halfWidth) + var arrow = Path() + arrow.move(to: CGPoint(x: x - halfWidth, y: body.minY + 1)) + arrow.addQuadCurve(to: CGPoint(x: x, y: rect.minY), control: CGPoint(x: x - 2, y: body.minY)) + arrow.addQuadCurve(to: CGPoint(x: x + halfWidth, y: body.minY + 1), control: CGPoint(x: x + 2, y: body.minY)) + arrow.closeSubpath() + return bubble.union(arrow) } } diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index 236272b..d1cc39c 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -1,5 +1,7 @@ import AppKit import CopyCore +import SwiftUI +import UniformTypeIdentifiers final class KeyablePanel: NSPanel { override var canBecomeKey: Bool { true } @@ -47,6 +49,102 @@ private final class ShelfClippingView: NSView { } } +/// The shelf's SwiftUI host, and the only drag destination in the shelf: it takes both +/// drags that end on a pinboard tab — a card (or selection) to file into it, and a tab to +/// reorder — in AppKit, following each from start to end and resolving the tab under the +/// pointer from the frames the view model holds. +/// +/// The shelf deliberately has no SwiftUI `.onDrop`. That modifier installs its own +/// full-size drop view inside this one, registered for `public.item`, so it claims every +/// drag over the shelf and AppKit never asks this view; it then rejected card drags +/// outright (they slid back with no highlight), and a drag that starts in the card row +/// stops getting SwiftUI drop updates once it crosses into the header. Points are reported +/// in this view's top-left space, the same space the tab frames use. +final class ShelfHostingView: NSHostingView { + /// The drag started in the shelf, if one is in progress. Used when the drag pasteboard + /// doesn't list the card or pinboard type by name (it can be promised lazily). + var activeShelfDrag: () -> ShelfDrag? = { nil } + /// The pointer moved to `point`; returns whether a pinboard tab is under it. + var onDragMoved: ((ShelfDrag.Kind, CGPoint) -> Bool)? + /// The drag left the shelf; it may come back. + var onDragExited: (() -> Void)? + /// The drag session ended, dropped or not. + var onDragEnded: (() -> Void)? + /// Cards dropped at `point` with the payload's uuids (nil if unreadable); returns success. + var onCardDrop: ((CGPoint, [String]?) -> Bool)? + /// A pinboard tab dropped at `point` with its id (nil if unreadable); returns success. + var onPinboardDrop: ((CGPoint, Int64?) -> Bool)? + + private static let cardType = NSPasteboard.PasteboardType(UTType.copyItem.identifier) + private static let pinboardType = NSPasteboard.PasteboardType(UTType.copyPinboard.identifier) + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + registerForDraggedTypes([Self.cardType, Self.pinboardType]) + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + operation(for: sender) + } + + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + operation(for: sender) + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + onDragExited?() + } + + override func draggingEnded(_ sender: NSDraggingInfo) { + onDragEnded?() + } + + override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool { + dragKind(sender) != nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + let point = shelfPoint(for: sender) + let payload = sender.draggingPasteboard + switch dragKind(sender) { + case .cards: + let uuids = payload.data(forType: Self.cardType) + .flatMap { String(data: $0, encoding: .utf8) } + .map { $0.split(separator: "\n").map(String.init) } + return onCardDrop?(point, uuids) ?? false + case .pinboard: + let id = payload.data(forType: Self.pinboardType) + .flatMap { String(data: $0, encoding: .utf8) } + .flatMap { Int64($0) } + return onPinboardDrop?(point, id) ?? false + case nil: + return false + } + } + + /// What the drag carries: a pinboard tab or cards started in the shelf, or nil for + /// anything else (drags from other apps), which the shelf doesn't accept. + private func dragKind(_ sender: NSDraggingInfo) -> ShelfDrag.Kind? { + let types = sender.draggingPasteboard.types ?? [] + if types.contains(Self.pinboardType) { return .pinboard } + if types.contains(Self.cardType) { return .cards } + guard sender.draggingSource != nil else { return nil } + return activeShelfDrag()?.kind + } + + private func operation(for sender: NSDraggingInfo) -> NSDragOperation { + guard let kind = dragKind(sender), + onDragMoved?(kind, shelfPoint(for: sender)) == true else { return [] } + return kind == .cards ? .copy : .move + } + + /// The drag location in this view's top-left coordinate space. + private func shelfPoint(for sender: NSDraggingInfo) -> CGPoint { + let point = convert(sender.draggingLocation, from: nil) + return isFlipped ? point : CGPoint(x: point.x, y: bounds.height - point.y) + } +} + @MainActor final class ShelfPanelController: NSObject, NSWindowDelegate { static let shelfHeight: CGFloat = 352 diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index ea6fba1..683345b 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -7,9 +7,6 @@ import UniformTypeIdentifiers struct ShelfRootView: View { @Bindable var viewModel: ShelfViewModel @State private var permissionBannerDismissed = false - /// Pinboard tab frames (id → frame in the "shelfRoot" space), published by each tab and - /// consumed by the shelf-level `PinboardDropDelegate` to route a drop to the right tab. - @State private var pinboardTabFrames: [Int64: CGRect] = [:] /// Persisted so the keyboard legend, once dismissed, stays gone. Read once here; /// `dismissLegend()` writes it back. Defaults to shown (false) for new users. @State private var legendDismissed = UserDefaults.standard.bool(forKey: Self.legendDismissedKey) @@ -63,34 +60,18 @@ struct ShelfRootView: View { topTrailingRadius: 12, style: .continuous )) - // Card → pinboard filing is handled here, at the shelf root, because a per-tab - // `.onDrop` never establishes a working drop region on the small pills inside this - // borderless non-activating glass panel (a shelf-level drop, by contrast, fires - // reliably). The delegate maps the drop location to the tab under it using each - // tab's frame, collected via PinboardTabFramesKey below. + // Drags onto the pinboard tabs (cards to file, tabs to reorder) are taken by the + // hosting view in AppKit, not by a SwiftUI `.onDrop` (see `ShelfHostingView`). It + // maps the pointer to a tab using each tab's frame, collected via + // PinboardTabFramesKey below. .coordinateSpace(name: "shelfRoot") - .onPreferenceChange(PinboardTabFramesKey.self) { pinboardTabFrames = $0 } - .onDrop(of: [UTType.copyItem, UTType.copyPinboard], delegate: PinboardDropDelegate( - tabFrames: { pinboardTabFrames }, - onFileTargetChange: { viewModel.dropTargetedPinboardID = $0 }, - onReorderTargetChange: { id, placeAfterTarget in - viewModel.reorderTargetedPinboardID = id - viewModel.reorderPlacesAfterTarget = placeAfterTarget - }, - onFile: { id, uuids in - guard let pinboard = viewModel.pinboards.first(where: { $0.id == id }) else { return } - viewModel.dropItems(uuids: uuids, toPinboard: pinboard) - // Open the pinboard we just filed into, so the drop's result shows at once. - viewModel.tab = .pinboard(id) - }, - onMove: { sourceID, targetID, placeAfterTarget in - viewModel.movePinboard( - id: sourceID, - relativeTo: targetID, - placeAfterTarget: placeAfterTarget - ) - } - )) + .onPreferenceChange(PinboardTabFramesKey.self) { viewModel.pinboardTabFrames = $0 } + // Pops out of the tab under a card drag to name the board it will land in. + .overlay { + PinboardDropCalloutLayer(callout: viewModel.dropCallout, + pinboards: viewModel.pinboards, + tabFrames: viewModel.pinboardTabFrames) + } // Pro-dark: force the marketing electric-blue accent regardless of the system // accent color. The forced dark appearance itself is set on the panel window in // `ShelfPanelController`, which cascades to this hosted content. @@ -354,21 +335,23 @@ private struct ShelfTabs: View { } .onDrag { guard let id = pinboard.id else { return NSItemProvider() } + viewModel.pinboardDragStarted(id: id) let data = Data(String(id).utf8) let provider = NSItemProvider() provider.registerDataRepresentation( forTypeIdentifier: UTType.copyPinboard.identifier, - visibility: .ownProcess + // `.all` so the type is listed on the drag pasteboard: AppKit only offers a drag + // to `ShelfHostingView` when one of its registered types is there. + visibility: .all ) { completion in completion(data, nil) return nil } return provider } - // Publish this tab's frame (in the shelf's "shelfRoot" space) so the - // shelf-level PinboardDropDelegate can map a drop location back to this - // pinboard. Drops are handled at the shelf root, not per-tab — see - // PinboardDropDelegate for why. + // Publish this tab's frame (in the shelf's "shelfRoot" space) so + // ShelfHostingView can map a drag location back to this pinboard. Drops are + // handled for the whole shelf, not per-tab — see PinboardTabFramesKey for why. .background( GeometryReader { geo in Color.clear.preference( @@ -444,6 +427,9 @@ private struct TabPill: View { tint.isEmpty ? nil : Tokens.color(fromHex: tint) } + /// The board's own color when it has one, so the targeted tab matches its callout. + private var dropColor: Color { tintColor ?? .accentColor } + var body: some View { HStack(spacing: 4) { if let emoji, !emoji.isEmpty { @@ -478,7 +464,7 @@ private struct TabPill: View { ) .overlay( RoundedRectangle(cornerRadius: 6) - .stroke(isDropTargeted ? Color.accentColor : .clear, lineWidth: 2) + .stroke(isDropTargeted ? dropColor : .clear, lineWidth: 2) ) .overlay(alignment: reorderIndicatorEdge == .trailing ? .trailing : .leading) { if reorderIndicatorEdge != nil { @@ -489,13 +475,13 @@ private struct TabPill: View { } } // A drop-targeted tab visibly pops so it's unmistakable which pinboard a dragged - // card will land in, even when the cursor's drag chip sits near it. + // card will land in, even when the cursor's drag chip sits near it; the drop + // callout hangs from it (see `PinboardDropCalloutLayer`). .scaleEffect(isDropTargeted ? 1.08 : 1) .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isDropTargeted) - // A plain tappable surface, NOT a Button, so the `.onDrop` each tab carries in - // `ShelfTabs` is a reliable drop target. A SwiftUI `Button` on macOS fights the + // A plain tappable surface, NOT a Button: a SwiftUI `Button` on macOS fights the // drag session for the mouse-up, so dragging a card onto a pinboard landed only - // intermittently; a tap gesture on a plain view coexists with `.onDrop` cleanly. + // intermittently; a tap gesture on a plain view coexists with the drag cleanly. .contentShape(Rectangle()) .onTapGesture { action() } .accessibilityElement(children: .combine) @@ -504,7 +490,7 @@ private struct TabPill: View { } private var backgroundFill: Color { - if isDropTargeted { return Color.accentColor.opacity(0.24) } + if isDropTargeted { return dropColor.opacity(0.24) } if isSelected { if let tintColor { return tintColor.opacity(0.18) } return Color.primary.opacity(0.08) diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 0e9d4f3..d4d5b59 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -86,12 +86,24 @@ final class ShelfViewModel { /// The uuid of the card the pointer is currently over, so a force-click can act on /// exactly the card under the cursor (set by `ItemCardView`'s hover callback). var hoveredItemID: String? - /// The pinboard tab currently under an in-flight card drag, so that tab highlights. - /// Driven by the shelf-level drop delegate (see `PinboardDropDelegate`) rather than a - /// per-tab `.onDrop`, which never established a working drop region on the small pills. + /// The pinboard tab a dragged tab would land beside, and on which side, so that tab + /// shows its insertion edge. Driven by `ShelfHostingView` (see `shelfDragMoved`). var reorderTargetedPinboardID: Int64? var reorderPlacesAfterTarget = false + /// The pinboard tab currently under an in-flight card drag, so that tab highlights. + /// Driven by `ShelfHostingView` (see `shelfDragMoved`). var dropTargetedPinboardID: Int64? + /// The callout that pops out under a pinboard tab: which board a card drag is about to + /// file into, then a short confirmation once it has. See `PinboardDropCalloutView`. + private(set) var dropCallout: PinboardDropCallout? + /// Each pinboard tab's frame in the shelf's top-left coordinate space, published by + /// `ShelfRootView`. Read by `ShelfHostingView`'s drag handling to find the tab under + /// the pointer. Not observed: nothing redraws from it. + @ObservationIgnored var pinboardTabFrames: [Int64: CGRect] = [:] + /// What the drag started in the shelf carries, recorded when the drag begins. A + /// fallback for when the drag pasteboard can't name or hand over the payload yet. + /// Cleared once the drag ends over the shelf, and replaced by the next drag. + @ObservationIgnored private(set) var activeShelfDrag: ShelfDrag? /// Set by a force-click (which fires before the click's own mouse-up resolves) so the /// release doesn't then paste the card. Consumed by the next `handleCardClick`. @ObservationIgnored var suppressNextCardPaste = false @@ -377,6 +389,8 @@ final class ShelfViewModel { cachedApps = [] isSearchFieldFocused = false focusSearchRequested = false + shelfDragEnded() + dropCallout = nil endSearchEditingRequested = false } @@ -791,11 +805,112 @@ final class ShelfViewModel { return (try? store.recentItems(limit: 1_000))?.first(where: { $0.uuid == uuid }) } + /// A pinboard tab drag started (from the tab's `.onDrag`). + func pinboardDragStarted(id: Int64) { + activeShelfDrag = .pinboard(id) + } + + /// A shelf drag moved to `point` (from `ShelfHostingView`). Highlights the pinboard tab + /// under it — the filing target for cards, the insertion edge for a dragged tab — and + /// returns whether there is one, which decides whether the drop is accepted there. + func shelfDragMoved(_ kind: ShelfDrag.Kind, to point: CGPoint) -> Bool { + let target = PinboardTabHitTest.pinboard(at: point, in: pinboardTabFrames) + switch kind { + case .cards: + if dropTargetedPinboardID != target { + dropTargetedPinboardID = target + var count = 1 + if case .cards(let uuids) = activeShelfDrag { count = max(uuids.count, 1) } + dropCallout = target.map { PinboardDropCallout(pinboardID: $0, count: count, phase: .targeting) } + // A light tap on the trackpad as the drag lands on a board. + if target != nil { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .now) + } + } + case .pinboard: + reorderTargetedPinboardID = target + reorderPlacesAfterTarget = target.map { placesAfterTab($0, at: point) } ?? false + } + return target != nil + } + + /// The drag left the shelf (it may come back) or ended: drop every tab highlight. + func shelfDragExited() { + dropTargetedPinboardID = nil + if dropCallout?.phase == .targeting { dropCallout = nil } + reorderTargetedPinboardID = nil + reorderPlacesAfterTarget = false + } + + /// The drag session ended, dropped or not. + func shelfDragEnded() { + shelfDragExited() + activeShelfDrag = nil + } + + /// Files dropped card uuid(s) into the pinboard tab under `point`, then opens that tab + /// so the result shows at once. `payloadUUIDs` is nil when the drag pasteboard couldn't + /// hand them over; the uuids recorded at drag start are used instead. Returns false + /// when the drop wasn't on a tab. + func fileDroppedCards(_ payloadUUIDs: [String]?, at point: CGPoint) -> Bool { + var recorded: [String] = [] + if case .cards(let uuids) = activeShelfDrag { recorded = uuids } + shelfDragEnded() + let uuids = payloadUUIDs ?? recorded + guard !uuids.isEmpty, + let id = PinboardTabHitTest.pinboard(at: point, in: pinboardTabFrames), + let pinboard = pinboards.first(where: { $0.id == id }) else { + return false + } + let added = dropItems(uuids: uuids, toPinboard: pinboard) + tab = .pinboard(id) + if added > 0 { showFiledCallout(pinboardID: id, count: added) } + return true + } + + /// Moves the dropped pinboard tab before or after the tab under `point`. `payloadID` + /// is nil when the drag pasteboard couldn't hand it over; the id recorded at drag start + /// is used instead. Returns false when the drop wasn't on a tab. + func moveDroppedPinboard(_ payloadID: Int64?, at point: CGPoint) -> Bool { + var recorded: Int64? + if case .pinboard(let id) = activeShelfDrag { recorded = id } + shelfDragEnded() + guard let sourceID = payloadID ?? recorded, + let targetID = PinboardTabHitTest.pinboard(at: point, in: pinboardTabFrames) else { + return false + } + if sourceID != targetID { + movePinboard(id: sourceID, relativeTo: targetID, + placeAfterTarget: placesAfterTab(targetID, at: point)) + } + return true + } + + /// Turns the drop callout into its "added" confirmation for a moment, then lets it + /// fold back into the tab, unless a newer drag has taken the callout over by then. + private func showFiledCallout(pinboardID: Int64, count: Int) { + let callout = PinboardDropCallout(pinboardID: pinboardID, count: count, phase: .filed) + dropCallout = callout + DispatchQueue.main.asyncAfter(deadline: .now() + 1.4) { [weak self] in + guard let self, self.dropCallout == callout else { return } + self.dropCallout = nil + } + } + + /// Whether `point` is past the middle of the tab, so a dragged tab goes after it. + private func placesAfterTab(_ id: Int64, at point: CGPoint) -> Bool { + guard let frame = pinboardTabFrames[id] else { return false } + return point.x >= frame.midX + } + /// Handles a card (or a whole multi-selection) dropped onto a pinboard tab. /// `uuids` is one uuid for a single-card drag, or the ordered selection's uuids /// for a multi-selection drag (see `ShelfViewModel.multiDragProvider()`). - func dropItems(uuids: [String], toPinboard pinboard: Pinboard) { - guard let id = pinboard.id else { return } + /// Returns how many were added. Success is confirmed by the drop callout under the tab + /// (see `showFiledCallout`), so only a failure shows the HUD. + @discardableResult + func dropItems(uuids: [String], toPinboard pinboard: Pinboard) -> Int { + guard let id = pinboard.id else { return 0 } var added = 0 for uuid in uuids { guard let item = item(forUUID: uuid), let itemID = item.id else { continue } @@ -806,11 +921,8 @@ final class ShelfViewModel { NSLog("Copy: failed to add item to pinboard: \(error)") } } - switch added { - case 0: HUD.show("Couldn't complete that") - case 1: HUD.show("Added to \(pinboard.name)") - default: HUD.show("Added \(added) items to \(pinboard.name)") - } + if added == 0 { HUD.show("Couldn't complete that") } + return added } /// Called from a card's "Edit…" context menu item (with that card's item) and the @@ -950,9 +1062,17 @@ final class ShelfViewModel { /// selection (or when only one card is selected) keeps today's single-item /// behavior unchanged. func dragProvider(for item: ClipItem) -> NSItemProvider { - if selection.selected.contains(item.uuid), selection.selected.count > 1 { + // Dragging a card that isn't selected selects it first, like Finder: the selection + // follows the drag, and the drag carries that card alone. Dragging a card inside a + // multi-selection still carries the whole selection. + if !selection.selected.contains(item.uuid) { + selection.click(item.uuid) + } + if selection.selected.count > 1 { + activeShelfDrag = .cards(orderedSelectedItems.map(\.uuid)) return multiDragProvider() } + activeShelfDrag = .cards([item.uuid]) let provider = contentProvider(for: item) let uuid = item.uuid provider.registerDataRepresentation(forTypeIdentifier: UTType.copyItem.identifier, visibility: .all) { completion in @@ -972,7 +1092,7 @@ final class ShelfViewModel { /// text-accepting app (TextEdit, a browser field, Notes...) drops all selected /// items together. It also carries every selected uuid (newline-joined) under the /// internal `copyItem` type, so dropping the whole selection onto a pinboard tab - /// files all of them — see `ShelfRootView`'s pinboard `.onDrop` and `dropItems(uuids:toPinboard:)`. + /// files all of them — see `ShelfHostingView` and `dropItems(uuids:toPinboard:)`. private func multiDragProvider() -> NSItemProvider { let selected = orderedSelectedItems let joinedText = selected.map { $0.plainText ?? "" }.joined(separator: "\n")