The panel is a full-height strip that sits beside your work, where the system grey reads as a half-lit window next to a dark editor. Dark mode is now pure black, with card and row colours in one Theme file so both the panel background and the strip under the titlebar match.
777 lines
29 KiB
Swift
777 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")
|
|
Button { PanelController.shared.hide() } label: { Image(systemName: "xmark") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
|
.help("Hide the panel (Cmd+W) — Prompt keeps running so Shift-Shift capture still works. Quit from the menu bar icon or Cmd+Q.")
|
|
}
|
|
.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 { 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 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)
|
|
}
|
|
}
|