diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index 168016f..5b43910 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -250,6 +250,30 @@ final class AppController: NSObject, NSApplicationDelegate { copyToast.show() } + func editItem(_ item: ClipItem, launchWritingTools: Bool = false) { + suppressAutoHide = true + defer { suppressAutoHide = false } + + guard let edit = ClipEditor.run(for: item, launchWritingTools: launchWritingTools) else { return } + let changed: Bool + switch edit { + case let .text(text, richTextData): + changed = store.updateTextContent(text, richTextData: richTextData, for: item) + case let .color(hex): + changed = store.updateColorContent(hex, for: item) + } + guard changed else { return } + + // An explicit Edit makes the revised item the current clipboard too. + // Suppress the monitor so this write updates the existing record rather + // than capturing a duplicate history item. + if let updatedItem = store.item(withID: item.id) { + let change = PasteService.copy(updatedItem) + monitor.suppressUntilChangeCount = change + } + refreshQuickLookPreview() + } + func commandCopy() { if store.source == .pasteStack, let entry = pasteSequence.selectedEntry { copyItem(entry.item) @@ -258,6 +282,15 @@ final class AppController: NSObject, NSApplicationDelegate { copySelected() } + private func refreshQuickLookPreview() { + if store.source == .pasteStack { + QuickLookService.shared.refresh(items: pasteSequence.displayEntries.map(\.item), + selectedID: pasteSequence.selectedEntry?.item.id) + } else { + QuickLookService.shared.refresh(items: store.visibleItems, selectedID: store.selectedID) + } + } + func beginDragOut() { // Let AppKit establish the dragging session before taking the source // panel offscreen; the drag then continues naturally into another app. diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 6c81204..dab47f2 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -215,6 +215,99 @@ final class ClipboardStore { scheduleSave() } + /// Returns the latest canonical copy after an edit, including clips that + /// currently live only in a saved Paste Stack. + func item(withID id: UUID) -> ClipItem? { + if let item = history.first(where: { $0.id == id }) { return item } + if let item = pinboards.lazy.flatMap(\.items).first(where: { $0.id == id }) { return item } + return PasteSequence.shared.item(withID: id) + } + + /// Updates the actual text payload while preserving a clip's identity, + /// source metadata, creation date, and any separately assigned title. + @discardableResult + func updateTextContent(_ text: String, richTextData: Data? = nil, for item: ClipItem) -> Bool { + guard [.text, .richText, .link].contains(item.type), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + + let type: ClipType + if richTextData != nil { + type = .richText + } else { + type = isWebLink(text) ? .link : .text + } + + return updateContent(for: item) { existing in + var updated = existing + updated.type = type + updated.text = text + updated.rtfData = richTextData + updated.colorHex = nil + return updated + } + } + + @discardableResult + func updateColorContent(_ hex: String, for item: ClipItem) -> Bool { + guard item.type == .color, let color = NSColor(hex: hex) else { return false } + let normalizedHex = color.hexString + + return updateContent(for: item) { existing in + var updated = existing + updated.type = .color + updated.text = nil + updated.rtfData = nil + updated.colorHex = normalizedHex + return updated + } + } + + @discardableResult + private func updateContent(for item: ClipItem, + transform: (ClipItem) -> ClipItem) -> Bool { + var changed = false + + if let i = history.firstIndex(where: { $0.id == item.id }) { + let updated = transform(history[i]) + if updated != history[i] { + history[i] = updated + changed = true + } + } + for boardIndex in pinboards.indices { + for itemIndex in pinboards[boardIndex].items.indices where pinboards[boardIndex].items[itemIndex].id == item.id { + let updated = transform(pinboards[boardIndex].items[itemIndex]) + if updated != pinboards[boardIndex].items[itemIndex] { + pinboards[boardIndex].items[itemIndex] = updated + changed = true + } + } + } + + if PasteSequence.shared.updateItem(item.id, transform: transform) { + changed = true + } + guard changed else { return false } + + // An edited clip can stop matching the active search. Keep navigation + // and the inline preview attached to a card that is still visible. + if source != .pasteStack, selectedItem == nil { + selectFirst() + } + scheduleSave() + return true + } + + private func isWebLink(_ text: String) -> Bool { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.contains(" "), !value.contains("\n"), + let url = URL(string: value), + let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), + url.host != nil else { return false } + return true + } + func selectFirst() { selectedID = visibleItems.first?.id } func prepareForBarPresentation() { diff --git a/Sources/Pesty/Store/PasteSequence.swift b/Sources/Pesty/Store/PasteSequence.swift index e7d4251..8c412a1 100644 --- a/Sources/Pesty/Store/PasteSequence.swift +++ b/Sources/Pesty/Store/PasteSequence.swift @@ -17,6 +17,13 @@ struct PasteStackEntry: Identifiable, Codable { self.isPasted = false } + init(id: UUID, item: ClipItem, imagePreview: NSImage?, isPasted: Bool) { + self.id = id + self.item = item + self.imagePreview = imagePreview + self.isPasted = isPasted + } + private enum CodingKeys: String, CodingKey { case id, item, isPasted } init(from decoder: Decoder) throws { @@ -97,6 +104,14 @@ final class PasteSequence { return entries.first(where: { $0.id == selectedEntryID }) } + func item(withID id: UUID) -> ClipItem? { + if let entry = entries.first(where: { $0.item.id == id }) { return entry.item } + return savedStacks.lazy + .flatMap(\.entries) + .first(where: { $0.item.id == id })? + .item + } + // Kept as an alias while the main bar transitions from the old queue mode. var isBuilding: Bool { isCollecting } @@ -289,6 +304,39 @@ final class PasteSequence { ClipboardStore.shared.pasteStacksDidChange() } + /// Keeps saved Paste Stack copies in sync when the canonical clipboard + /// item is edited. Entry IDs and paste progress are intentionally retained. + @discardableResult + func updateItem(_ id: UUID, transform: (ClipItem) -> ClipItem) -> Bool { + var changed = false + + for stackIndex in savedStacks.indices { + for entryIndex in savedStacks[stackIndex].entries.indices { + let entry = savedStacks[stackIndex].entries[entryIndex] + guard entry.item.id == id else { continue } + + let updatedItem = transform(entry.item) + guard updatedItem != entry.item else { continue } + savedStacks[stackIndex].entries[entryIndex] = PasteStackEntry( + id: entry.id, + item: updatedItem, + imagePreview: entry.imagePreview, + isPasted: entry.isPasted + ) + savedStacks[stackIndex].updatedAt = .now + changed = true + } + } + + guard changed else { return false } + if let activeStackID, + let active = savedStacks.first(where: { $0.id == activeStackID }) { + entries = active.entries + } + ClipboardStore.shared.pasteStacksDidChange() + return true + } + private func ensureActiveStack() { if let activeStackID, savedStacks.contains(where: { $0.id == activeStackID }) { return } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index 32ba404..adca626 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -243,6 +243,10 @@ struct ClipCardView: View { } Button("Copy") { AppController.shared.copyItem(item) } Divider() + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") + } + Divider() Button("Remove from Paste Stack", role: .destructive) { AppController.shared.removePasteStackEntry(entry) } @@ -250,6 +254,10 @@ struct ClipCardView: View { Button("Paste") { AppController.shared.pasteItem(item) } Button("Copy") { AppController.shared.copyItem(item) } Divider() + Button { AppController.shared.editItem(item) } label: { + Label("Edit", systemImage: "pencil") + } + Divider() if !store.pinboards.isEmpty { Menu("Save to Pinboard") { ForEach(store.pinboards) { b in diff --git a/Sources/Pesty/UI/ClipEditor.swift b/Sources/Pesty/UI/ClipEditor.swift new file mode 100644 index 0000000..02b7525 --- /dev/null +++ b/Sources/Pesty/UI/ClipEditor.swift @@ -0,0 +1,153 @@ +import AppKit + +/// Native modal editing for the clipboard content itself, distinct from a +/// custom card title. The editor intentionally returns data only; persistence +/// and pasteboard policy remain owned by AppController and ClipboardStore. +@MainActor +enum ClipEditor { + enum Edit { + case text(String, richTextData: Data?) + case color(String) + } + + static func run(for item: ClipItem, launchWritingTools: Bool = false) -> Edit? { + switch item.type { + case .text, .richText, .link: + return editText(item, launchWritingTools: launchWritingTools) + case .color: + return editColor(item) + case .image, .file: + showUnsupportedEditor(for: item) + return nil + } + } + + private static func editText(_ item: ClipItem, launchWritingTools: Bool) -> Edit? { + let isRichText = item.type == .richText + let alert = NSAlert() + alert.messageText = "Edit \(item.type.label)" + alert.informativeText = isRichText + ? "Edit this saved clip. Its rich-text formatting is preserved when possible." + : "Edit this saved clip's contents." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + textView.isRichText = isRichText + textView.importsGraphics = false + textView.allowsUndo = true + textView.isEditable = true + textView.isSelectable = true + textView.font = .systemFont(ofSize: 13) + textView.textContainer?.containerSize = NSSize(width: 430, height: CGFloat.greatestFiniteMagnitude) + textView.textContainer?.widthTracksTextView = true + if #available(macOS 15.0, *) { + textView.writingToolsBehavior = .complete + } + + if isRichText, + let data = item.rtfData, + let value = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil + ) { + textView.textStorage?.setAttributedString(value) + } else { + textView.string = item.text ?? "" + } + + let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 430, height: 220)) + scrollView.borderType = .bezelBorder + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.documentView = textView + alert.accessoryView = scrollView + alert.window.initialFirstResponder = textView + if launchWritingTools { + DispatchQueue.main.async { + guard #available(macOS 15.2, *) else { return } + alert.window.makeFirstResponder(textView) + _ = NSApp.sendAction(#selector(NSResponder.showWritingTools(_:)), to: nil, from: textView) + } + } + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + let text = textView.string + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + showEmptyTextWarning() + return nil + } + + let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0) + let richTextData = isRichText ? textView.rtf(from: range) : nil + return .text(text, richTextData: richTextData) + } + + private static func editColor(_ item: ClipItem) -> Edit? { + let alert = NSAlert() + alert.messageText = "Edit Color" + alert.informativeText = "Choose the color stored in this saved clip." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let initialColor = item.colorHex.flatMap(NSColor.init(hex:)) ?? .black + let accessory = ColorEditorAccessoryView(color: initialColor) + alert.accessoryView = accessory + + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + return .color(accessory.selectedHex) + } + + private static func showUnsupportedEditor(for item: ClipItem) { + let alert = NSAlert() + alert.messageText = "This clip can't be edited" + alert.informativeText = "Pesty can edit text, rich text, links, and colors. \(item.type.label) clips are kept as-is." + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private static func showEmptyTextWarning() { + let alert = NSAlert() + alert.messageText = "Clip content can't be empty" + alert.informativeText = "Enter some text before saving this clip." + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +@MainActor +private final class ColorEditorAccessoryView: NSStackView { + private let colorWell: NSColorWell + private let valueLabel: NSTextField + + init(color: NSColor) { + colorWell = NSColorWell() + valueLabel = NSTextField(labelWithString: color.hexString) + super.init(frame: NSRect(x: 0, y: 0, width: 260, height: 32)) + + orientation = .horizontal + alignment = .centerY + spacing = 10 + + let label = NSTextField(labelWithString: "Color:") + valueLabel.font = .monospacedSystemFont(ofSize: 12, weight: .medium) + valueLabel.textColor = .secondaryLabelColor + colorWell.color = color + colorWell.target = self + colorWell.action = #selector(colorDidChange) + colorWell.widthAnchor.constraint(equalToConstant: 42).isActive = true + + addArrangedSubview(label) + addArrangedSubview(colorWell) + addArrangedSubview(valueLabel) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + var selectedHex: String { colorWell.color.hexString } + + @objc private func colorDidChange() { + valueLabel.stringValue = selectedHex + } +} diff --git a/Sources/Pesty/UI/ClipPreviewViews.swift b/Sources/Pesty/UI/ClipPreviewViews.swift index b107cdb..175cc88 100644 --- a/Sources/Pesty/UI/ClipPreviewViews.swift +++ b/Sources/Pesty/UI/ClipPreviewViews.swift @@ -55,6 +55,7 @@ struct LinkPreviewContent: View { Spacer(minLength: 0) } .onAppear { previews.load(for: url) } + .onChange(of: url) { _, newURL in previews.load(for: newURL) } } @ViewBuilder @@ -129,6 +130,7 @@ struct LinkCardPreview: View { } } .onAppear { previews.load(for: url) } + .onChange(of: url) { _, newURL in previews.load(for: newURL) } } } diff --git a/Sources/Pesty/Util/QuickLookService.swift b/Sources/Pesty/Util/QuickLookService.swift index 0361454..af53e97 100644 --- a/Sources/Pesty/Util/QuickLookService.swift +++ b/Sources/Pesty/Util/QuickLookService.swift @@ -21,26 +21,21 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource return } - prepareTemporaryDirectory() - var selectedIndex = 0 - var newItems: [PreviewItem] = [] - var newStartIndexes: [UUID: Int] = [:] - for clip in items { - let startIndex = newItems.count - newItems.append(contentsOf: previewItems(for: clip)) - if startIndex < newItems.count { newStartIndexes[clip.id] = startIndex } - if clip.id == selectedID, startIndex < newItems.count { selectedIndex = startIndex } - } - guard !newItems.isEmpty else { return } - - previewItems = newItems - startIndexByClipID = newStartIndexes + guard let selectedIndex = replacePreviewItems(with: items, selectedID: selectedID) else { return } panel.dataSource = self panel.reloadData() panel.currentPreviewItemIndex = selectedIndex panel.makeKeyAndOrderFront(nil) } + /// Rebuilds temporary Quick Look assets when a visible clip changes. + func refresh(items: [ClipItem], selectedID: UUID?) { + guard let panel = QLPreviewPanel.shared(), panel.isVisible, + let selectedIndex = replacePreviewItems(with: items, selectedID: selectedID) else { return } + panel.reloadData() + panel.currentPreviewItemIndex = selectedIndex + } + func updateSelection(selectedID: UUID?) { guard let panel = QLPreviewPanel.shared(), panel.isVisible, let selectedID, let index = startIndexByClipID[selectedID] else { return } @@ -53,6 +48,24 @@ final class QuickLookService: NSObject, @preconcurrency QLPreviewPanelDataSource previewItems[index] } + private func replacePreviewItems(with clips: [ClipItem], selectedID: UUID?) -> Int? { + prepareTemporaryDirectory() + var selectedIndex = 0 + var newItems: [PreviewItem] = [] + var newStartIndexes: [UUID: Int] = [:] + for clip in clips { + let startIndex = newItems.count + newItems.append(contentsOf: previewItems(for: clip)) + if startIndex < newItems.count { newStartIndexes[clip.id] = startIndex } + if clip.id == selectedID, startIndex < newItems.count { selectedIndex = startIndex } + } + guard !newItems.isEmpty else { return nil } + + previewItems = newItems + startIndexByClipID = newStartIndexes + return selectedIndex + } + private func previewItems(for clip: ClipItem) -> [PreviewItem] { switch clip.type { case .file: