title area above editor with double-click edit and backspace-to-focus

This commit is contained in:
jess 2026-04-05 03:12:39 -07:00
parent 695571e90e
commit 9ed64712f8
2 changed files with 201 additions and 5 deletions

View File

@ -23,4 +23,6 @@ struct ContentView: View {
extension Notification.Name { extension Notification.Name {
static let toggleSidebar = Notification.Name("toggleSidebar") static let toggleSidebar = Notification.Name("toggleSidebar")
static let focusEditor = Notification.Name("focusEditor")
static let focusTitle = Notification.Name("focusTitle")
} }

View File

@ -530,13 +530,185 @@ class FileEmbedCell: NSTextAttachmentCell {
} }
} }
struct EditorView: View { // MARK: - Title View
@ObservedObject var state: AppState
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 { var body: some View {
EditorTextView(text: $state.documentText, evalResults: state.evalResults, onEvaluate: { HStack {
state.evaluate() 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<String> {
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<String> {
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 @Binding var text: String
var evalResults: [Int: String] var evalResults: [Int: String]
var onEvaluate: () -> Void var onEvaluate: () -> Void
var onBackspaceAtStart: (() -> Void)? = nil
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(self) Coordinator(self)
@ -644,9 +817,24 @@ struct EditorTextView: NSViewRepresentable {
private var isUpdatingImages = false private var isUpdatingImages = false
private var isUpdatingTables = false private var isUpdatingTables = false
private var embeddedTableViews: [MarkdownTableView] = [] private var embeddedTableViews: [MarkdownTableView] = []
private var focusObserver: NSObjectProtocol?
init(_ parent: EditorTextView) { init(_ parent: EditorTextView) {
self.parent = parent 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) { func textDidChange(_ notification: Notification) {
@ -679,6 +867,12 @@ struct EditorTextView: NSViewRepresentable {
} }
return true return true
} }
if commandSelector == #selector(NSResponder.deleteBackward(_:)) {
if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 {
parent.onBackspaceAtStart?()
return true
}
}
return false return false
} }