613 lines
22 KiB
Swift
613 lines
22 KiB
Swift
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("(?<!\\*)\\*([^*]+)\\*(?!\\*)", 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
|
|
}
|
|
}
|
|
|
|
private var platformTextBackground: Color {
|
|
#if os(macOS)
|
|
Color(nsColor: .textBackgroundColor)
|
|
#else
|
|
Color(uiColor: .systemBackground)
|
|
#endif
|
|
}
|