feat(editor): improve writing layout
This commit is contained in:
parent
0b01178f24
commit
820099ddad
3 changed files with 96 additions and 37 deletions
|
|
@ -106,6 +106,13 @@ public struct EditorState: Hashable, Sendable {
|
|||
document.hasUnsavedChanges
|
||||
}
|
||||
|
||||
public var activeColumnNumber: Int {
|
||||
guard lines.indices.contains(activeLineIndex) else { return 1 }
|
||||
let activeLine = lines[activeLineIndex]
|
||||
let offset = selection.location - activeLine.range.location
|
||||
return max(0, min(offset, activeLine.range.length)) + 1
|
||||
}
|
||||
|
||||
public mutating func updateSource(_ source: String) {
|
||||
document.source = source
|
||||
activeLineIndex = Self.lineIndex(containing: selection.location, in: source)
|
||||
|
|
|
|||
|
|
@ -91,42 +91,35 @@ public struct HybridMarkdownEditor: View, EditorView {
|
|||
|
||||
EditorStatusBar(
|
||||
activeLineIndex: viewModel.state.activeLineIndex,
|
||||
columnNumber: viewModel.state.activeColumnNumber,
|
||||
lineCount: viewModel.state.lines.count,
|
||||
hasUnsavedChanges: viewModel.state.hasUnsavedChanges,
|
||||
activeLinePreview: activeLinePreview
|
||||
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
|
||||
)
|
||||
}
|
||||
.background(platformTextBackground)
|
||||
}
|
||||
|
||||
private var activeLinePreview: AttributedString {
|
||||
guard viewModel.state.lines.indices.contains(viewModel.state.activeLineIndex) else {
|
||||
return AttributedString()
|
||||
}
|
||||
return renderer.inlineMarkdown(for: viewModel.state.lines[viewModel.state.activeLineIndex].source)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditorStatusBar: View {
|
||||
let activeLineIndex: Int
|
||||
let columnNumber: Int
|
||||
let lineCount: Int
|
||||
let hasUnsavedChanges: Bool
|
||||
let activeLinePreview: AttributedString
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text("Line \(activeLineIndex + 1) of \(lineCount)")
|
||||
Text("Line \(activeLineIndex + 1)")
|
||||
Text("Column \(columnNumber)")
|
||||
Text("\(lineCount) lines")
|
||||
Text(hasUnsavedChanges ? "Modified" : "Saved")
|
||||
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
|
||||
Spacer()
|
||||
Text(activeLinePreview)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,10 +134,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
guard let textView = scrollView.documentView as? NSTextView else {
|
||||
return scrollView
|
||||
}
|
||||
let scrollView = ComfortableEditorScrollView()
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.drawsBackground = true
|
||||
scrollView.backgroundColor = .textBackgroundColor
|
||||
|
||||
let textStorage = NSTextStorage()
|
||||
let layoutManager = NSLayoutManager()
|
||||
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
||||
textContainer.widthTracksTextView = true
|
||||
textContainer.heightTracksTextView = false
|
||||
layoutManager.addTextContainer(textContainer)
|
||||
textStorage.addLayoutManager(layoutManager)
|
||||
|
||||
let textView = NSTextView(frame: scrollView.contentView.bounds, textContainer: textContainer)
|
||||
textView.autoresizingMask = [.width]
|
||||
textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height)
|
||||
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
|
||||
textView.delegate = context.coordinator
|
||||
textView.string = text
|
||||
|
|
@ -155,16 +166,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
textView.allowsUndo = true
|
||||
textView.usesFindPanel = true
|
||||
textView.isContinuousSpellCheckingEnabled = true
|
||||
textView.textContainerInset = NSSize(width: 20, height: 18)
|
||||
textView.backgroundColor = .textBackgroundColor
|
||||
textView.insertionPointColor = .controlAccentColor
|
||||
textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
textView.font = .systemFont(ofSize: 16, weight: .regular)
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.textContainer?.containerSize = NSSize(
|
||||
width: scrollView.contentSize.width,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
|
||||
scrollView.documentView = textView
|
||||
scrollView.editorTextView = textView
|
||||
scrollView.updateEditorInsets()
|
||||
context.coordinator.applyHybridAttributes(to: textView)
|
||||
return scrollView
|
||||
}
|
||||
|
|
@ -246,6 +259,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
guard textView.selectedRange() != range else { return }
|
||||
performProgrammaticUpdate {
|
||||
textView.setSelectedRange(range)
|
||||
textView.scrollRangeToVisible(range)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,6 +280,27 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ComfortableEditorScrollView: NSScrollView {
|
||||
weak var editorTextView: NSTextView?
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateEditorInsets()
|
||||
}
|
||||
|
||||
func updateEditorInsets() {
|
||||
guard let editorTextView else { return }
|
||||
|
||||
let readableWidth: CGFloat = 760
|
||||
let horizontalInset = max(36, floor((contentView.bounds.width - readableWidth) / 2))
|
||||
let targetInset = NSSize(width: horizontalInset, height: 38)
|
||||
|
||||
if editorTextView.textContainerInset != targetInset {
|
||||
editorTextView.textContainerInset = targetInset
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||
@Binding var text: String
|
||||
|
|
@ -284,8 +319,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
textView.autocorrectionType = .yes
|
||||
textView.smartDashesType = .no
|
||||
textView.smartQuotesType = .no
|
||||
textView.font = .monospacedSystemFont(ofSize: 15, weight: .regular)
|
||||
textView.textContainerInset = UIEdgeInsets(top: 18, left: 16, bottom: 18, right: 16)
|
||||
textView.font = .systemFont(ofSize: 17, weight: .regular)
|
||||
textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
|
||||
textView.backgroundColor = .systemBackground
|
||||
context.coordinator.applyHybridAttributes(to: textView)
|
||||
return textView
|
||||
|
|
@ -344,6 +379,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
if textView.selectedRange != selectedRange,
|
||||
selectedRange.location <= textView.text.utf16.count {
|
||||
textView.selectedRange = selectedRange
|
||||
textView.scrollRangeToVisible(selectedRange)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +433,7 @@ private enum MarkdownTextStyler {
|
|||
if line.index == activeLineIndex {
|
||||
textStorage.addAttributes([
|
||||
.backgroundColor: activeLineBackgroundColor,
|
||||
.font: monospacedFont(size: 14, weight: .regular)
|
||||
.font: monospacedFont(size: 15, weight: .regular)
|
||||
], range: line.range)
|
||||
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
||||
} else {
|
||||
|
|
@ -431,7 +467,7 @@ private enum MarkdownTextStyler {
|
|||
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: secondaryTextColor,
|
||||
.font: monospacedFont(size: 12, weight: .regular)
|
||||
.font: monospacedFont(size: 13, weight: .regular)
|
||||
], range: heading.markerRange)
|
||||
textStorage.addAttributes([
|
||||
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold)
|
||||
|
|
@ -442,7 +478,7 @@ private enum MarkdownTextStyler {
|
|||
if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") {
|
||||
textStorage.addAttributes([
|
||||
.foregroundColor: secondaryTextColor,
|
||||
.font: monospacedFont(size: 13, weight: .regular)
|
||||
.font: monospacedFont(size: 14, weight: .regular)
|
||||
], range: NSRange(location: line.range.location, length: min(6, line.range.length)))
|
||||
}
|
||||
|
||||
|
|
@ -464,7 +500,7 @@ private enum MarkdownTextStyler {
|
|||
) {
|
||||
applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in
|
||||
guard match.numberOfRanges > 1 else { return }
|
||||
textStorage.addAttributes([.font: systemFont(size: 14, weight: .semibold)], range: match.range(at: 1))
|
||||
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: match.range(at: 1))
|
||||
markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach {
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
|
||||
}
|
||||
|
|
@ -472,7 +508,7 @@ private enum MarkdownTextStyler {
|
|||
|
||||
applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
|
||||
guard match.numberOfRanges > 1 else { return }
|
||||
textStorage.addAttributes([.font: italicSystemFont(size: 14)], range: match.range(at: 1))
|
||||
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: match.range(at: 1))
|
||||
markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach {
|
||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
|
||||
}
|
||||
|
|
@ -509,9 +545,9 @@ private enum MarkdownTextStyler {
|
|||
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 4
|
||||
paragraph.paragraphSpacing = 3
|
||||
paragraph.paragraphSpacing = 5
|
||||
return [
|
||||
.font: systemFont(size: 14, weight: .regular),
|
||||
.font: systemFont(size: 16, weight: .regular),
|
||||
.foregroundColor: textColor,
|
||||
.paragraphStyle: paragraph
|
||||
]
|
||||
|
|
@ -564,10 +600,10 @@ private enum MarkdownTextStyler {
|
|||
|
||||
private static func headingFontSize(level: Int) -> CGFloat {
|
||||
switch level {
|
||||
case 1: 24
|
||||
case 2: 20
|
||||
case 3: 18
|
||||
default: 15
|
||||
case 1: 28
|
||||
case 2: 23
|
||||
case 3: 20
|
||||
default: 17
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,22 @@ final class EditorStateTests: XCTestCase {
|
|||
XCTAssertFalse(state.hasUnsavedChanges)
|
||||
}
|
||||
|
||||
func testActiveColumnFollowsSelectionWithinLine() {
|
||||
let source = "First line\nSecond line"
|
||||
let document = EditorDocument(
|
||||
url: URL(fileURLWithPath: "/tmp/EditorStateTests.md"),
|
||||
title: "EditorStateTests",
|
||||
source: source
|
||||
)
|
||||
var state = EditorState(document: document)
|
||||
|
||||
let secondLineLocation = (source as NSString).range(of: "Second").location
|
||||
state.updateSelection(EditorSelection(location: secondLineLocation + 3, length: 0))
|
||||
|
||||
XCTAssertEqual(state.activeLineIndex, 1)
|
||||
XCTAssertEqual(state.activeColumnNumber, 4)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testViewModelSavesDocumentToDisk() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue