From 9ed64712f81ce7e5554915f4c0c9c0e4393ce097 Mon Sep 17 00:00:00 2001 From: jess Date: Sun, 5 Apr 2026 03:12:39 -0700 Subject: [PATCH] title area above editor with double-click edit and backspace-to-focus --- src/ContentView.swift | 2 + src/EditorView.swift | 204 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src/ContentView.swift b/src/ContentView.swift index c6da016..301e0cc 100644 --- a/src/ContentView.swift +++ b/src/ContentView.swift @@ -23,4 +23,6 @@ struct ContentView: View { extension Notification.Name { static let toggleSidebar = Notification.Name("toggleSidebar") + static let focusEditor = Notification.Name("focusEditor") + static let focusTitle = Notification.Name("focusTitle") } diff --git a/src/EditorView.swift b/src/EditorView.swift index 4dc46b0..09aba9d 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -530,13 +530,185 @@ class FileEmbedCell: NSTextAttachmentCell { } } -struct EditorView: View { - @ObservedObject var state: AppState +// MARK: - Title View + +struct TitleView: View { + @Binding var titleLine: String + @State private var isEditing = false + @State private var editText = "" + var onCommitAndFocusEditor: (() -> Void)? + + private var displayTitle: String { + let stripped = titleLine.trimmingCharacters(in: .whitespaces) + if stripped.hasPrefix("# ") { + return String(stripped.dropFirst(2)) + } + return stripped + } var body: some View { - EditorTextView(text: $state.documentText, evalResults: state.evalResults, onEvaluate: { - state.evaluate() - }) + HStack { + if isEditing { + TitleTextField( + text: $editText, + onCommit: { commitEdit() }, + onEscape: { commitEdit() } + ) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(Color(ns: Theme.current.text)) + .padding(.horizontal, 58) + .padding(.top, 16) + .padding(.bottom, 8) + } else { + Text(displayTitle.isEmpty ? "Untitled" : displayTitle) + .font(.system(size: 24, weight: .bold)) + .foregroundColor(displayTitle.isEmpty + ? Color(ns: Theme.current.overlay0) + : Color(ns: Theme.current.text)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 58) + .padding(.top, 16) + .padding(.bottom, 8) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + editText = titleLine + isEditing = true + } + } + Spacer() + } + .background(Color(ns: Theme.current.base)) + .onReceive(NotificationCenter.default.publisher(for: .focusTitle)) { _ in + editText = titleLine + isEditing = true + } + } + + private func commitEdit() { + titleLine = editText + isEditing = false + onCommitAndFocusEditor?() + } +} + +struct TitleTextField: NSViewRepresentable { + @Binding var text: String + var onCommit: () -> Void + var onEscape: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSTextField { + let field = NSTextField() + field.isBordered = false + field.drawsBackground = false + field.font = NSFont.systemFont(ofSize: 24, weight: .bold) + field.textColor = Theme.current.text + field.focusRingType = .none + field.stringValue = text + field.delegate = context.coordinator + field.cell?.lineBreakMode = .byTruncatingTail + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + return field + } + + func updateNSView(_ field: NSTextField, context: Context) { + if field.stringValue != text { + field.stringValue = text + } + } + + class Coordinator: NSObject, NSTextFieldDelegate { + var parent: TitleTextField + init(_ parent: TitleTextField) { self.parent = parent } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + parent.text = field.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool { + if sel == #selector(NSResponder.insertNewline(_:)) { + parent.onCommit() + return true + } + if sel == #selector(NSResponder.cancelOperation(_:)) { + parent.onEscape() + return true + } + return false + } + } +} + +// MARK: - Editor View + +struct EditorView: View { + @ObservedObject var state: AppState + @State private var titleIsEditing = false + + private var titleBinding: Binding { + Binding( + get: { + let lines = state.documentText.components(separatedBy: "\n") + return lines.first ?? "" + }, + set: { newTitle in + let lines = state.documentText.components(separatedBy: "\n") + var rest = Array(lines.dropFirst()) + if rest.isEmpty && state.documentText.isEmpty { + rest = [] + } + state.documentText = ([newTitle] + rest).joined(separator: "\n") + } + ) + } + + private var bodyBinding: Binding { + Binding( + get: { + let text = state.documentText + guard let firstNewline = text.firstIndex(of: "\n") else { + return "" + } + return String(text[text.index(after: firstNewline)...]) + }, + set: { newBody in + let lines = state.documentText.components(separatedBy: "\n") + let title = lines.first ?? "" + state.documentText = title + "\n" + newBody + } + ) + } + + var body: some View { + VStack(spacing: 0) { + TitleView( + titleLine: titleBinding, + onCommitAndFocusEditor: { + NotificationCenter.default.post(name: .focusEditor, object: nil) + } + ) + EditorTextView( + text: bodyBinding, + evalResults: offsetEvalResults(state.evalResults), + onEvaluate: { state.evaluate() }, + onBackspaceAtStart: { + NotificationCenter.default.post(name: .focusTitle, object: nil) + } + ) + } + } + + private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { + var shifted: [Int: String] = [:] + for (key, val) in results where key > 0 { + shifted[key - 1] = val + } + return shifted } } @@ -544,6 +716,7 @@ struct EditorTextView: NSViewRepresentable { @Binding var text: String var evalResults: [Int: String] var onEvaluate: () -> Void + var onBackspaceAtStart: (() -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) @@ -644,9 +817,24 @@ struct EditorTextView: NSViewRepresentable { private var isUpdatingImages = false private var isUpdatingTables = false private var embeddedTableViews: [MarkdownTableView] = [] + private var focusObserver: NSObjectProtocol? init(_ parent: EditorTextView) { self.parent = parent + super.init() + focusObserver = NotificationCenter.default.addObserver( + forName: .focusEditor, object: nil, queue: .main + ) { [weak self] _ in + guard let tv = self?.textView else { return } + tv.window?.makeFirstResponder(tv) + tv.setSelectedRange(NSRange(location: 0, length: 0)) + } + } + + deinit { + if let obs = focusObserver { + NotificationCenter.default.removeObserver(obs) + } } func textDidChange(_ notification: Notification) { @@ -679,6 +867,12 @@ struct EditorTextView: NSViewRepresentable { } return true } + if commandSelector == #selector(NSResponder.deleteBackward(_:)) { + if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 { + parent.onBackspaceAtStart?() + return true + } + } return false }