feat(editor): add native text view bridge
This commit is contained in:
parent
bd54714fa0
commit
93d095feeb
1 changed files with 487 additions and 106 deletions
|
|
@ -1,52 +1,62 @@
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SaplingCore
|
import SaplingCore
|
||||||
import SaplingRenderer
|
import SaplingRenderer
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#elseif os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class HybridMarkdownEditorViewModel: ObservableObject {
|
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
|
||||||
@Published public var document: MarkdownDocument
|
@Published public private(set) var state: EditorState
|
||||||
@Published public var activeLineIndex: Int
|
|
||||||
|
|
||||||
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
|
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
|
||||||
self.document = document
|
self.state = EditorState(
|
||||||
self.activeLineIndex = activeLineIndex
|
document: EditorDocument(markdownDocument: document),
|
||||||
}
|
activeLineIndex: activeLineIndex
|
||||||
|
|
||||||
public var lines: [String] {
|
|
||||||
document.content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func bindingForActiveLine() -> Binding<String> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func activateLine(at index: Int) {
|
public var document: MarkdownDocument {
|
||||||
activeLineIndex = index
|
state.document.markdownDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceActiveLine(with newValue: String) {
|
public var hasUnsavedChanges: Bool {
|
||||||
var updatedLines = lines
|
state.hasUnsavedChanges
|
||||||
if updatedLines.indices.contains(activeLineIndex) {
|
|
||||||
updatedLines[activeLineIndex] = newValue
|
|
||||||
} else {
|
|
||||||
updatedLines.append(newValue)
|
|
||||||
activeLineIndex = updatedLines.endIndex - 1
|
|
||||||
}
|
}
|
||||||
document.content = updatedLines.joined(separator: "\n")
|
|
||||||
|
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
|
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
|
||||||
private let renderer: any MarkdownRendering
|
private let renderer: any MarkdownRendering
|
||||||
|
|
||||||
|
|
@ -58,96 +68,467 @@ public struct HybridMarkdownEditor: View {
|
||||||
self.renderer = renderer
|
self.renderer = renderer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var state: EditorState {
|
||||||
|
viewModel.state
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
LazyVStack(alignment: .leading, spacing: 4) {
|
NativeMarkdownTextView(
|
||||||
ForEach(Array(viewModel.lines.enumerated()), id: \.offset) { index, line in
|
text: Binding(
|
||||||
if index == viewModel.activeLineIndex {
|
get: { viewModel.state.document.source },
|
||||||
TextEditor(text: viewModel.bindingForActiveLine())
|
set: { viewModel.updateSource($0) }
|
||||||
.font(.system(.body, design: .monospaced))
|
),
|
||||||
.scrollContentBackground(.hidden)
|
selection: Binding(
|
||||||
.padding(.horizontal, 8)
|
get: { viewModel.state.selection },
|
||||||
.padding(.vertical, 4)
|
set: { viewModel.updateSelection($0) }
|
||||||
.frame(minHeight: 34)
|
),
|
||||||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 6))
|
activeLineIndex: viewModel.state.activeLineIndex
|
||||||
} else {
|
)
|
||||||
RenderedMarkdownLine(line: line, renderer: renderer)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
EditorStatusBar(
|
||||||
viewModel.activateLine(at: index)
|
activeLineIndex: viewModel.state.activeLineIndex,
|
||||||
}
|
lineCount: viewModel.state.lines.count,
|
||||||
}
|
hasUnsavedChanges: viewModel.state.hasUnsavedChanges,
|
||||||
}
|
activeLinePreview: activeLinePreview
|
||||||
}
|
)
|
||||||
.padding(20)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
}
|
||||||
.background(platformTextBackground)
|
.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 {
|
private struct EditorStatusBar: View {
|
||||||
let line: String
|
let activeLineIndex: Int
|
||||||
let renderer: any MarkdownRendering
|
let lineCount: Int
|
||||||
|
let hasUnsavedChanges: Bool
|
||||||
|
let activeLinePreview: AttributedString
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let block = renderer.blocks(for: line).first ?? .blank(id: UUID())
|
HStack(spacing: 12) {
|
||||||
blockView(block)
|
Text("Line \(activeLineIndex + 1) of \(lineCount)")
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
Text(hasUnsavedChanges ? "Modified" : "Saved")
|
||||||
.padding(.horizontal, 8)
|
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
|
||||||
.padding(.vertical, 3)
|
Spacer()
|
||||||
}
|
Text(activeLinePreview)
|
||||||
|
.lineLimit(1)
|
||||||
@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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(code)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
}
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.padding(.horizontal, 12)
|
||||||
}
|
.padding(.vertical, 6)
|
||||||
}
|
.background(.bar)
|
||||||
case .blank:
|
|
||||||
Spacer(minLength: 20)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func headingFont(level: Int) -> Font {
|
#if os(macOS)
|
||||||
switch level {
|
private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
case 1: .largeTitle
|
@Binding var text: String
|
||||||
case 2: .title
|
@Binding var selection: EditorSelection
|
||||||
case 3: .title2
|
let activeLineIndex: Int
|
||||||
default: .headline
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue