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")