1: markdown links, image syntax, and URL autodetection
This commit is contained in:
parent
6b5404f679
commit
6d166a0f0b
|
|
@ -61,6 +61,8 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
height: CGFloat.greatestFiniteMagnitude
|
height: CGFloat.greatestFiniteMagnitude
|
||||||
)
|
)
|
||||||
|
|
||||||
|
textView.registerForDraggedTypes([.fileURL])
|
||||||
|
|
||||||
scrollView.documentView = textView
|
scrollView.documentView = textView
|
||||||
|
|
||||||
let ruler = LineNumberRulerView(textView: textView)
|
let ruler = LineNumberRulerView(textView: textView)
|
||||||
|
|
@ -80,6 +82,10 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
context.coordinator.triggerImageUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
return scrollView
|
return scrollView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +116,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
var parent: EditorTextView
|
var parent: EditorTextView
|
||||||
weak var textView: NSTextView?
|
weak var textView: NSTextView?
|
||||||
weak var rulerView: LineNumberRulerView?
|
weak var rulerView: LineNumberRulerView?
|
||||||
|
private var isUpdatingImages = false
|
||||||
|
|
||||||
init(_ parent: EditorTextView) {
|
init(_ parent: EditorTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
@ -117,6 +124,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
func textDidChange(_ notification: Notification) {
|
||||||
guard let tv = textView, let ts = tv.textStorage else { return }
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
|
if isUpdatingImages { return }
|
||||||
parent.text = tv.string
|
parent.text = tv.string
|
||||||
let sel = tv.selectedRanges
|
let sel = tv.selectedRanges
|
||||||
ts.beginEditing()
|
ts.beginEditing()
|
||||||
|
|
@ -124,6 +132,10 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
ts.endEditing()
|
ts.endEditing()
|
||||||
tv.selectedRanges = sel
|
tv.selectedRanges = sel
|
||||||
rulerView?.needsDisplay = true
|
rulerView?.needsDisplay = true
|
||||||
|
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.updateInlineImages()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
|
|
@ -136,6 +148,104 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func triggerImageUpdate() {
|
||||||
|
updateInlineImages()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,6 +305,9 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) {
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
|
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
|
||||||
|
highlightLinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
||||||
|
highlightImages(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
||||||
|
highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
|
private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
|
||||||
|
|
@ -712,6 +825,163 @@ private func isTableSeparator(_ trimmed: String) -> Bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Markdown Links
|
||||||
|
|
||||||
|
private func highlightLinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: "(?<!!)\\[([^\\]]+)\\]\\(([^)]+)\\)") else { return }
|
||||||
|
let text = textStorage.string
|
||||||
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
||||||
|
let matches = regex.matches(in: text, range: fullRange)
|
||||||
|
for match in matches {
|
||||||
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
||||||
|
if isInsideInlineCode(match.range, in: textStorage) { continue }
|
||||||
|
|
||||||
|
let openBracket = NSRange(location: match.range.location, length: 1)
|
||||||
|
let textRange = match.range(at: 1)
|
||||||
|
let closeBracketAndUrl = NSRange(
|
||||||
|
location: textRange.location + textRange.length,
|
||||||
|
length: match.range.length - textRange.length - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openBracket)
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeBracketAndUrl)
|
||||||
|
|
||||||
|
if textRange.length > 0 {
|
||||||
|
let urlStr = (text as NSString).substring(with: match.range(at: 2))
|
||||||
|
textStorage.addAttributes([
|
||||||
|
.foregroundColor: palette.blue,
|
||||||
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||||
|
.link: urlStr
|
||||||
|
], range: textRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Markdown Images
|
||||||
|
|
||||||
|
private func highlightImages(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: "!\\[([^\\]]*)\\]\\(([^)]+)\\)") else { return }
|
||||||
|
let text = textStorage.string
|
||||||
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
||||||
|
let matches = regex.matches(in: text, range: fullRange)
|
||||||
|
for match in matches {
|
||||||
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
||||||
|
if isInsideInlineCode(match.range, in: textStorage) { continue }
|
||||||
|
|
||||||
|
let bang = NSRange(location: match.range.location, length: 1)
|
||||||
|
let openBracket = NSRange(location: match.range.location + 1, length: 1)
|
||||||
|
let altRange = match.range(at: 1)
|
||||||
|
let closeBracketParen = NSRange(
|
||||||
|
location: altRange.location + altRange.length,
|
||||||
|
length: 2
|
||||||
|
)
|
||||||
|
let urlRange = match.range(at: 2)
|
||||||
|
let closeParen = NSRange(
|
||||||
|
location: match.range.location + match.range.length - 1,
|
||||||
|
length: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: bang)
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openBracket)
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeBracketParen)
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeParen)
|
||||||
|
|
||||||
|
if altRange.length > 0 {
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.green, range: altRange)
|
||||||
|
}
|
||||||
|
if urlRange.length > 0 {
|
||||||
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: urlRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Autolinks
|
||||||
|
|
||||||
|
private let autolinkDetector: NSDataDetector? = {
|
||||||
|
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func highlightAutolinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
||||||
|
guard let detector = autolinkDetector else { return }
|
||||||
|
let text = textStorage.string
|
||||||
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
||||||
|
|
||||||
|
let inlineCodeRanges = collectInlineCodeRanges(in: textStorage)
|
||||||
|
let linkAttrRanges = collectLinkAttributeRanges(in: textStorage)
|
||||||
|
|
||||||
|
let matches = detector.matches(in: text, range: fullRange)
|
||||||
|
for match in matches {
|
||||||
|
guard let url = match.url else { continue }
|
||||||
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
||||||
|
if rangeOverlapsAny(match.range, ranges: inlineCodeRanges) { continue }
|
||||||
|
if rangeOverlapsAny(match.range, ranges: linkAttrRanges) { continue }
|
||||||
|
|
||||||
|
textStorage.addAttributes([
|
||||||
|
.foregroundColor: palette.blue,
|
||||||
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||||
|
.link: url.absoluteString
|
||||||
|
], range: match.range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isInsideInlineCode(_ range: NSRange, in textStorage: NSTextStorage) -> Bool {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return false }
|
||||||
|
let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length)
|
||||||
|
let matches = regex.matches(in: textStorage.string, range: fullRange)
|
||||||
|
for m in matches {
|
||||||
|
if m.range.location <= range.location && NSMaxRange(m.range) >= NSMaxRange(range) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectInlineCodeRanges(in textStorage: NSTextStorage) -> [NSRange] {
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return [] }
|
||||||
|
let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length)
|
||||||
|
return regex.matches(in: textStorage.string, range: fullRange).map { $0.range }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectLinkAttributeRanges(in textStorage: NSTextStorage) -> [NSRange] {
|
||||||
|
var ranges: [NSRange] = []
|
||||||
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||||
|
textStorage.enumerateAttribute(.link, in: fullRange) { value, range, _ in
|
||||||
|
if value != nil { ranges.append(range) }
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool {
|
||||||
|
for r in ranges {
|
||||||
|
if NSIntersectionRange(range, r).length > 0 { return true }
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Image Cache & Path Resolution
|
||||||
|
|
||||||
|
private 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? {
|
||||||
|
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
|
||||||
|
let expanded: String
|
||||||
|
if rawPath.hasPrefix("~/") {
|
||||||
|
expanded = (rawPath as NSString).expandingTildeInPath
|
||||||
|
} else if rawPath.hasPrefix("/") {
|
||||||
|
expanded = rawPath
|
||||||
|
} else if rawPath.hasPrefix("file://") {
|
||||||
|
expanded = URL(string: rawPath)?.path ?? rawPath
|
||||||
|
} else {
|
||||||
|
expanded = rawPath
|
||||||
|
}
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - LineNumberTextView
|
// MARK: - LineNumberTextView
|
||||||
|
|
||||||
class LineNumberTextView: NSTextView {
|
class LineNumberTextView: NSTextView {
|
||||||
|
|
@ -720,6 +990,75 @@ class LineNumberTextView: NSTextView {
|
||||||
widened.size.width = 2
|
widened.size.width = 2
|
||||||
super.drawInsertionPoint(in: widened, color: color, turnedOn: flag)
|
super.drawInsertionPoint(in: widened, color: color, turnedOn: flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Paste (image from clipboard)
|
||||||
|
|
||||||
|
override func paste(_ sender: Any?) {
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
|
||||||
|
|
||||||
|
if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }),
|
||||||
|
let data = pb.data(forType: imageType) {
|
||||||
|
if let image = NSImage(data: data), let pngData = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: pngData),
|
||||||
|
let png = bitmap.representation(using: .png, properties: [:]) {
|
||||||
|
let uuid = UUID().uuidString
|
||||||
|
let path = imageCacheDir.appendingPathComponent("\(uuid).png")
|
||||||
|
do {
|
||||||
|
try png.write(to: path)
|
||||||
|
let markdown = ".png)"
|
||||||
|
insertText(markdown, replacementRange: selectedRange())
|
||||||
|
return
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.paste(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag and Drop
|
||||||
|
|
||||||
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||||
|
if sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: [
|
||||||
|
.urlReadingFileURLsOnly: true
|
||||||
|
]) {
|
||||||
|
return .copy
|
||||||
|
}
|
||||||
|
return super.draggingEntered(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||||
|
guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: [
|
||||||
|
.urlReadingFileURLsOnly: true
|
||||||
|
]) as? [URL] else {
|
||||||
|
return super.performDragOperation(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageExts: Set<String> = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]
|
||||||
|
var insertions: [String] = []
|
||||||
|
|
||||||
|
for url in urls {
|
||||||
|
let ext = url.pathExtension.lowercased()
|
||||||
|
if imageExts.contains(ext) {
|
||||||
|
let uuid = UUID().uuidString
|
||||||
|
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: url, to: dest)
|
||||||
|
insertions.append(".\(ext))")
|
||||||
|
} catch {
|
||||||
|
insertions.append(")")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
insertions.append("[\(url.lastPathComponent)](\(url.absoluteString))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !insertions.isEmpty {
|
||||||
|
let text = insertions.joined(separator: "\n")
|
||||||
|
insertText(text, replacementRange: selectedRange())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.performDragOperation(sender)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LineNumberRulerView: NSRulerView {
|
class LineNumberRulerView: NSRulerView {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue