WIP(editor): syntax highlighting + code block line numbers
Syntax highlighting: wires the already-pinned HighlighterSwiftBridge (MarkdownEngineCodeBlocks) into services.syntaxHighlighter on every NativeTextViewWrapper, shared via one CodeSyntaxHighlighting.shared instance (JSContext init is expensive, don't build one per text view). Line numbers: app-side CodeBlockLineNumberGutter overlay positioned via onCodeBlockSelectionChange's rect, widens codeBlock.horizontalIndent to make room. Marked WIP: line-number overlays (and the engine's own built-in copy-button overlay) stay empty on cold document load until the user's first click/keystroke — a real gap in swift-markdown-engine's rebuild path (cachedCodeBlockTokens is only ever seeded by AppKit text- delegate callbacks, not the programmatic text-binding rebuild), not fixable from the app side without patching the package. See TODO.local.md for the full trace and options. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
/// Read-only for now — document/overview editing isn't wired up yet. Uses
|
||||||
@@ -18,7 +19,10 @@ struct CollectionOverviewContent: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $markdown,
|
text: $markdown,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
|
||||||
@@ -44,7 +45,10 @@ struct DocumentPresentSheet: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $text,
|
text: $text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
)
|
)
|
||||||
.font(.system(size: 18))
|
.font(.system(size: 18))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||||
@@ -17,6 +18,21 @@ struct DocumentReaderView: View {
|
|||||||
/// Outline — see `SettingsView.editorDetail`.
|
/// Outline — see `SettingsView.editorDetail`.
|
||||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
|
|
||||||
|
/// Outline's own "Show line numbers" preference (synced, read via
|
||||||
|
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||||
|
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
|
||||||
|
/// problem here since this is read directly in the view, not the
|
||||||
|
/// view model.
|
||||||
|
private var showCodeBlockLineNumbers: Bool {
|
||||||
|
session.userPreferences?.codeBlockLineNumbers ?? false
|
||||||
|
}
|
||||||
|
/// Widened left indent reserved for the number gutter when line numbers
|
||||||
|
/// are on (default is 12pt, just enough margin, no room for digits).
|
||||||
|
private static let lineNumberGutterWidth: CGFloat = 32
|
||||||
|
private var editorCodeBlockStyle: CodeBlockStyle {
|
||||||
|
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
|
||||||
|
}
|
||||||
|
|
||||||
@State private var viewModel: DocumentReaderViewModel
|
@State private var viewModel: DocumentReaderViewModel
|
||||||
let apiClient: OutlineAPIClient
|
let apiClient: OutlineAPIClient
|
||||||
let document: OutlineDocument
|
let document: OutlineDocument
|
||||||
@@ -44,6 +60,14 @@ struct DocumentReaderView: View {
|
|||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
@State private var isShowingNewDocumentSheet = false
|
@State private var isShowingNewDocumentSheet = false
|
||||||
@State private var actionErrorMessage: String?
|
@State private var actionErrorMessage: String?
|
||||||
|
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||||
|
/// one array per instance (main pane, split-view preview pane), since
|
||||||
|
/// each lays the same text out at a different width and gets different
|
||||||
|
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
|
||||||
|
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
|
||||||
|
/// widened, which is gated on the same flag).
|
||||||
|
@State private var readerCodeBlocks: [CodeBlockSelection] = []
|
||||||
|
@State private var previewCodeBlocks: [CodeBlockSelection] = []
|
||||||
|
|
||||||
init(
|
init(
|
||||||
apiClient: OutlineAPIClient,
|
apiClient: OutlineAPIClient,
|
||||||
@@ -307,12 +331,24 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
codeBlock: editorCodeBlockStyle,
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: viewModel.isEffectivelyEditable
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
|
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
|
||||||
)
|
)
|
||||||
|
if showCodeBlockLineNumbers {
|
||||||
|
ForEach(readerCodeBlocks) { selection in
|
||||||
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -360,12 +396,24 @@ struct DocumentReaderView: View {
|
|||||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(
|
||||||
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
|
codeBlock: editorCodeBlockStyle,
|
||||||
|
heightBehavior: .fitsContent
|
||||||
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: false
|
isEditable: false,
|
||||||
|
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
|
||||||
)
|
)
|
||||||
|
if showCodeBlockLineNumbers {
|
||||||
|
ForEach(previewCodeBlocks) { selection in
|
||||||
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
}
|
}
|
||||||
@@ -610,4 +658,58 @@ struct DocumentReaderView: View {
|
|||||||
operation.run()
|
operation.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One code block's number gutter, positioned absolutely over a
|
||||||
|
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay
|
||||||
|
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
|
||||||
|
///
|
||||||
|
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
|
||||||
|
/// + close fence line), matching what the engine actually lays out — the
|
||||||
|
/// fence lines render with invisible (`.clear`) text once the caret leaves
|
||||||
|
/// the block, but they don't collapse to zero height, so the block is
|
||||||
|
/// always exactly `content line count + 2` rows tall. `selection.code` is
|
||||||
|
/// content only, so the row height and number positions below both account
|
||||||
|
/// for that phantom top/bottom row explicitly instead of dividing by the
|
||||||
|
/// content line count alone (which would drift the numbers upward, more so
|
||||||
|
/// per line, the taller the block).
|
||||||
|
///
|
||||||
|
/// Known limitation, accepted rather than fixable app-side: a content line
|
||||||
|
/// that soft-wraps onto a second visual row (MarkdownEngine always
|
||||||
|
/// char-wraps code blocks, no way to opt out without forking the package)
|
||||||
|
/// throws this off — every row below it reads one line low. Documented in
|
||||||
|
/// TODO.local.md alongside the same package's other gaps.
|
||||||
|
private struct CodeBlockLineNumberGutter: View {
|
||||||
|
let selection: CodeBlockSelection
|
||||||
|
let gutterWidth: CGFloat
|
||||||
|
|
||||||
|
/// `selection.code` (`token.contentRange`) always ends with exactly one
|
||||||
|
/// trailing `\n` per content line — the range runs right up to the
|
||||||
|
/// start of the closing fence's own line, so the newline that ends the
|
||||||
|
/// last content line is included, but there's never an unterminated
|
||||||
|
/// final line to add one more for. Counting `\n` characters directly
|
||||||
|
/// (not `.components(separatedBy:).count`, which is one too many
|
||||||
|
/// whenever the string ends in the separator) is what makes a
|
||||||
|
/// single-line block read "1", not "2".
|
||||||
|
private var contentLineCount: Int {
|
||||||
|
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let totalRows = CGFloat(contentLineCount + 2)
|
||||||
|
let rowHeight = selection.rect.height / totalRows
|
||||||
|
ForEach(0..<contentLineCount, id: \.self) { line in
|
||||||
|
Text("\(line + 1)")
|
||||||
|
.font(.system(size: 10, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: gutterWidth - 6, alignment: .trailing)
|
||||||
|
.position(
|
||||||
|
x: selection.rect.minX + (gutterWidth - 6) / 2,
|
||||||
|
// +1.5 rows: skip the invisible open-fence row, then
|
||||||
|
// center within this content row.
|
||||||
|
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import MarkdownEngineCodeBlocks
|
||||||
|
|
||||||
|
/// One `HighlighterSwiftBridge` for the whole app. It owns a JavaScriptCore
|
||||||
|
/// context (expensive to spin up) plus its own highlight cache, so every
|
||||||
|
/// `NativeTextViewWrapper` should share this instance rather than each
|
||||||
|
/// constructing its own.
|
||||||
|
enum CodeSyntaxHighlighting {
|
||||||
|
static let shared = HighlighterSwiftBridge()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Reference in New Issue
Block a user