Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

695 lines
24 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,
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("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 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
}