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 {
|
||||
@Binding var text: String
|
||||
var evalResults: [Int: EvalEntry]
|
||||
var fileFormat: FileFormat
|
||||
var onEvaluate: () -> Void
|
||||
var onBackspaceAtStart: (() -> Void)?
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSScrollView()
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = false
|
||||
scrollView.autohidesScrollers = true
|
||||
scrollView.drawsBackground = true
|
||||
scrollView.backgroundColor = Theme.current.base
|
||||
let scrollView = NSTextView.scrollableTextView()
|
||||
let defaultTV = scrollView.documentView as! NSTextView
|
||||
|
||||
let compositor = CompositorView(frame: scrollView.contentView.bounds)
|
||||
compositor.scrollView = scrollView
|
||||
compositor.autoresizingMask = [.width]
|
||||
compositor.onContentHeightChanged = {
|
||||
compositor.frame.size.height = compositor.contentHeight
|
||||
let tc = defaultTV.textContainer!
|
||||
tc.replaceLayoutManager(MarkdownLayoutManager())
|
||||
|
||||
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
|
||||
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
|
||||
|
||||
context.coordinator.compositor = compositor
|
||||
context.coordinator.loadDocument(text)
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.triggerImageUpdate()
|
||||
context.coordinator.triggerTableUpdate()
|
||||
}
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
let coord = context.coordinator
|
||||
if coord.lastSyncedText != text {
|
||||
coord.loadDocument(text)
|
||||
guard let textView = scrollView.documentView as? LineNumberTextView else { return }
|
||||
if textView.string != text {
|
||||
let selectedRanges = textView.selectedRanges
|
||||
textView.string = text
|
||||
if let ts = textView.textStorage {
|
||||
ts.beginEditing()
|
||||
applySyntaxHighlighting(to: ts, format: fileFormat)
|
||||
ts.endEditing()
|
||||
}
|
||||
scrollView.backgroundColor = Theme.current.base
|
||||
textView.selectedRanges = selectedRanges
|
||||
updateBlockRanges(for: textView)
|
||||
}
|
||||
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 compositor: CompositorView?
|
||||
var lastSyncedText: String = ""
|
||||
weak var textView: NSTextView?
|
||||
private var isUpdatingImages = false
|
||||
private var isUpdatingTables = false
|
||||
private var embeddedTableViews: [MarkdownTableView] = []
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(_ parent: CompositorRepresentable) {
|
||||
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) {
|
||||
guard let compositor = compositor else { return }
|
||||
lastSyncedText = markdown
|
||||
|
||||
let descriptors = parseDocument(markdown)
|
||||
var newBlocks: [CompositorBlock] = []
|
||||
|
||||
for desc in descriptors {
|
||||
switch desc.type {
|
||||
case .text:
|
||||
let block = TextBlock(text: desc.content)
|
||||
block.compositor = compositor
|
||||
newBlocks.append(block)
|
||||
case .horizontalRule:
|
||||
newBlocks.append(HRBlock(sourceText: desc.content))
|
||||
case .table:
|
||||
// Stage 3 will add TableBlock; for now treat as text
|
||||
let block = TextBlock(text: desc.content)
|
||||
block.compositor = compositor
|
||||
newBlocks.append(block)
|
||||
deinit {
|
||||
for obs in observers {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
}
|
||||
}
|
||||
|
||||
compositor.setBlocks(newBlocks)
|
||||
func textDidChange(_ notification: Notification) {
|
||||
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
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateInlineImages()
|
||||
self?.updateEmbeddedTables()
|
||||
}
|
||||
}
|
||||
|
||||
func syncToBinding() {
|
||||
guard let compositor = compositor else { return }
|
||||
let serialized = serializeDocument(compositor.blocks)
|
||||
lastSyncedText = serialized
|
||||
parent.text = serialized
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||
insertNewlineWithAutoIndent(textView)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.parent.onEvaluate()
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
||||
var urlString: String?
|
||||
if let url = link as? URL {
|
||||
urlString = url.absoluteString
|
||||
} 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 {
|
||||
EditorTextView(
|
||||
CompositorRepresentable(
|
||||
text: bodyBinding,
|
||||
evalResults: offsetEvalResults(state.evalResults),
|
||||
fileFormat: state.currentFileFormat,
|
||||
|
|
@ -2683,14 +2683,14 @@ private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool {
|
|||
|
||||
// MARK: - Image Cache & Path Resolution
|
||||
|
||||
private let imageCacheDir: URL = {
|
||||
let imageCacheDir: URL = {
|
||||
let dir = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".swiftly/images", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}()
|
||||
|
||||
private func resolveLocalImagePath(_ rawPath: String) -> String? {
|
||||
func resolveLocalImagePath(_ rawPath: String) -> String? {
|
||||
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
|
||||
let expanded: String
|
||||
if rawPath.hasPrefix("~/") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue