- The composer only enables its scroller once the text is taller than the seven-line max, so it no longer flashes on every growth step. - The composer re-asserts first responder only when canStealFocus is true; it is off while a modal field or the sync overlay has the keyboard, so typing in the edit dialog, search and sync fields no longer bounces back. - The edit dialog is a ComposerView now: Return saves, Shift+Return inserts a newline, Esc cancels, and paste uses the same path as the composer. - Shift+Return never actually worked: returning false from doCommandBy does not fall through to the default newline insertion, so the event died. The newline is inserted explicitly and the Shift state comes from the keyDown.
911 lines
34 KiB
Swift
911 lines
34 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 composerFocused = false
|
|
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
|
|
@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 editingItem: PromptItem?
|
|
@State private var editText = ""
|
|
@State private var editFocused = false
|
|
@State private var editHeight: CGFloat = ComposerView.oneLineHeight
|
|
@State private var copiedID: UUID?
|
|
@State private var accessibilityGranted = AXIsProcessTrusted()
|
|
|
|
private enum Tab { case workspace, all }
|
|
@State private var tab: Tab = .workspace
|
|
|
|
private enum Field: Hashable { case search, newWorkspace }
|
|
@FocusState private var focusedField: Field?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
if !accessibilityGranted {
|
|
permissionBanner
|
|
}
|
|
tabPicker
|
|
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)
|
|
.overlay(editModal)
|
|
.onDrop(of: [UTType.image.identifier, UTType.fileURL.identifier], isTargeted: nil, perform: handleDrop)
|
|
.onReceive(NotificationCenter.default.publisher(for: .promptPanelDidShow)) { _ in
|
|
composerFocused = true
|
|
accessibilityGranted = AXIsProcessTrusted()
|
|
}
|
|
.onAppear { composerFocused = true }
|
|
}
|
|
|
|
// 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: {
|
|
SyncDot()
|
|
}
|
|
.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)
|
|
}
|
|
|
|
// MARK: - Tabs
|
|
|
|
/// The global view: every prompt across every workspace, each labeled.
|
|
private var tabPicker: some View {
|
|
Picker("View", selection: $tab) {
|
|
Text(store.activeSection.name).tag(Tab.workspace)
|
|
Text("All").tag(Tab.all)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
|
|
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 = nil
|
|
composerFocused = true
|
|
}
|
|
if !search.isEmpty {
|
|
Button { search = "" } label: { Image(systemName: "xmark.circle.fill") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
/// Every item across every workspace, each labeled with its workspace.
|
|
private var allLabeledItems: [(section: String, item: PromptItem)] {
|
|
store.sections.flatMap { section in
|
|
section.items.map { (section.name, $0) }
|
|
}
|
|
}
|
|
|
|
private var displayedItems: [(section: String, item: PromptItem)] {
|
|
if !search.isEmpty {
|
|
let query = search.lowercased()
|
|
return allLabeledItems.filter { entry in
|
|
if case .text(let text) = entry.item.content {
|
|
return text.lowercased().contains(query)
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
if tab == .all {
|
|
return allLabeledItems
|
|
}
|
|
return store.activeSection.items.map { ("", $0) }
|
|
}
|
|
|
|
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…") {
|
|
editText = text
|
|
editHeight = ComposerView.oneLineHeight
|
|
editingItem = item
|
|
editFocused = true
|
|
}
|
|
}
|
|
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
|
|
tab = .workspace
|
|
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)
|
|
}
|
|
Divider()
|
|
Toggle("Launch at Login", isOn: Binding(
|
|
get: { LoginItem.isEnabled },
|
|
set: { _ in _ = LoginItem.toggle() }
|
|
))
|
|
.font(.callout)
|
|
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: - Edit
|
|
|
|
/// The editor is a ComposerView too, so it behaves exactly like the main
|
|
/// input: Return saves, Shift+Return inserts a newline, Esc cancels, and
|
|
/// pasting goes through the same working path.
|
|
@ViewBuilder
|
|
private var editModal: some View {
|
|
if let item = editingItem {
|
|
ZStack {
|
|
Color.black.opacity(0.25)
|
|
.onTapGesture { dismissEditor() }
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Edit")
|
|
.font(.headline)
|
|
ComposerView(
|
|
text: $editText,
|
|
isFocused: $editFocused,
|
|
height: $editHeight,
|
|
placeholder: "",
|
|
onSubmit: { saveEdit(item) },
|
|
onEscape: { dismissEditor() }
|
|
)
|
|
.frame(height: editHeight)
|
|
.padding(8)
|
|
.background(Theme.rowSwiftUI)
|
|
.cornerRadius(8)
|
|
HStack {
|
|
Spacer()
|
|
Button("Cancel") { dismissEditor() }
|
|
.keyboardShortcut(.cancelAction)
|
|
Button("Save") { saveEdit(item) }
|
|
.keyboardShortcut("s", modifiers: .command)
|
|
.disabled(editText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: 320)
|
|
.background(Theme.backgroundSwiftUI)
|
|
.cornerRadius(12)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(Color.primary.opacity(0.12), lineWidth: 1)
|
|
)
|
|
.shadow(radius: 18)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
private func saveEdit(_ item: PromptItem) {
|
|
let text = editText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !text.isEmpty {
|
|
store.updateText(item.id, newText: text)
|
|
}
|
|
dismissEditor()
|
|
}
|
|
|
|
private func dismissEditor() {
|
|
editingItem = nil
|
|
editText = ""
|
|
editFocused = false
|
|
composerFocused = true
|
|
}
|
|
|
|
// 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 = nil
|
|
composerFocused = true
|
|
}
|
|
|
|
// 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(alignment: .bottom, spacing: 8) {
|
|
ComposerView(
|
|
text: $composer,
|
|
isFocused: $composerFocused,
|
|
height: $composerHeight,
|
|
placeholder: "Add a note or prompt (\(store.activeSection.name))",
|
|
// A modal field (search, edit, new workspace) or the sync
|
|
// overlay owns the keyboard; don't steal focus back mid-edit.
|
|
canStealFocus: focusedField == nil && !showSync && editingItem == nil,
|
|
onSubmit: submit,
|
|
onEscape: {
|
|
if selection.isEmpty {
|
|
PanelController.shared.hide()
|
|
} else {
|
|
clearSelection()
|
|
}
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: composerHeight)
|
|
if !composer.isEmpty {
|
|
Button(action: submit) { Image(systemName: "arrow.up.circle.fill") }
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(Color.accentColor)
|
|
.help("Add (Return; Shift+Return for a new line)")
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
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("") { composerFocused = true }
|
|
.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 = nil
|
|
composerFocused = true
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Sync status lives in its own view so a `SyncService` publish cannot
|
|
/// invalidate the panel's list. Watching it from `PanelView`'s body meant every
|
|
/// sync event — including the 5-second poll — re-rendered every row.
|
|
private struct SyncDot: View {
|
|
@ObservedObject private var sync = SyncService.shared
|
|
|
|
var body: some View {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
|
}
|
|
}
|