title area above editor with double-click edit and backspace-to-focus
This commit is contained in:
parent
695571e90e
commit
9ed64712f8
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue