diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 4960887..2f5c7f3 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -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 { - 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 - } - document.content = updatedLines.joined(separator: "\n") + 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) { + 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,97 +68,468 @@ 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) + 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) } - @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) - .foregroundStyle(.secondary) - } - Text(code) - .font(.system(.body, design: .monospaced)) + 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 + } + } +} +#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 + ) } - .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) + } + + 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) } - 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) - } + } + + 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) } - case .blank: - Spacer(minLength: 20) + } + + 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 func headingFont(level: Int) -> Font { + 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: .largeTitle - case 2: .title - case 3: .title2 - default: .headline + 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 {