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 {
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue