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 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<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)
|
||||
}
|
||||
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
|
||||
public var hasUnsavedChanges: Bool {
|
||||
state.hasUnsavedChanges
|
||||
}
|
||||
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
|
||||
private let renderer: any MarkdownRendering
|
||||
|
||||
|
|
@ -58,96 +68,467 @@ 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)
|
||||
}
|
||||
|
||||
@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)
|
||||
HStack(spacing: 12) {
|
||||
Text("Line \(activeLineIndex + 1) of \(lineCount)")
|
||||
Text(hasUnsavedChanges ? "Modified" : "Saved")
|
||||
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
|
||||
Spacer()
|
||||
Text(activeLinePreview)
|
||||
.lineLimit(1)
|
||||
.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)
|
||||
.foregroundStyle(.secondary)
|
||||
.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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
case .blank:
|
||||
Spacer(minLength: 20)
|
||||
}
|
||||
#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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func headingFont(level: Int) -> Font {
|
||||
switch level {
|
||||
case 1: .largeTitle
|
||||
case 2: .title
|
||||
case 3: .title2
|
||||
default: .headline
|
||||
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