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, EditorCoordinator { @Published public private(set) var state: EditorState public init(document: MarkdownDocument, activeLineIndex: Int = 0) { self.state = EditorState( document: EditorDocument(markdownDocument: document), activeLineIndex: activeLineIndex ) } public var document: MarkdownDocument { state.document.markdownDocument } public var hasUnsavedChanges: Bool { state.hasUnsavedChanges } public var activeLineIndex: Int { state.activeLineIndex } public func replaceDocument(_ document: EditorDocument) { state = EditorState(document: document) } public func updateSource(_ source: String) { guard state.document.source != source else { return } state.updateSource(source) } public func updateSelection(_ selection: EditorSelection) { guard state.selection != selection else { return } 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, EditorView { @ObservedObject private var viewModel: HybridMarkdownEditorViewModel private let renderer: any MarkdownRendering public init( viewModel: HybridMarkdownEditorViewModel, renderer: any MarkdownRendering = MarkdownRenderer() ) { self.viewModel = viewModel self.renderer = renderer } public var state: EditorState { viewModel.state } public var body: some View { 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 EditorStatusBar: View { let activeLineIndex: Int let lineCount: Int let hasUnsavedChanges: Bool let activeLinePreview: AttributedString var body: some View { HStack(spacing: 12) { Text("Line \(activeLineIndex + 1) of \(lineCount)") 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) } } #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 { context.coordinator.performProgrammaticUpdate { textView.string = text } } let selectedRange = selection.range if textView.selectedRange() != selectedRange, selectedRange.location <= textView.string.utf16.count { context.coordinator.setSelection(selectedRange, in: textView) } context.coordinator.applyHybridAttributes(to: textView) } final class Coordinator: NSObject, NSTextViewDelegate { var parent: NativeMarkdownTextView private var programmaticUpdateDepth = 0 private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? init(_ parent: NativeMarkdownTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { guard !isPerformingProgrammaticUpdate else { return } guard let textView = notification.object as? NSTextView else { return } parent.text = textView.string parent.selection = EditorSelection(range: textView.selectedRange()) lastStyledText = nil applyHybridAttributes(to: textView) } func textViewDidChangeSelection(_ notification: Notification) { guard !isPerformingProgrammaticUpdate else { return } guard let textView = notification.object as? NSTextView else { return } let newSelection = EditorSelection(range: textView.selectedRange()) guard parent.selection != newSelection else { return } applyHybridAttributes(to: textView) parent.selection = newSelection } func applyHybridAttributes(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } guard shouldRestyle(textView.string) else { return } let selectedRange = textView.selectedRange() performProgrammaticUpdate { MarkdownTextStyler.apply( to: textStorage, activeLineIndex: parent.activeLineIndex, backgroundColor: .textBackgroundColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), textColor: .labelColor, secondaryTextColor: .secondaryLabelColor, accentColor: .controlAccentColor ) if textView.selectedRange() != selectedRange, selectedRange.location <= textView.string.utf16.count { textView.setSelectedRange(selectedRange) } } lastStyledText = textView.string lastStyledActiveLineIndex = parent.activeLineIndex } func setSelection(_ range: NSRange, in textView: NSTextView) { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { textView.setSelectedRange(range) } } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func shouldRestyle(_ text: String) -> Bool { lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex } } } #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 { context.coordinator.performProgrammaticUpdate { textView.text = text } } context.coordinator.applyHybridAttributes(to: textView) } final class Coordinator: NSObject, UITextViewDelegate { var parent: NativeMarkdownTextView private var programmaticUpdateDepth = 0 private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? init(_ parent: NativeMarkdownTextView) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { guard !isPerformingProgrammaticUpdate else { return } parent.text = textView.text parent.selection = EditorSelection(range: textView.selectedRange) lastStyledText = nil applyHybridAttributes(to: textView) } func textViewDidChangeSelection(_ textView: UITextView) { guard !isPerformingProgrammaticUpdate else { return } let newSelection = EditorSelection(range: textView.selectedRange) guard parent.selection != newSelection else { return } applyHybridAttributes(to: textView) parent.selection = newSelection } func applyHybridAttributes(to textView: UITextView) { guard shouldRestyle(textView.text) else { return } let selectedRange = textView.selectedRange performProgrammaticUpdate { MarkdownTextStyler.apply( to: textView.textStorage, activeLineIndex: parent.activeLineIndex, backgroundColor: .systemBackground, activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10), textColor: .label, secondaryTextColor: .secondaryLabel, accentColor: .systemBlue ) if textView.selectedRange != selectedRange, selectedRange.location <= textView.text.utf16.count { textView.selectedRange = selectedRange } } lastStyledText = textView.text lastStyledActiveLineIndex = parent.activeLineIndex } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func shouldRestyle(_ text: String) -> Bool { lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex } } } #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 ) } } 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("(? 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 } } private var platformTextBackground: Color { #if os(macOS) Color(nsColor: .textBackgroundColor) #else Color(uiColor: .systemBackground) #endif }