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, columnNumber: viewModel.state.activeColumnNumber, lineCount: viewModel.state.lines.count, hasUnsavedChanges: viewModel.state.hasUnsavedChanges ) } .background(platformTextBackground) } } private struct EditorStatusBar: View { let activeLineIndex: Int let columnNumber: Int let lineCount: Int let hasUnsavedChanges: Bool var body: some View { HStack(spacing: 12) { Text("Line \(activeLineIndex + 1)") Text("Column \(columnNumber)") Text("\(lineCount) lines") Text(hasUnsavedChanges ? "Modified" : "Saved") .foregroundStyle(hasUnsavedChanges ? .orange : .secondary) Spacer() } .font(.caption.weight(.medium)) .foregroundStyle(.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .background(.thinMaterial) } } #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 = 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 = EditorTextView(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 textView.isRichText = false textView.isEditable = true textView.isSelectable = true textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.allowsUndo = true textView.usesFindPanel = true textView.isContinuousSpellCheckingEnabled = true textView.backgroundColor = .textBackgroundColor textView.insertionPointColor = .controlAccentColor 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) context.coordinator.requestInitialFocus(for: 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) context.coordinator.requestInitialFocus(for: textView) } final class Coordinator: NSObject, NSTextViewDelegate { var parent: NativeMarkdownTextView private var programmaticUpdateDepth = 0 private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? private var didFocusTextView = false 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) textView.scrollRangeToVisible(range) } } func requestInitialFocus(for textView: NSTextView) { guard !didFocusTextView else { return } DispatchQueue.main.async { [weak self, weak textView] in guard let self, let textView, let window = textView.window, !self.didFocusTextView else { return } if window.firstResponder !== textView { window.makeFirstResponder(textView) } self.didFocusTextView = window.firstResponder === textView } } 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 } } } private final class EditorTextView: NSTextView { override var acceptsFirstResponder: Bool { true } } 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 @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 = .systemFont(ofSize: 17, weight: .regular) textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24) textView.backgroundColor = .systemBackground context.coordinator.applyHybridAttributes(to: textView) context.coordinator.requestInitialFocus(for: 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) context.coordinator.requestInitialFocus(for: textView) } final class Coordinator: NSObject, UITextViewDelegate { var parent: NativeMarkdownTextView private var programmaticUpdateDepth = 0 private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? private var didFocusTextView = false 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 textView.scrollRangeToVisible(selectedRange) } } lastStyledText = textView.text lastStyledActiveLineIndex = parent.activeLineIndex } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } func requestInitialFocus(for textView: UITextView) { guard !didFocusTextView else { return } DispatchQueue.main.async { [weak self, weak textView] in guard let self, let textView, textView.window != nil, !self.didFocusTextView else { return } self.didFocusTextView = textView.becomeFirstResponder() } } 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: 15, 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: 13, 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: 14, 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: 16, 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: 16)], 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 = 5 return [ .font: systemFont(size: 16, 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: 28 case 2: 23 case 3: 20 default: 17 } } #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 }