feat(editor): add native text view bridge

This commit is contained in:
Feror 2026-05-29 17:55:37 +02:00
parent bd54714fa0
commit 93d095feeb

View file

@ -1,52 +1,62 @@
import Foundation
import SwiftUI
import SaplingCore
import SaplingRenderer
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject {
@Published public var document: MarkdownDocument
@Published public var activeLineIndex: Int
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
@Published public private(set) var state: EditorState
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.document = document
self.activeLineIndex = activeLineIndex
}
public var lines: [String] {
document.content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
}
public func bindingForActiveLine() -> Binding<String> {
Binding(
get: { [weak self] in
guard let self else { return "" }
let lines = self.lines
guard lines.indices.contains(self.activeLineIndex) else { return "" }
return lines[self.activeLineIndex]
},
set: { [weak self] newValue in
self?.replaceActiveLine(with: newValue)
}
self.state = EditorState(
document: EditorDocument(markdownDocument: document),
activeLineIndex: activeLineIndex
)
}
public func activateLine(at index: Int) {
activeLineIndex = index
public var document: MarkdownDocument {
state.document.markdownDocument
}
private func replaceActiveLine(with newValue: String) {
var updatedLines = lines
if updatedLines.indices.contains(activeLineIndex) {
updatedLines[activeLineIndex] = newValue
} else {
updatedLines.append(newValue)
activeLineIndex = updatedLines.endIndex - 1
public var hasUnsavedChanges: Bool {
state.hasUnsavedChanges
}
document.content = updatedLines.joined(separator: "\n")
public var activeLineIndex: Int {
state.activeLineIndex
}
public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document)
}
public func updateSource(_ source: String) {
state.updateSource(source)
}
public func updateSelection(_ selection: EditorSelection) {
state.updateSelection(selection)
}
public func save() throws {
try state.document.source.write(to: state.document.url, atomically: true, encoding: .utf8)
state.markSaved()
}
public static func loadDocument(at url: URL) throws -> MarkdownDocument {
let source = try String(contentsOf: url, encoding: .utf8)
let title = url.deletingPathExtension().lastPathComponent
return MarkdownDocument(url: url, title: title, content: source)
}
}
public struct HybridMarkdownEditor: View {
public struct HybridMarkdownEditor: View, EditorView {
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
private let renderer: any MarkdownRendering
@ -58,96 +68,467 @@ public struct HybridMarkdownEditor: View {
self.renderer = renderer
}
public var state: EditorState {
viewModel.state
}
public var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(Array(viewModel.lines.enumerated()), id: \.offset) { index, line in
if index == viewModel.activeLineIndex {
TextEditor(text: viewModel.bindingForActiveLine())
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(minHeight: 34)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 6))
} else {
RenderedMarkdownLine(line: line, renderer: renderer)
.contentShape(Rectangle())
.onTapGesture {
viewModel.activateLine(at: index)
}
}
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
NativeMarkdownTextView(
text: Binding(
get: { viewModel.state.document.source },
set: { viewModel.updateSource($0) }
),
selection: Binding(
get: { viewModel.state.selection },
set: { viewModel.updateSelection($0) }
),
activeLineIndex: viewModel.state.activeLineIndex
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
lineCount: viewModel.state.lines.count,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges,
activeLinePreview: activeLinePreview
)
}
.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 RenderedMarkdownLine: View {
let line: String
let renderer: any MarkdownRendering
private struct EditorStatusBar: View {
let activeLineIndex: Int
let lineCount: Int
let hasUnsavedChanges: Bool
let activeLinePreview: AttributedString
var body: some View {
let block = renderer.blocks(for: line).first ?? .blank(id: UUID())
blockView(block)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.vertical, 3)
}
@ViewBuilder
private func blockView(_ block: RenderedMarkdownBlock) -> some View {
switch block {
case .heading(_, let level, let text):
Text(text)
.font(headingFont(level: level))
.fontWeight(.semibold)
case .paragraph(_, let text):
Text(text)
.font(.body)
case .codeBlock(_, let language, let code):
VStack(alignment: .leading, spacing: 6) {
if let language {
Text(language.uppercased())
.font(.caption2)
HStack(spacing: 12) {
Text("Line \(activeLineIndex + 1) of \(lineCount)")
Text(hasUnsavedChanges ? "Modified" : "Saved")
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
Spacer()
Text(activeLinePreview)
.lineLimit(1)
.foregroundStyle(.secondary)
}
Text(code)
.font(.system(.body, design: .monospaced))
}
.padding(10)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 6))
case .task(_, let checked, let text):
HStack(spacing: 8) {
Image(systemName: checked ? "checkmark.square.fill" : "square")
.foregroundStyle(checked ? .green : .secondary)
Text(text)
}
case .image(_, let altText, let source):
HStack(spacing: 8) {
Image(systemName: "photo")
VStack(alignment: .leading, spacing: 2) {
Text(altText.isEmpty ? "Image" : altText)
Text(source)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
}
}
#if os(macOS)
private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}
textView.delegate = context.coordinator
textView.string = text
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
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.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
context.coordinator.applyHybridAttributes(to: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
let selectedRange = selection.range
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
private var isApplyingAttributes = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard !isApplyingAttributes,
let textView = notification.object as? NSTextView
else { return }
parent.text = textView.string
parent.selection = EditorSelection(range: textView.selectedRange())
applyHybridAttributes(to: textView)
}
func textViewDidChangeSelection(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.selection = EditorSelection(range: textView.selectedRange())
applyHybridAttributes(to: textView)
}
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
isApplyingAttributes = true
let selectedRange = textView.selectedRange()
MarkdownTextStyler.apply(
to: textStorage,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
textView.setSelectedRange(selectedRange)
isApplyingAttributes = false
}
}
case .blank:
Spacer(minLength: 20)
}
#elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.text = text
textView.allowsEditingTextAttributes = false
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.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
if textView.text != text {
textView.text = text
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
private var isApplyingAttributes = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
guard !isApplyingAttributes else { return }
parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange)
applyHybridAttributes(to: textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
parent.selection = EditorSelection(range: textView.selectedRange)
applyHybridAttributes(to: textView)
}
func applyHybridAttributes(to textView: UITextView) {
isApplyingAttributes = true
let selectedRange = textView.selectedRange
MarkdownTextStyler.apply(
to: textView.textStorage,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
textColor: .label,
secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue
)
textView.selectedRange = selectedRange
isApplyingAttributes = false
}
}
}
#endif
private enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
#elseif os(iOS)
typealias PlatformColor = UIColor
#endif
static func apply(
to textStorage: NSTextStorage,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
let source = textStorage.string as NSString
let fullRange = NSRange(location: 0, length: source.length)
guard fullRange.length > 0 else { return }
textStorage.beginEditing()
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
for line in lines(in: source as String) {
if line.index == activeLineIndex {
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 14, weight: .regular)
], range: line.range)
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
} else {
styleRenderedLine(
in: textStorage,
line: line,
source: source,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
}
private func headingFont(level: Int) -> Font {
switch level {
case 1: .largeTitle
case 2: .title
case 3: .title2
default: .headline
textStorage.endEditing()
}
private static func styleRenderedLine(
in textStorage: NSTextStorage,
line: EditorLine,
source: NSString,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
guard line.range.length > 0 else { return }
let rawLine = source.substring(with: line.range)
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 12, weight: .regular)
], range: heading.markerRange)
textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold)
], range: heading.textRange)
return
}
if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 13, weight: .regular)
], range: NSRange(location: line.range.location, length: min(6, line.range.length)))
}
styleInlineMarkdown(
in: textStorage,
line: line,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
private static func styleInlineMarkdown(
in textStorage: NSTextStorage,
line: EditorLine,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
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))
markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
}
}
applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 1 else { return }
textStorage.addAttributes([.font: italicSystemFont(size: 14)], range: match.range(at: 1))
markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
}
}
applyRegex("\\[([^\\]]+)\\]\\(([^\\)]+)\\)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 2 else { return }
textStorage.addAttributes([
.foregroundColor: accentColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
], range: match.range(at: 1))
let markerRanges = [
NSRange(location: match.range.location, length: 1),
NSRange(location: match.range(at: 1).upperBound, length: 2),
NSRange(location: match.range(at: 2).upperBound, length: 1)
]
markerRanges.forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
}
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range(at: 2))
}
}
private static func styleSourceLineMarkers(
in textStorage: NSTextStorage,
line: EditorLine,
secondaryTextColor: PlatformColor
) {
applyRegex("(```|#{1,6}|\\*\\*|\\*|\\[[ xX]\\]|\\[|\\]|\\(|\\))", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
}
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 3
return [
.font: systemFont(size: 14, weight: .regular),
.foregroundColor: textColor,
.paragraphStyle: paragraph
]
}
private static func lines(in source: String) -> [EditorLine] {
EditorState(document: EditorDocument(url: URL(fileURLWithPath: "/dev/null"), title: "", source: source)).lines
}
private static func headingPrefixRange(
in rawLine: String,
lineRange: NSRange
) -> (level: Int, markerRange: NSRange, textRange: NSRange)? {
let markerCount = rawLine.prefix { $0 == "#" }.count
guard (1...6).contains(markerCount),
rawLine.dropFirst(markerCount).first == " "
else { return nil }
let textOffset = markerCount + 1
return (
markerCount,
NSRange(location: lineRange.location, length: markerCount),
NSRange(location: lineRange.location + textOffset, length: max(0, lineRange.length - textOffset))
)
}
private static func applyRegex(
_ pattern: String,
in textStorage: NSTextStorage,
line: EditorLine,
handler: (NSTextCheckingResult) -> Void
) {
guard line.range.length > 0,
let regex = try? NSRegularExpression(pattern: pattern)
else { return }
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
}
private static func markdownDelimiterRanges(
_ fullRange: NSRange,
leading: Int,
trailing: Int
) -> [NSRange] {
[
NSRange(location: fullRange.location, length: leading),
NSRange(location: fullRange.upperBound - trailing, length: trailing)
]
}
private static func headingFontSize(level: Int) -> CGFloat {
switch level {
case 1: 24
case 2: 20
case 3: 18
default: 15
}
}
#if os(macOS)
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> NSFont {
NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask)
}
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
}
#elseif os(iOS)
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> UIFont {
UIFont.italicSystemFont(ofSize: size)
}
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
}
#endif
}
private extension NSRange {
var upperBound: Int {
location + length
}
}