From badc649b25030780e7b6e311ade1e76fe4a5c6d4 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 01:24:01 -0700 Subject: [PATCH] WIP: add clipboard card drag out --- .../clipbored/views/ClipboardPanelView.swift | 67 +++++++++++++++++-- .../views/ClipboardPanelViewModel.swift | 5 ++ .../PasteActionServiceTests.swift | 51 ++++++++++++++ 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index c495451..64686a9 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -492,6 +492,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { self?.viewModel.selectItem(at: selected) self?.viewModel.copySelected() } + card.onPasteboardWriters = { [weak self] selected in + self?.viewModel.pasteboardWriters(forItemAt: selected) ?? [] + } card.onOpen = { [weak self] selected in self?.viewModel.selectItem(at: selected) self?.viewModel.openSelected() @@ -1117,7 +1120,7 @@ private final class AspectFillImageView: NSView { } } -private final class ClipboardItemCardView: NSView { +private final class ClipboardItemCardView: NSView, NSDraggingSource { private enum Metrics { static let width: CGFloat = 320 static let height: CGFloat = 244 @@ -1128,6 +1131,7 @@ private final class ClipboardItemCardView: NSView { static let actionButtonSize: CGFloat = 24 static let primaryActionButtonSize: CGFloat = 30 static let actionRailHeight: CGFloat = 34 + static let dragThreshold: CGFloat = 4 } private enum Palette { static let border = NSColor.separatorColor.withAlphaComponent(0.20).cgColor @@ -1142,6 +1146,7 @@ private final class ClipboardItemCardView: NSView { var onSelect: (Int) -> Void = { _ in } var onPaste: (Int) -> Void = { _ in } var onCopy: (Int) -> Void = { _ in } + var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] } var onOpen: (Int) -> Void = { _ in } var onReveal: (Int) -> Void = { _ in } var onTogglePin: (Int) -> Void = { _ in } @@ -1161,6 +1166,7 @@ private final class ClipboardItemCardView: NSView { private weak var headerPinView: NSView? private var isSelected = false private var isHovered = false + private var mouseDownLocation: NSPoint? private var trackingAreaRef: NSTrackingArea? init(item: ClipboardItem, thumbnail: NSImage?, index: Int, collectionNames: [String] = []) { @@ -1226,12 +1232,55 @@ private final class ClipboardItemCardView: NSView { override func mouseDown(with event: NSEvent) { if event.clickCount == 2 { + mouseDownLocation = nil onPaste(index) } else { + mouseDownLocation = convert(event.locationInWindow, from: nil) onSelect(index) } } + override func mouseDragged(with event: NSEvent) { + guard let start = mouseDownLocation else { return } + let current = convert(event.locationInWindow, from: nil) + guard hypot(current.x - start.x, current.y - start.y) >= Metrics.dragThreshold else { + return + } + + mouseDownLocation = nil + let writers = onPasteboardWriters(index) + guard !writers.isEmpty else { return } + onSelect(index) + + let preview = dragPreviewImage() + let dragItems = writers.enumerated().map { offset, writer in + let draggingItem = NSDraggingItem(pasteboardWriter: writer) + let offsetAmount = CGFloat(offset) * 4 + let frame = NSRect( + x: bounds.minX + offsetAmount, + y: bounds.minY - offsetAmount, + width: bounds.width, + height: bounds.height + ) + draggingItem.setDraggingFrame(frame, contents: preview) + return draggingItem + } + + let session = beginDraggingSession(with: dragItems, event: event, source: self) + session.animatesToStartingPositionsOnCancelOrFail = true + if dragItems.count > 1 { + session.draggingFormation = .pile + } + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + .copy + } + + func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { + true + } + override func menu(for event: NSEvent) -> NSMenu? { onSelect(index) return contextMenu() @@ -1576,9 +1625,7 @@ private final class ClipboardItemCardView: NSView { kind.maximumNumberOfLines = 1 kind.toolTip = kind.stringValue - let source = NSTextField( - labelWithString: "\(Self.relativeDateText(for: item.createdAt)) \(sourceText(for: item))" - ) + let source = NSTextField(labelWithString: Self.relativeDateText(for: item.createdAt)) source.font = .systemFont(ofSize: 11, weight: .regular) source.textColor = NSColor.white.withAlphaComponent(0.72) source.lineBreakMode = .byTruncatingTail @@ -2119,6 +2166,18 @@ private final class ClipboardItemCardView: NSView { return footer } + private func dragPreviewImage() -> NSImage { + guard let representation = bitmapImageRepForCachingDisplay(in: bounds) else { + return NSImage(size: bounds.size) + } + representation.size = bounds.size + cacheDisplay(in: bounds, to: representation) + + let image = NSImage(size: bounds.size) + image.addRepresentation(representation) + return image + } + private func iconBadge(for item: ClipboardItem) -> NSView { let badge = NSView() badge.wantsLayer = true diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 07b601e..e7c767f 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -163,6 +163,11 @@ final class ClipboardPanelViewModel { settings.setPasteStatus(message: result.message) } + func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] { + guard index >= 0 && index < visibleItems.count else { return [] } + return pasteService.pasteboardWriters(for: visibleItems[index]) + } + func openSelected() { guard let item = selectedItem else { return } switch item.kind { diff --git a/tests/clipboredtests/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index e2ceb9c..4d459ba 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -32,6 +32,57 @@ final class PasteActionServiceTests: XCTestCase { XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello") } + func testPasteboardWritersExposeTextForDragOut() { + let service = PasteActionService() + let item = ClipboardItem( + id: UUID(), + kind: .text, + displayText: "Drag text", + payload: "Drag text", + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + let pasteboard = NSPasteboard.withUniqueName() + pasteboard.clearContents() + + XCTAssertTrue(pasteboard.writeObjects(service.pasteboardWriters(for: item))) + XCTAssertEqual(pasteboard.string(forType: .string), "Drag text") + } + + func testPasteboardWritersExposeURLAndTitleForDragOut() { + let service = PasteActionService() + let item = ClipboardItem( + id: UUID(), + kind: .url, + displayText: "Apple", + payload: "https://apple.com", + payloadHash: "hash", + createdAt: Date(), + lastUsedAt: Date(), + useCount: 0, + sourceApp: nil, + imagePath: nil, + thumbnailPath: nil + ) + + let pasteboard = NSPasteboard.withUniqueName() + pasteboard.clearContents() + + XCTAssertTrue(pasteboard.writeObjects(service.pasteboardWriters(for: item))) + XCTAssertEqual(pasteboard.string(forType: .string), "https://apple.com") + XCTAssertEqual(pasteboard.string(forType: .URL), "https://apple.com") + XCTAssertEqual( + pasteboard.string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")), + "Apple" + ) + } + func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() { let service = PasteActionService() let item = ClipboardItem(