WIP: drag cards to collections
This commit is contained in:
@@ -16,7 +16,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
||||
- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, and optional local OCR for copied images
|
||||
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
|
||||
- Custom named collections for organizing clips from the card context menu
|
||||
- Custom named collections for organizing clips from the card context menu or by dragging cards onto collection chips
|
||||
- Copy and paste actions with Accessibility permission fallback
|
||||
- Image thumbnail cache with byte and file-count pruning
|
||||
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
|
||||
|
||||
@@ -42,6 +42,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
|
||||
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
12. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected.
|
||||
|
||||
## Copy And Paste
|
||||
|
||||
|
||||
@@ -389,6 +389,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
chip.onPress = { [weak self] in
|
||||
self?.viewModel.selectCollection(named: collectionName)
|
||||
}
|
||||
chip.onDropItem = { [weak self] itemID in
|
||||
self?.viewModel.assignItem(withID: itemID, to: collectionName)
|
||||
}
|
||||
customCollectionButtons[collectionName] = chip
|
||||
collectionStack.addArrangedSubview(chip)
|
||||
}
|
||||
@@ -1124,6 +1127,15 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
addSelectedClipToCollection()
|
||||
}
|
||||
|
||||
func debugDropFirstCard(onCollectionNamed collectionName: String) {
|
||||
guard let itemID = cardViews.first?.debugItemID else { return }
|
||||
customCollectionButtons[collectionName]?.debugDropItem(itemID)
|
||||
}
|
||||
|
||||
var debugCustomCollectionDropTargets: [String] {
|
||||
viewModel.collectionNames.filter { customCollectionButtons[$0]?.debugAcceptsItemDrops == true }
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
@@ -1207,6 +1219,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private enum ClipboardItemDragPasteboard {
|
||||
static let itemIDType = NSPasteboard.PasteboardType("com.clipbored.clipboard-item-id")
|
||||
static let acceptedTypes: [NSPasteboard.PasteboardType] = [
|
||||
itemIDType,
|
||||
.string,
|
||||
.URL,
|
||||
.fileURL,
|
||||
.tiff,
|
||||
.png,
|
||||
.pdf,
|
||||
.sound,
|
||||
.rtf
|
||||
]
|
||||
}
|
||||
|
||||
private enum ClipboardCardDragContext {
|
||||
static var itemID: UUID?
|
||||
}
|
||||
|
||||
private final class CollectionChipView: NSView {
|
||||
let titleText: String
|
||||
private let color: NSColor
|
||||
@@ -1215,7 +1246,9 @@ private final class CollectionChipView: NSView {
|
||||
private let countLabel = NSTextField(labelWithString: "0")
|
||||
private(set) var isSelected = false
|
||||
private(set) var count = 0
|
||||
private var isDropTargeted = false
|
||||
var onPress: () -> Void = {}
|
||||
var onDropItem: ((UUID) -> Void)?
|
||||
|
||||
init(title: String, color: NSColor) {
|
||||
self.titleText = title
|
||||
@@ -1238,6 +1271,7 @@ private final class CollectionChipView: NSView {
|
||||
setAccessibilityRole(.button)
|
||||
setAccessibilityLabel(titleText)
|
||||
heightAnchor.constraint(equalToConstant: 26).isActive = true
|
||||
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
|
||||
|
||||
dot.wantsLayer = true
|
||||
dot.layer?.cornerRadius = 4
|
||||
@@ -1291,7 +1325,20 @@ private final class CollectionChipView: NSView {
|
||||
? NSColor.controlAccentColor.withAlphaComponent(0.16)
|
||||
: NSColor.labelColor.withAlphaComponent(0.07)
|
||||
).cgColor
|
||||
if selected {
|
||||
updateChrome()
|
||||
}
|
||||
|
||||
private func setDropTargeted(_ targeted: Bool) {
|
||||
guard isDropTargeted != targeted else { return }
|
||||
isDropTargeted = targeted
|
||||
updateChrome()
|
||||
}
|
||||
|
||||
private func updateChrome() {
|
||||
if isDropTargeted {
|
||||
layer?.backgroundColor = color.withAlphaComponent(0.18).cgColor
|
||||
layer?.borderColor = color.withAlphaComponent(0.68).cgColor
|
||||
} else if isSelected {
|
||||
layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.58).cgColor
|
||||
layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.34).cgColor
|
||||
} else {
|
||||
@@ -1315,6 +1362,60 @@ private final class CollectionChipView: NSView {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
onPress()
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
guard onDropItem != nil, draggedItemID(from: sender) != nil else { return [] }
|
||||
setDropTargeted(true)
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
guard onDropItem != nil, draggedItemID(from: sender) != nil else {
|
||||
setDropTargeted(false)
|
||||
return []
|
||||
}
|
||||
setDropTargeted(true)
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
setDropTargeted(false)
|
||||
}
|
||||
|
||||
override func prepareForDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
onDropItem != nil && draggedItemID(from: sender) != nil
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
guard let itemID = draggedItemID(from: sender), let onDropItem else { return false }
|
||||
onDropItem(itemID)
|
||||
setDropTargeted(false)
|
||||
return true
|
||||
}
|
||||
|
||||
override func concludeDragOperation(_ sender: NSDraggingInfo?) {
|
||||
setDropTargeted(false)
|
||||
}
|
||||
|
||||
private func draggedItemID(from sender: NSDraggingInfo) -> UUID? {
|
||||
if let itemID = ClipboardCardDragContext.itemID {
|
||||
return itemID
|
||||
}
|
||||
|
||||
return sender.draggingPasteboard
|
||||
.string(forType: ClipboardItemDragPasteboard.itemIDType)
|
||||
.flatMap(UUID.init(uuidString:))
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugAcceptsItemDrops: Bool {
|
||||
onDropItem != nil
|
||||
}
|
||||
|
||||
func debugDropItem(_ itemID: UUID) {
|
||||
onDropItem?(itemID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private final class AspectFillImageView: NSView {
|
||||
@@ -1405,6 +1506,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
var onDelete: (Int) -> Void = { _ in }
|
||||
|
||||
private let index: Int
|
||||
private let itemID: UUID
|
||||
private let itemKind: ClipboardItemKind
|
||||
private let itemIsPinned: Bool
|
||||
private let itemIsStacked: Bool
|
||||
@@ -1434,6 +1536,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
stackCount: Int = 0
|
||||
) {
|
||||
self.index = index
|
||||
self.itemID = item.id
|
||||
self.itemKind = item.kind
|
||||
self.itemIsPinned = item.isPinned
|
||||
self.itemIsStacked = isStacked
|
||||
@@ -1516,11 +1619,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
|
||||
mouseDownLocation = nil
|
||||
let writers = onPasteboardWriters(index)
|
||||
guard !writers.isEmpty else { return }
|
||||
let dragWriters = writers.isEmpty ? [internalDragPasteboardItem()] : writers
|
||||
onSelect(index)
|
||||
ClipboardCardDragContext.itemID = itemID
|
||||
|
||||
let preview = dragPreviewImage()
|
||||
let dragItems = writers.enumerated().map { offset, writer in
|
||||
let dragItems = dragWriters.enumerated().map { offset, writer in
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: writer)
|
||||
let offsetAmount = CGFloat(offset) * 4
|
||||
let frame = NSRect(
|
||||
@@ -1540,6 +1644,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
}
|
||||
}
|
||||
|
||||
private func internalDragPasteboardItem() -> NSPasteboardItem {
|
||||
let pasteboardItem = NSPasteboardItem()
|
||||
pasteboardItem.setString(itemID.uuidString, forType: ClipboardItemDragPasteboard.itemIDType)
|
||||
return pasteboardItem
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
||||
.copy
|
||||
}
|
||||
@@ -1548,6 +1658,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
true
|
||||
}
|
||||
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
if ClipboardCardDragContext.itemID == itemID {
|
||||
ClipboardCardDragContext.itemID = nil
|
||||
}
|
||||
}
|
||||
|
||||
override func menu(for event: NSEvent) -> NSMenu? {
|
||||
onSelect(index)
|
||||
return contextMenu()
|
||||
@@ -1598,6 +1714,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
||||
var debugQuickPasteBadgeText: String? {
|
||||
quickPasteBadgeLabel?.stringValue
|
||||
}
|
||||
|
||||
var debugItemID: UUID {
|
||||
itemID
|
||||
}
|
||||
#endif
|
||||
|
||||
private func contextMenu() -> NSMenu {
|
||||
|
||||
@@ -417,13 +417,13 @@ final class ClipboardPanelViewModel {
|
||||
func assignSelected(to collectionName: String?) {
|
||||
guard let item = selectedItem else { return }
|
||||
selectedItemID = item.id
|
||||
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
|
||||
store.setCollection(item.id, name: normalizedName)
|
||||
if let normalizedName {
|
||||
statusMessage = "Added to \(normalizedName)"
|
||||
} else {
|
||||
statusMessage = "Removed from collection"
|
||||
assign(item: item, to: collectionName)
|
||||
}
|
||||
|
||||
func assignItem(withID id: UUID, to collectionName: String?) {
|
||||
guard let item = items.first(where: { $0.id == id }) else { return }
|
||||
selectedItemID = selectedItem?.id
|
||||
assign(item: item, to: collectionName)
|
||||
}
|
||||
|
||||
func ignoreSelectedSourceApp() {
|
||||
@@ -819,6 +819,16 @@ final class ClipboardPanelViewModel {
|
||||
return min(lhs, rhs)
|
||||
}
|
||||
|
||||
private func assign(item: ClipboardItem, to collectionName: String?) {
|
||||
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
|
||||
store.setCollection(item.id, name: normalizedName)
|
||||
if let normalizedName {
|
||||
statusMessage = "Added to \(normalizedName)"
|
||||
} else {
|
||||
statusMessage = "Removed from collection"
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceIgnoreRule(for item: ClipboardItem) -> (value: String, displayName: String)? {
|
||||
if let bundleID = item.sourceAppBundleId?.clipboardTrimmed, !bundleID.isEmpty {
|
||||
let sourceApp = item.sourceApp?.clipboardTrimmed
|
||||
|
||||
@@ -215,6 +215,30 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.visibleItems.isEmpty)
|
||||
}
|
||||
|
||||
func testAssignItemByIDAddsCollectionWithoutChangingSelection() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||
let first = makeTextItem("first clip", createdAt: Date(timeIntervalSince1970: 100))
|
||||
let second = makeTextItem("second clip", createdAt: Date(timeIntervalSince1970: 200))
|
||||
store.upsert(first)
|
||||
store.upsert(second)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
viewModel.selectItem(at: 0)
|
||||
|
||||
viewModel.assignItem(withID: first.id, to: "Pinned Research")
|
||||
store.flushPersistenceForTesting()
|
||||
waitForVisibleItems(in: viewModel, count: 2)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedItem?.id, second.id)
|
||||
XCTAssertEqual(viewModel.collectionNames, ["Pinned Research"])
|
||||
XCTAssertEqual(viewModel.collectionCount(named: "Pinned Research"), 1)
|
||||
XCTAssertEqual(viewModel.statusMessage, "Added to Pinned Research")
|
||||
}
|
||||
|
||||
func testSearchTextRecomputesVisibleItemsImmediately() {
|
||||
let settings = makeSettings()
|
||||
let cacheService = makeCacheService()
|
||||
|
||||
@@ -263,6 +263,33 @@ final class ClipboardPanelViewTests: XCTestCase {
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"])
|
||||
}
|
||||
|
||||
func testCardsCanDropOntoCollectionChipsToOrganize() {
|
||||
let fixture = makePanelFixture()
|
||||
var existing = makeTextItem("Existing client note", store: fixture.store)
|
||||
existing.collectionName = "Client Work"
|
||||
fixture.store.upsert(existing)
|
||||
let dropped = makeTextItem("Drop this note", store: fixture.store)
|
||||
fixture.store.upsert(dropped)
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Client Work"])
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionDropTargets, ["Client Work"])
|
||||
XCTAssertEqual(fixture.viewModel.visibleItems.first?.id, dropped.id)
|
||||
|
||||
fixture.view.debugDropFirstCard(onCollectionNamed: "Client Work")
|
||||
drainMainQueue()
|
||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Client Work")
|
||||
XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [2])
|
||||
|
||||
fixture.viewModel.selectCollection(named: "Client Work")
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(Set(fixture.viewModel.visibleItems.map(\.payload)), ["Existing client note", "Drop this note"])
|
||||
}
|
||||
|
||||
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
|
||||
let fixture = makePanelFixture()
|
||||
let names = [
|
||||
|
||||
Reference in New Issue
Block a user