The selection bar covered copying but not the other action people reach for with a selection. The header also carried a second close button next to the window's own traffic light, so it was removed in favor of that.
786 lines
29 KiB
Swift
786 lines
29 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
struct PanelView: View {
|
|
@ObservedObject var store: Store
|
|
@ObservedObject private var sync = SyncService.shared
|
|
|
|
@State private var composer = ""
|
|
@State private var search = ""
|
|
@State private var showSearch = false
|
|
@State private var showSectionSwitcher = false
|
|
@State private var showNewWorkspace = false
|
|
@State private var newWorkspaceName = ""
|
|
@State private var showSync = false
|
|
@State private var joinCode = ""
|
|
@State private var selection = Set<UUID>()
|
|
@State private var lastSelectedID: UUID?
|
|
@State private var copiedID: UUID?
|
|
@State private var accessibilityGranted = AXIsProcessTrusted()
|
|
|
|
private enum Field: Hashable { case composer, search, newWorkspace }
|
|
@FocusState private var focusedField: Field?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
if !accessibilityGranted {
|
|
permissionBanner
|
|
}
|
|
if showSearch {
|
|
searchField
|
|
}
|
|
Divider()
|
|
content
|
|
Divider()
|
|
if !selection.isEmpty {
|
|
selectionBar
|
|
}
|
|
composerField
|
|
}
|
|
.frame(minWidth: 300)
|
|
.background(Theme.backgroundSwiftUI)
|
|
.background(shortcutButtons)
|
|
.overlay(sectionSwitcher)
|
|
.overlay(syncOverlay)
|
|
.overlay(newWorkspaceModal)
|
|
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
|
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
|
focusedField = .composer
|
|
accessibilityGranted = AXIsProcessTrusted()
|
|
}
|
|
.onAppear { focusedField = .composer }
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 10) {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(store.activeSection.name.uppercased())
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.tracking(1)
|
|
Image(systemName: "chevron.down")
|
|
.font(.system(size: 9, weight: .bold))
|
|
}
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Switch workspace (Cmd+K)")
|
|
|
|
Spacer()
|
|
|
|
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
|
}
|
|
.buttonStyle(.plain).help("Sync")
|
|
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("Search (Cmd+F)")
|
|
Button { addPhotos() } label: { Image(systemName: "photo.on.rectangle.angled") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("Add photos")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var permissionBanner: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.yellow)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Shift-tap capture needs Accessibility access.")
|
|
.font(.caption)
|
|
Text("Meanwhile: Cmd+Shift+Space toggles this panel.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button("Grant…") {
|
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
_ = AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color.yellow.opacity(0.1))
|
|
}
|
|
|
|
private var searchField: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
|
TextField("Search all workspaces", text: $search)
|
|
.textFieldStyle(.plain)
|
|
.focused($focusedField, equals: .search)
|
|
.onExitCommand {
|
|
search = ""
|
|
showSearch = false
|
|
focusedField = .composer
|
|
}
|
|
if !search.isEmpty {
|
|
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
private var displayedItems: [(section: String, item: PromptItem)] {
|
|
if search.isEmpty {
|
|
return store.activeSection.items.map { ("", $0) }
|
|
}
|
|
let query = search.lowercased()
|
|
return store.sections.flatMap { section in
|
|
section.items.compactMap { item in
|
|
switch item.content {
|
|
case .text(let text) where text.lowercased().contains(query):
|
|
return (section.name, item)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var content: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(spacing: 6) {
|
|
if displayedItems.isEmpty {
|
|
emptyState
|
|
}
|
|
ForEach(displayedItems, id: \.item.id) { entry in
|
|
row(for: entry.item, sectionName: entry.section)
|
|
.id(entry.item.id)
|
|
}
|
|
}
|
|
.padding(10)
|
|
}
|
|
.onChange(of: store.activeSection.items.count) {
|
|
if let last = store.activeSection.items.last {
|
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "tray")
|
|
.font(.system(size: 28))
|
|
.foregroundStyle(.quaternary)
|
|
Text(search.isEmpty ? "Nothing here yet" : "No matches")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
if search.isEmpty {
|
|
Text("Select text anywhere and tap Shift twice,\nor drop photos into this panel.")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 48)
|
|
}
|
|
|
|
/// Rows carry a checkbox that *selects* the item, so a selection can be
|
|
/// copied or deleted from the context menu. The row cannot be a Button
|
|
/// because the checkbox is one, and nested buttons do not receive clicks.
|
|
private func row(for item: PromptItem, sectionName: String) -> some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Button {
|
|
toggleSelection(item.id)
|
|
} label: {
|
|
Image(systemName: selection.contains(item.id) ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(selection.contains(item.id) ? Color.accentColor : Color.secondary.opacity(0.55))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(selection.contains(item.id) ? "Deselect" : "Select for copy or delete")
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if !sectionName.isEmpty {
|
|
Text(sectionName.uppercased())
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.tracking(1)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
itemContent(item)
|
|
HStack(spacing: 6) {
|
|
if let sourceApp = item.sourceApp {
|
|
Text(sourceApp)
|
|
}
|
|
Text(item.createdAt, style: .time)
|
|
}
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(rowBackground(item))
|
|
.cornerRadius(8)
|
|
.overlay(alignment: .topTrailing) {
|
|
if copiedID == item.id {
|
|
Label("Copied", systemImage: "checkmark.circle.fill")
|
|
.font(.caption2)
|
|
.foregroundStyle(.green)
|
|
.padding(6)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { handleClick(item) }
|
|
.help("Click to select · Shift+click for a range · right-click to copy, delete and more")
|
|
.contextMenu { contextMenu(for: item) }
|
|
}
|
|
|
|
private func rowBackground(_ item: PromptItem) -> Color {
|
|
if selection.contains(item.id) { return Color.accentColor.opacity(0.25) }
|
|
return Theme.rowSwiftUI
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func itemContent(_ item: PromptItem) -> some View {
|
|
switch item.content {
|
|
case .text(let text):
|
|
Text(markdown(text))
|
|
.font(.callout)
|
|
.strikethrough(item.done)
|
|
.opacity(item.done ? 0.45 : 1)
|
|
.lineLimit(8)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
case .image(let filename):
|
|
if let image = store.image(for: filename) {
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxHeight: 160)
|
|
.cornerRadius(6)
|
|
.opacity(item.done ? 0.4 : 1)
|
|
} else {
|
|
Label("Missing image", systemImage: "photo")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func contextMenu(for item: PromptItem) -> some View {
|
|
let ids = effectiveIDs(for: item)
|
|
let multiple = ids.count > 1
|
|
return Group {
|
|
Button("Copy") { copy(item) }
|
|
Button(multiple ? "Copy \(ids.count) as List" : "Copy as List") {
|
|
store.copyAsList(ids: ids)
|
|
flashCopied(item.id)
|
|
}
|
|
Button(multiple ? "Copy \(ids.count) as Lines" : "Copy as Lines") {
|
|
store.copyAsLines(ids: ids)
|
|
flashCopied(item.id)
|
|
}
|
|
Divider()
|
|
Button(item.done ? "Mark as Not Done" : "Mark as Done") {
|
|
store.setDone(!item.done, ids: ids)
|
|
}
|
|
if case .text(let text) = item.content, !multiple {
|
|
Button("Edit…") {
|
|
PanelController.shared.editController.edit(itemID: item.id, text: text)
|
|
}
|
|
}
|
|
if case .image(let filename) = item.content, !multiple {
|
|
Button("Open") {
|
|
NSWorkspace.shared.open(store.imageURL(for: filename))
|
|
}
|
|
}
|
|
if multiple {
|
|
Button("Merge Notes") {
|
|
store.merge(ids: ids)
|
|
clearSelection()
|
|
}
|
|
}
|
|
Menu("Move To") {
|
|
ForEach(store.sections.filter { $0.id != store.sectionID(of: item.id) }) { section in
|
|
Button(section.name) {
|
|
store.move(ids: ids, to: section.id)
|
|
if multiple { clearSelection() }
|
|
}
|
|
}
|
|
}
|
|
Divider()
|
|
Button(multiple ? "Delete \(ids.count) Items" : "Delete", role: .destructive) {
|
|
store.delete(ids: ids)
|
|
selection.subtract(ids)
|
|
lastSelectedID = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Section switcher
|
|
|
|
@ViewBuilder
|
|
private var sectionSwitcher: some View {
|
|
if showSectionSwitcher {
|
|
VStack(spacing: 0) {
|
|
Color.black.opacity(0.001)
|
|
.onTapGesture { showSectionSwitcher = false }
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(store.sections.enumerated()), id: \.element.id) { index, section in
|
|
HStack(spacing: 6) {
|
|
Text(section.name)
|
|
.font(.callout)
|
|
Spacer()
|
|
Text("\(section.items.count)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
if section.id == store.activeSection.id {
|
|
Image(systemName: "checkmark")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
// Reorder the workspace list; the boundary rows are
|
|
// disabled rather than hidden so the list does not jump.
|
|
HStack(spacing: 0) {
|
|
Button {
|
|
store.moveSection(section.id, by: -1)
|
|
} label: {
|
|
Image(systemName: "chevron.up")
|
|
}
|
|
.disabled(index == 0)
|
|
Button {
|
|
store.moveSection(section.id, by: 1)
|
|
} label: {
|
|
Image(systemName: "chevron.down")
|
|
}
|
|
.disabled(index == store.sections.count - 1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
store.activeSectionID = section.id
|
|
showSectionSwitcher = false
|
|
}
|
|
}
|
|
Divider()
|
|
Button {
|
|
showSectionSwitcher = false
|
|
newWorkspaceName = ""
|
|
showNewWorkspace = true
|
|
focusedField = .newWorkspace
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "plus")
|
|
Text("New Workspace")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.background(.regularMaterial)
|
|
.cornerRadius(10)
|
|
.shadow(radius: 12)
|
|
.padding(.horizontal, 24)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.padding(.top, 44)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync
|
|
|
|
@ViewBuilder
|
|
private var syncOverlay: some View {
|
|
if showSync {
|
|
VStack(spacing: 0) {
|
|
Color.black.opacity(0.001)
|
|
.onTapGesture { showSync = false }
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Sync").font(.headline)
|
|
Spacer()
|
|
if sync.isSyncing { ProgressView().controlSize(.small) }
|
|
}
|
|
TextField("Server URL", text: $sync.serverURL)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.callout)
|
|
if let code = sync.code {
|
|
Divider()
|
|
Text("Your code").font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
Text(code)
|
|
.font(.system(size: 20, weight: .medium, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
Spacer()
|
|
Button {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(code, forType: .string)
|
|
} label: {
|
|
Image(systemName: "doc.on.doc")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Copy code")
|
|
}
|
|
Text("Enter this code on your other Mac to sync.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
statusLine
|
|
Divider()
|
|
Button("Disconnect", role: .destructive) { sync.disconnect() }
|
|
.font(.callout)
|
|
} else {
|
|
Divider()
|
|
Button("Generate Code") { sync.generateCode() }
|
|
.disabled(sync.isSyncing)
|
|
Text("Creates a new sync group and uploads what's here.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Divider()
|
|
Text("Or join an existing group").font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
TextField("1234-5678", text: $joinCode)
|
|
.textFieldStyle(.roundedBorder)
|
|
.font(.system(.callout, design: .monospaced))
|
|
.onExitCommand { showSync = false }
|
|
Button("Join") {
|
|
sync.join(rawCode: joinCode)
|
|
joinCode = ""
|
|
}
|
|
.disabled(sync.isSyncing || joinCode.isEmpty)
|
|
}
|
|
Text("Joining replaces what's on this Mac with the group's notes.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let error = sync.lastError {
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(.regularMaterial)
|
|
.cornerRadius(10)
|
|
.shadow(radius: 12)
|
|
.padding(.horizontal, 20)
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.padding(.top, 44)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusLine: some View {
|
|
if sync.isSyncing {
|
|
Text("Syncing…").font(.caption).foregroundStyle(.secondary)
|
|
} else if let last = sync.lastSyncedAt {
|
|
Text("Last synced \(last, style: .relative) ago")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("Not synced yet").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
// MARK: - New workspace
|
|
|
|
@ViewBuilder
|
|
private var newWorkspaceModal: some View {
|
|
if showNewWorkspace {
|
|
ZStack {
|
|
Color.black.opacity(0.25)
|
|
.onTapGesture { dismissNewWorkspace() }
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("New Workspace")
|
|
.font(.headline)
|
|
TextField("Name", text: $newWorkspaceName)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focusedField, equals: .newWorkspace)
|
|
.onSubmit(createWorkspace)
|
|
.onExitCommand { dismissNewWorkspace() }
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel") { dismissNewWorkspace() }
|
|
Button("Create") { createWorkspace() }
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(newWorkspaceName.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(width: 260)
|
|
.background(.regularMaterial)
|
|
.cornerRadius(12)
|
|
.shadow(radius: 18)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
private func createWorkspace() {
|
|
let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { return }
|
|
store.createSection(named: name)
|
|
dismissNewWorkspace()
|
|
}
|
|
|
|
private func dismissNewWorkspace() {
|
|
showNewWorkspace = false
|
|
newWorkspaceName = ""
|
|
focusedField = .composer
|
|
}
|
|
|
|
// MARK: - Selection
|
|
|
|
private var selectionBar: some View {
|
|
HStack(spacing: 12) {
|
|
Text("\(selection.count) selected")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if selection.count == 1, let id = selection.first, let item = store.item(id) {
|
|
Button("Copy") { copy(item) }
|
|
.font(.caption)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
Button("Copy as List") { copySelection(asList: true) }
|
|
.font(.caption)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Color.accentColor)
|
|
Button("Copy as Lines") { copySelection(asList: false) }
|
|
.font(.caption)
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Color.accentColor)
|
|
Button { deleteSelection() } label: {
|
|
Image(systemName: "trash")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.red)
|
|
.help("Delete selected")
|
|
Button { clearSelection() } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.tertiary)
|
|
.help("Clear selection (Esc)")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color.accentColor.opacity(0.12))
|
|
}
|
|
|
|
private func deleteSelection() {
|
|
guard !selection.isEmpty else { return }
|
|
store.delete(ids: selection)
|
|
clearSelection()
|
|
}
|
|
|
|
private func copySelection(asList: Bool) {
|
|
guard !selection.isEmpty else { return }
|
|
if asList {
|
|
store.copyAsList(ids: selection)
|
|
} else {
|
|
store.copyAsLines(ids: selection)
|
|
}
|
|
if let id = selection.first { flashCopied(id) }
|
|
}
|
|
|
|
private func clearSelection() {
|
|
selection.removeAll()
|
|
lastSelectedID = nil
|
|
}
|
|
|
|
// MARK: - Composer
|
|
|
|
private var composerField: some View {
|
|
HStack(spacing: 8) {
|
|
TextField("Add a note or prompt (\(store.activeSection.name))", text: $composer)
|
|
.textFieldStyle(.plain)
|
|
.focused($focusedField, equals: .composer)
|
|
.onSubmit(submit)
|
|
.onExitCommand {
|
|
// Esc clears a selection first, and only hides the panel
|
|
// once there is nothing selected.
|
|
if selection.isEmpty {
|
|
PanelController.shared.hide()
|
|
} else {
|
|
clearSelection()
|
|
}
|
|
}
|
|
if !composer.isEmpty {
|
|
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private func submit() {
|
|
let text = composer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { return }
|
|
if text.hasPrefix("#") {
|
|
let name = String(text.dropFirst()).trimmingCharacters(in: .whitespaces)
|
|
if !name.isEmpty { store.createSection(named: name) }
|
|
} else {
|
|
store.addText(text)
|
|
}
|
|
composer = ""
|
|
}
|
|
|
|
// MARK: - Shortcuts
|
|
|
|
// Only Cmd-modified shortcuts live here: bare keys (Space, Delete, Cmd+C)
|
|
// would be swallowed as key equivalents and break typing in the composer.
|
|
private var shortcutButtons: some View {
|
|
VStack {
|
|
Button("") { toggleSearch() }
|
|
.keyboardShortcut("f", modifiers: .command)
|
|
Button("") {
|
|
withAnimation(.easeInOut(duration: 0.15)) { showSectionSwitcher.toggle() }
|
|
}
|
|
.keyboardShortcut("k", modifiers: .command)
|
|
Button("") { focusedField = .composer }
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
Button("") {
|
|
withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() }
|
|
}
|
|
.keyboardShortcut(",", modifiers: .command)
|
|
Button("") {
|
|
copySelection(asList: true)
|
|
}
|
|
.keyboardShortcut("c", modifiers: [.command, .shift])
|
|
Button("") {
|
|
copySelection(asList: false)
|
|
}
|
|
.keyboardShortcut("c", modifiers: [.command, .option])
|
|
}
|
|
.opacity(0)
|
|
.frame(width: 0, height: 0)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
/// A click anywhere on the card selects it (Shift+click selects a range).
|
|
/// Copying is then an action on the selection — from the selection bar or
|
|
/// the right-click menu — rather than the side effect of a click.
|
|
private func handleClick(_ item: PromptItem) {
|
|
let modifiers = NSApp.currentEvent?.modifierFlags ?? []
|
|
if modifiers.contains(.shift), let anchor = lastSelectedID, anchor != item.id {
|
|
selectRange(from: anchor, to: item.id)
|
|
return
|
|
}
|
|
toggleSelection(item.id)
|
|
}
|
|
|
|
private func toggleSelection(_ id: UUID) {
|
|
if selection.contains(id) {
|
|
selection.remove(id)
|
|
} else {
|
|
selection.insert(id)
|
|
}
|
|
lastSelectedID = id
|
|
}
|
|
|
|
private func selectRange(from anchor: UUID, to target: UUID) {
|
|
let ids = displayedItems.map(\.item.id)
|
|
guard let start = ids.firstIndex(of: anchor), let end = ids.firstIndex(of: target) else { return }
|
|
let range = start <= end ? start...end : end...start
|
|
selection = Set(ids[range])
|
|
lastSelectedID = target
|
|
}
|
|
|
|
private func copy(_ item: PromptItem) {
|
|
store.copy(item)
|
|
flashCopied(item.id)
|
|
}
|
|
|
|
private func flashCopied(_ id: UUID) {
|
|
withAnimation { copiedID = id }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
if copiedID == id {
|
|
withAnimation { copiedID = nil }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func effectiveIDs(for item: PromptItem) -> Set<UUID> {
|
|
if selection.contains(item.id), selection.count > 1 { return selection }
|
|
return [item.id]
|
|
}
|
|
|
|
private func toggleSearch() {
|
|
showSearch.toggle()
|
|
if showSearch {
|
|
focusedField = .search
|
|
} else {
|
|
search = ""
|
|
focusedField = .composer
|
|
}
|
|
}
|
|
|
|
private func addPhotos() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [.image]
|
|
panel.allowsMultipleSelection = true
|
|
guard panel.runModal() == .OK else { return }
|
|
for url in panel.urls {
|
|
if let image = NSImage(contentsOf: url) {
|
|
store.addImage(image)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
|
|
var handled = false
|
|
for provider in providers {
|
|
if provider.canLoadObject(ofClass: NSImage.self) {
|
|
handled = true
|
|
_ = provider.loadObject(ofClass: NSImage.self) { object, _ in
|
|
if let image = object as? NSImage {
|
|
Task { @MainActor in Store.shared.addImage(image) }
|
|
}
|
|
}
|
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
|
handled = true
|
|
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in
|
|
guard let data = item as? Data,
|
|
let url = URL(dataRepresentation: data, relativeTo: nil),
|
|
let image = NSImage(contentsOf: url) else { return }
|
|
Task { @MainActor in Store.shared.addImage(image) }
|
|
}
|
|
}
|
|
}
|
|
return handled
|
|
}
|
|
|
|
private func markdown(_ string: String) -> AttributedString {
|
|
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
|
return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
|
|
}
|
|
}
|