diff --git a/src/EditorView.swift b/src/EditorView.swift index 4d3d751..e509ad2 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -61,6 +61,8 @@ struct EditorTextView: NSViewRepresentable { height: CGFloat.greatestFiniteMagnitude ) + textView.registerForDraggedTypes([.fileURL]) + scrollView.documentView = textView let ruler = LineNumberRulerView(textView: textView) @@ -80,6 +82,10 @@ struct EditorTextView: NSViewRepresentable { ts.endEditing() } + DispatchQueue.main.async { + context.coordinator.triggerImageUpdate() + } + return scrollView } @@ -110,6 +116,7 @@ struct EditorTextView: NSViewRepresentable { var parent: EditorTextView weak var textView: NSTextView? weak var rulerView: LineNumberRulerView? + private var isUpdatingImages = false init(_ parent: EditorTextView) { self.parent = parent @@ -117,6 +124,7 @@ struct EditorTextView: NSViewRepresentable { func textDidChange(_ notification: Notification) { guard let tv = textView, let ts = tv.textStorage else { return } + if isUpdatingImages { return } parent.text = tv.string let sel = tv.selectedRanges ts.beginEditing() @@ -124,6 +132,10 @@ struct EditorTextView: NSViewRepresentable { ts.endEditing() tv.selectedRanges = sel rulerView?.needsDisplay = true + + DispatchQueue.main.async { [weak self] in + self?.updateInlineImages() + } } func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { @@ -136,6 +148,104 @@ struct EditorTextView: NSViewRepresentable { } 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) + 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 { @@ -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: "(? 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 class LineNumberTextView: NSTextView { @@ -720,6 +990,75 @@ class LineNumberTextView: NSTextView { widened.size.width = 2 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 = "![image](~/.swiftly/images/\(uuid).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 = ["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("![image](~/.swiftly/images/\(uuid).\(ext))") + } catch { + insertions.append("![\(url.lastPathComponent)](\(url.path))") + } + } 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 {