wire compositor into app, replacing EditorTextView with CompositorRepresentable
This commit is contained in:
parent
b6a007490e
commit
b4411cc33f
|
|
@ -353,82 +353,573 @@ class HRBlock: NSObject, CompositorBlock {
|
||||||
|
|
||||||
struct CompositorRepresentable: NSViewRepresentable {
|
struct CompositorRepresentable: NSViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
var evalResults: [Int: EvalEntry]
|
||||||
|
var fileFormat: FileFormat
|
||||||
|
var onEvaluate: () -> Void
|
||||||
|
var onBackspaceAtStart: (() -> Void)?
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(self)
|
Coordinator(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSScrollView {
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
let scrollView = NSScrollView()
|
let scrollView = NSTextView.scrollableTextView()
|
||||||
scrollView.hasVerticalScroller = true
|
let defaultTV = scrollView.documentView as! NSTextView
|
||||||
scrollView.hasHorizontalScroller = false
|
|
||||||
scrollView.autohidesScrollers = true
|
|
||||||
scrollView.drawsBackground = true
|
|
||||||
scrollView.backgroundColor = Theme.current.base
|
|
||||||
|
|
||||||
let compositor = CompositorView(frame: scrollView.contentView.bounds)
|
let tc = defaultTV.textContainer!
|
||||||
compositor.scrollView = scrollView
|
tc.replaceLayoutManager(MarkdownLayoutManager())
|
||||||
compositor.autoresizingMask = [.width]
|
|
||||||
compositor.onContentHeightChanged = {
|
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
|
||||||
compositor.frame.size.height = compositor.contentHeight
|
textView.minSize = defaultTV.minSize
|
||||||
|
textView.maxSize = defaultTV.maxSize
|
||||||
|
textView.isVerticallyResizable = defaultTV.isVerticallyResizable
|
||||||
|
textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable
|
||||||
|
textView.autoresizingMask = defaultTV.autoresizingMask
|
||||||
|
|
||||||
|
textView.isEditable = true
|
||||||
|
textView.isSelectable = true
|
||||||
|
textView.allowsUndo = true
|
||||||
|
textView.isRichText = true
|
||||||
|
textView.usesFindBar = true
|
||||||
|
textView.isIncrementalSearchingEnabled = true
|
||||||
|
textView.font = Theme.editorFont
|
||||||
|
textView.textColor = Theme.current.text
|
||||||
|
textView.backgroundColor = Theme.current.base
|
||||||
|
textView.insertionPointColor = Theme.current.text
|
||||||
|
textView.selectedTextAttributes = [
|
||||||
|
.backgroundColor: Theme.current.surface1
|
||||||
|
]
|
||||||
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
||||||
|
textView.isAutomaticDashSubstitutionEnabled = false
|
||||||
|
textView.isAutomaticTextReplacementEnabled = false
|
||||||
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||||
|
textView.smartInsertDeleteEnabled = false
|
||||||
|
textView.isAutomaticLinkDetectionEnabled = false
|
||||||
|
|
||||||
|
textView.textContainerInset = NSSize(width: 4, height: 8)
|
||||||
|
textView.textContainer?.widthTracksTextView = false
|
||||||
|
textView.registerForDraggedTypes([.fileURL])
|
||||||
|
|
||||||
|
scrollView.documentView = textView
|
||||||
|
|
||||||
|
textView.string = text
|
||||||
|
textView.evalResults = evalResults
|
||||||
|
textView.delegate = context.coordinator
|
||||||
|
context.coordinator.textView = textView
|
||||||
|
|
||||||
|
if let ts = textView.textStorage {
|
||||||
|
ts.beginEditing()
|
||||||
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
||||||
|
ts.endEditing()
|
||||||
}
|
}
|
||||||
|
textView.applyEvalSpacing()
|
||||||
|
textView.typingAttributes = [
|
||||||
|
.font: Theme.editorFont,
|
||||||
|
.foregroundColor: Theme.current.text
|
||||||
|
]
|
||||||
|
updateBlockRanges(for: textView)
|
||||||
|
|
||||||
scrollView.documentView = compositor
|
DispatchQueue.main.async {
|
||||||
|
context.coordinator.triggerImageUpdate()
|
||||||
context.coordinator.compositor = compositor
|
context.coordinator.triggerTableUpdate()
|
||||||
context.coordinator.loadDocument(text)
|
}
|
||||||
|
|
||||||
return scrollView
|
return scrollView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
let coord = context.coordinator
|
guard let textView = scrollView.documentView as? LineNumberTextView else { return }
|
||||||
if coord.lastSyncedText != text {
|
if textView.string != text {
|
||||||
coord.loadDocument(text)
|
let selectedRanges = textView.selectedRanges
|
||||||
|
textView.string = text
|
||||||
|
if let ts = textView.textStorage {
|
||||||
|
ts.beginEditing()
|
||||||
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
||||||
|
ts.endEditing()
|
||||||
|
}
|
||||||
|
textView.selectedRanges = selectedRanges
|
||||||
|
updateBlockRanges(for: textView)
|
||||||
}
|
}
|
||||||
scrollView.backgroundColor = Theme.current.base
|
textView.backgroundColor = Theme.current.base
|
||||||
|
textView.insertionPointColor = Theme.current.text
|
||||||
|
textView.typingAttributes = [
|
||||||
|
.font: Theme.editorFont,
|
||||||
|
.foregroundColor: Theme.current.text
|
||||||
|
]
|
||||||
|
|
||||||
|
textView.evalResults = evalResults
|
||||||
|
textView.needsDisplay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator {
|
class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
var parent: CompositorRepresentable
|
var parent: CompositorRepresentable
|
||||||
var compositor: CompositorView?
|
weak var textView: NSTextView?
|
||||||
var lastSyncedText: String = ""
|
private var isUpdatingImages = false
|
||||||
|
private var isUpdatingTables = false
|
||||||
|
private var embeddedTableViews: [MarkdownTableView] = []
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|
||||||
init(_ parent: CompositorRepresentable) {
|
init(_ parent: CompositorRepresentable) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .focusEditor, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let tv = self?.textView else { return }
|
||||||
|
tv.window?.makeFirstResponder(tv)
|
||||||
|
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .formatDocument, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.formatCurrentDocument()
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .settingsChanged, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
||||||
|
let palette = Theme.current
|
||||||
|
tv.backgroundColor = palette.base
|
||||||
|
tv.insertionPointColor = palette.text
|
||||||
|
tv.selectedTextAttributes = [.backgroundColor: palette.surface1]
|
||||||
|
tv.typingAttributes = [
|
||||||
|
.font: Theme.editorFont,
|
||||||
|
.foregroundColor: palette.text
|
||||||
|
]
|
||||||
|
ts.beginEditing()
|
||||||
|
applySyntaxHighlighting(to: ts, format: self?.parent.fileFormat ?? .markdown)
|
||||||
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
|
tv.needsDisplay = true
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .boldSelection, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.wrapSelection(with: "**")
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .italicizeSelection, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.wrapSelection(with: "*")
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .insertTable, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.insertBlankTable()
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .smartEval, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.performSmartEval()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDocument(_ markdown: String) {
|
deinit {
|
||||||
guard let compositor = compositor else { return }
|
for obs in observers {
|
||||||
lastSyncedText = markdown
|
NotificationCenter.default.removeObserver(obs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let descriptors = parseDocument(markdown)
|
func textDidChange(_ notification: Notification) {
|
||||||
var newBlocks: [CompositorBlock] = []
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
|
if isUpdatingImages || isUpdatingTables { return }
|
||||||
|
parent.text = tv.string
|
||||||
|
let sel = tv.selectedRanges
|
||||||
|
ts.beginEditing()
|
||||||
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
|
tv.typingAttributes = [
|
||||||
|
.font: Theme.editorFont,
|
||||||
|
.foregroundColor: Theme.current.text
|
||||||
|
]
|
||||||
|
tv.selectedRanges = sel
|
||||||
|
updateBlockRanges(for: tv)
|
||||||
|
tv.needsDisplay = true
|
||||||
|
|
||||||
for desc in descriptors {
|
DispatchQueue.main.async { [weak self] in
|
||||||
switch desc.type {
|
self?.updateInlineImages()
|
||||||
case .text:
|
self?.updateEmbeddedTables()
|
||||||
let block = TextBlock(text: desc.content)
|
}
|
||||||
block.compositor = compositor
|
}
|
||||||
newBlocks.append(block)
|
|
||||||
case .horizontalRule:
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
newBlocks.append(HRBlock(sourceText: desc.content))
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||||
case .table:
|
insertNewlineWithAutoIndent(textView)
|
||||||
// Stage 3 will add TableBlock; for now treat as text
|
DispatchQueue.main.async { [weak self] in
|
||||||
let block = TextBlock(text: desc.content)
|
self?.parent.onEvaluate()
|
||||||
block.compositor = compositor
|
}
|
||||||
newBlocks.append(block)
|
return true
|
||||||
|
}
|
||||||
|
if commandSelector == #selector(NSResponder.deleteBackward(_:)) {
|
||||||
|
if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 {
|
||||||
|
parent.onBackspaceAtStart?()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
|
||||||
|
guard let text = text, text.count == 1 else { return true }
|
||||||
|
let ch = text.first!
|
||||||
|
let hasSelection = range.length > 0
|
||||||
|
|
||||||
|
if !hasSelection {
|
||||||
|
let closerChars: Set<Character> = ["}", ")", "]", "\"", "'"]
|
||||||
|
if closerChars.contains(ch) {
|
||||||
|
let str = textView.string as NSString
|
||||||
|
if range.location < str.length {
|
||||||
|
let next = Character(UnicodeScalar(str.character(at: range.location))!)
|
||||||
|
if next == ch {
|
||||||
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compositor.setBlocks(newBlocks)
|
let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"]
|
||||||
|
if let close = pairClosers[ch] {
|
||||||
|
if hasSelection {
|
||||||
|
let selected = (textView.string as NSString).substring(with: range)
|
||||||
|
textView.insertText(String(ch) + selected + String(close), replacementRange: range)
|
||||||
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
||||||
|
} else {
|
||||||
|
textView.insertText(String(ch) + String(close), replacementRange: range)
|
||||||
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == "\"" || ch == "'" {
|
||||||
|
if hasSelection {
|
||||||
|
let selected = (textView.string as NSString).substring(with: range)
|
||||||
|
textView.insertText(String(ch) + selected + String(ch), replacementRange: range)
|
||||||
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
||||||
|
} else {
|
||||||
|
textView.insertText(String(ch) + String(ch), replacementRange: range)
|
||||||
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncToBinding() {
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||||
guard let compositor = compositor else { return }
|
var urlString: String?
|
||||||
let serialized = serializeDocument(compositor.blocks)
|
if let url = link as? URL {
|
||||||
lastSyncedText = serialized
|
urlString = url.absoluteString
|
||||||
parent.text = serialized
|
} else if let str = link as? String {
|
||||||
|
urlString = str
|
||||||
|
}
|
||||||
|
guard let str = urlString, let url = URL(string: str) else { return false }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Editing helpers
|
||||||
|
|
||||||
|
private func formatCurrentDocument() {
|
||||||
|
guard let tv = textView else { return }
|
||||||
|
let format = parent.fileFormat
|
||||||
|
let text = tv.string
|
||||||
|
|
||||||
|
var formatted: String?
|
||||||
|
switch format {
|
||||||
|
case .json:
|
||||||
|
formatted = formatJSON(text)
|
||||||
|
default:
|
||||||
|
if format.isCode {
|
||||||
|
formatted = normalizeIndentation(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let result = formatted, result != text {
|
||||||
|
let sel = tv.selectedRanges
|
||||||
|
tv.string = result
|
||||||
|
parent.text = result
|
||||||
|
if let ts = tv.textStorage {
|
||||||
|
ts.beginEditing()
|
||||||
|
applySyntaxHighlighting(to: ts, format: format)
|
||||||
|
ts.endEditing()
|
||||||
|
}
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
|
tv.selectedRanges = sel
|
||||||
|
tv.needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatJSON(_ text: String) -> String? {
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
||||||
|
let result = String(data: pretty, encoding: .utf8) else { return nil }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizeIndentation(_ text: String) -> String {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
var result: [String] = []
|
||||||
|
var depth = 0
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
result.append("")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let opens = trimmed.filter { "{([".contains($0) }.count
|
||||||
|
let closes = trimmed.filter { "})]".contains($0) }.count
|
||||||
|
let delta = opens - closes
|
||||||
|
|
||||||
|
if delta < 0 { depth = max(0, depth + delta) }
|
||||||
|
let indent = String(repeating: " ", count: depth)
|
||||||
|
result.append(indent + trimmed)
|
||||||
|
if delta > 0 { depth += delta }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertNewlineWithAutoIndent(_ textView: NSTextView) {
|
||||||
|
let str = textView.string as NSString
|
||||||
|
let cursor = textView.selectedRange().location
|
||||||
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
||||||
|
let currentLine = str.substring(with: lineRange)
|
||||||
|
|
||||||
|
var indent = ""
|
||||||
|
for c in currentLine {
|
||||||
|
if c == " " || c == "\t" { indent.append(c) }
|
||||||
|
else { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") ||
|
||||||
|
trimmed.hasSuffix("do") || trimmed.hasSuffix("then") ||
|
||||||
|
trimmed.hasSuffix("(") || trimmed.hasSuffix("[")
|
||||||
|
|
||||||
|
if shouldIndent {
|
||||||
|
indent += " "
|
||||||
|
}
|
||||||
|
|
||||||
|
let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil
|
||||||
|
let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil
|
||||||
|
|
||||||
|
if let before = charBeforeCursor, let after = charAtCursor,
|
||||||
|
(before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") {
|
||||||
|
let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : ""
|
||||||
|
let insertion = "\n" + indent + "\n" + baseIndent
|
||||||
|
textView.insertText(insertion, replacementRange: textView.selectedRange())
|
||||||
|
textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wrapSelection(with wrapper: String) {
|
||||||
|
guard let tv = textView else { return }
|
||||||
|
let sel = tv.selectedRange()
|
||||||
|
guard sel.length > 0 else { return }
|
||||||
|
let str = tv.string as NSString
|
||||||
|
let selected = str.substring(with: sel)
|
||||||
|
let wrapped = wrapper + selected + wrapper
|
||||||
|
tv.insertText(wrapped, replacementRange: sel)
|
||||||
|
tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertBlankTable() {
|
||||||
|
guard let tv = textView else { return }
|
||||||
|
let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n"
|
||||||
|
tv.insertText(table, replacementRange: tv.selectedRange())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performSmartEval() {
|
||||||
|
guard let tv = textView else { return }
|
||||||
|
let str = tv.string as NSString
|
||||||
|
let cursor = tv.selectedRange().location
|
||||||
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
||||||
|
let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines)
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
if trimmed.hasPrefix("let ") {
|
||||||
|
if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex {
|
||||||
|
let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4)..<eqIdx]
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let insertion = "\n/= \(afterLet)\n"
|
||||||
|
let endOfLine = NSMaxRange(lineRange)
|
||||||
|
tv.insertText(insertion, replacementRange: NSRange(location: endOfLine, length: 0))
|
||||||
|
}
|
||||||
|
} else if !trimmed.isEmpty {
|
||||||
|
let lineStart = lineRange.location
|
||||||
|
let whitespacePrefix = line.prefix(while: { $0 == " " || $0 == "\t" })
|
||||||
|
let insertLoc = lineStart + whitespacePrefix.count
|
||||||
|
tv.insertText("/= ", replacementRange: NSRange(location: insertLoc, length: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Images
|
||||||
|
|
||||||
|
func triggerImageUpdate() {
|
||||||
|
updateInlineImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerTableUpdate() {
|
||||||
|
updateEmbeddedTables()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let imageRegex: NSRegularExpression? = {
|
||||||
|
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func updateInlineImages() {
|
||||||
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
|
guard let regex = Coordinator.imageRegex else { return }
|
||||||
|
|
||||||
|
let text = ts.string
|
||||||
|
let nsText = text as NSString
|
||||||
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
||||||
|
|
||||||
|
let existingAttachmentRanges = findExistingImageAttachments(in: ts)
|
||||||
|
let matches = regex.matches(in: text, range: fullRange)
|
||||||
|
|
||||||
|
var resolvedPaths: [String] = []
|
||||||
|
for match in matches {
|
||||||
|
let urlRange = match.range(at: 1)
|
||||||
|
let rawPath = nsText.substring(with: urlRange)
|
||||||
|
if let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved) {
|
||||||
|
resolvedPaths.append(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let neededSet = Set(resolvedPaths)
|
||||||
|
let existingSet = Set(existingAttachmentRanges.map { $0.1 })
|
||||||
|
if neededSet == existingSet { return }
|
||||||
|
|
||||||
|
isUpdatingImages = true
|
||||||
|
ts.beginEditing()
|
||||||
|
|
||||||
|
for (range, _) in existingAttachmentRanges.reversed() {
|
||||||
|
ts.deleteCharacters(in: range)
|
||||||
|
}
|
||||||
|
|
||||||
|
let recalcText = ts.string
|
||||||
|
let recalcNS = recalcText as NSString
|
||||||
|
let recalcFull = NSRange(location: 0, length: recalcNS.length)
|
||||||
|
let recalcMatches = regex.matches(in: recalcText, range: recalcFull)
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
for match in recalcMatches {
|
||||||
|
let urlRange = NSRange(location: match.range(at: 1).location + offset, length: match.range(at: 1).length)
|
||||||
|
let rawPath = recalcNS.substring(with: NSRange(location: urlRange.location, length: urlRange.length))
|
||||||
|
guard let resolved = resolveLocalImagePath(rawPath),
|
||||||
|
FileManager.default.fileExists(atPath: resolved),
|
||||||
|
let image = NSImage(contentsOfFile: resolved) else { continue }
|
||||||
|
|
||||||
|
let maxWidth: CGFloat = min(600, tv.bounds.width - 40)
|
||||||
|
let ratio = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0
|
||||||
|
let displaySize = NSSize(
|
||||||
|
width: image.size.width * ratio,
|
||||||
|
height: image.size.height * ratio
|
||||||
|
)
|
||||||
|
image.size = displaySize
|
||||||
|
|
||||||
|
let attachment = NSTextAttachment()
|
||||||
|
let cell = NSTextAttachmentCell(imageCell: image)
|
||||||
|
attachment.attachmentCell = cell
|
||||||
|
|
||||||
|
let attachStr = NSMutableAttributedString(string: "\n")
|
||||||
|
attachStr.append(NSAttributedString(attachment: attachment))
|
||||||
|
attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1))
|
||||||
|
|
||||||
|
let lineEnd = NSMaxRange(match.range) + offset
|
||||||
|
let insertAt = min(lineEnd, ts.length)
|
||||||
|
ts.insert(attachStr, at: insertAt)
|
||||||
|
offset += attachStr.length
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.endEditing()
|
||||||
|
isUpdatingImages = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] {
|
||||||
|
var results: [(NSRange, String)] = []
|
||||||
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||||
|
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
||||||
|
if value is NSTextAttachment {
|
||||||
|
var extRange = range
|
||||||
|
if extRange.location > 0 {
|
||||||
|
let prev = NSRange(location: extRange.location - 1, length: 1)
|
||||||
|
let ch = (textStorage.string as NSString).substring(with: prev)
|
||||||
|
if ch == "\n" {
|
||||||
|
extRange = NSRange(location: prev.location, length: extRange.length + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? ""
|
||||||
|
results.append((extRange, tip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Embedded tables
|
||||||
|
|
||||||
|
private func updateEmbeddedTables() {
|
||||||
|
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
|
||||||
|
let tc = tv.textContainer else { return }
|
||||||
|
|
||||||
|
for tableView in embeddedTableViews {
|
||||||
|
tableView.removeFromSuperview()
|
||||||
|
}
|
||||||
|
embeddedTableViews.removeAll()
|
||||||
|
|
||||||
|
let origin = tv.textContainerOrigin
|
||||||
|
let text = tv.string as NSString
|
||||||
|
for block in lm.blockRanges {
|
||||||
|
guard case .tableBlock = block.kind else { continue }
|
||||||
|
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
||||||
|
|
||||||
|
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||||
|
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||||
|
|
||||||
|
let tableX = origin.x + 4
|
||||||
|
let tableY = rect.origin.y + origin.y
|
||||||
|
let tableWidth = tc.containerSize.width - 8
|
||||||
|
|
||||||
|
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
|
||||||
|
tableView.frame.origin = NSPoint(x: tableX, y: tableY)
|
||||||
|
tableView.textView = tv
|
||||||
|
|
||||||
|
tableView.onTableChanged = { [weak self] updatedTable in
|
||||||
|
self?.applyTableEdit(updatedTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
tv.addSubview(tableView)
|
||||||
|
embeddedTableViews.append(tableView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyTableEdit(_ table: ParsedTable) {
|
||||||
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
|
let newMarkdown = rebuildTableMarkdown(table)
|
||||||
|
let range = table.sourceRange
|
||||||
|
guard NSMaxRange(range) <= ts.length else { return }
|
||||||
|
|
||||||
|
isUpdatingTables = true
|
||||||
|
let sel = tv.selectedRanges
|
||||||
|
ts.beginEditing()
|
||||||
|
ts.replaceCharacters(in: range, with: newMarkdown)
|
||||||
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
||||||
|
ts.endEditing()
|
||||||
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
||||||
|
tv.selectedRanges = sel
|
||||||
|
parent.text = tv.string
|
||||||
|
updateBlockRanges(for: tv)
|
||||||
|
isUpdatingTables = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1084,7 +1084,7 @@ struct EditorView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EditorTextView(
|
CompositorRepresentable(
|
||||||
text: bodyBinding,
|
text: bodyBinding,
|
||||||
evalResults: offsetEvalResults(state.evalResults),
|
evalResults: offsetEvalResults(state.evalResults),
|
||||||
fileFormat: state.currentFileFormat,
|
fileFormat: state.currentFileFormat,
|
||||||
|
|
@ -2683,14 +2683,14 @@ private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool {
|
||||||
|
|
||||||
// MARK: - Image Cache & Path Resolution
|
// MARK: - Image Cache & Path Resolution
|
||||||
|
|
||||||
private let imageCacheDir: URL = {
|
let imageCacheDir: URL = {
|
||||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".swiftly/images", isDirectory: true)
|
.appendingPathComponent(".swiftly/images", isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
return dir
|
return dir
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private func resolveLocalImagePath(_ rawPath: String) -> String? {
|
func resolveLocalImagePath(_ rawPath: String) -> String? {
|
||||||
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
|
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
|
||||||
let expanded: String
|
let expanded: String
|
||||||
if rawPath.hasPrefix("~/") {
|
if rawPath.hasPrefix("~/") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue