Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions Clearly/ClearlyTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ final class ClearlyTextView: PersistentTextCheckingTextView {

// MARK: - Paste

/// NSTextView's default validation rejects `paste:` when the clipboard
/// has no string/RTF data (e.g., a screenshot put TIFF on the clipboard
/// via Cmd+Shift+Ctrl+4). The Edit > Paste menu item gets disabled and
/// Cmd+V silently no-ops before our `paste(_:)` ever runs. Force-allow
/// `paste:` so our override sees every Cmd+V regardless of clipboard
/// contents — we route image data ourselves in `handleIncomingPasteboard`.
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
if menuItem.action == #selector(NSText.paste(_:)) {
return true
}
return super.validateMenuItem(menuItem)
}

override func paste(_ sender: Any?) {
let pasteboard = NSPasteboard.general
if handleIncomingPasteboard(pasteboard) { return }
Expand Down Expand Up @@ -173,10 +186,34 @@ final class ClearlyTextView: PersistentTextCheckingTextView {
}
}

// Preserve-format types: write original bytes with matching
// extension so animations / quality / metadata survive. JPEG / GIF
// / HEIC don't have built-in NSPasteboard.PasteboardType constants
// on macOS, so use the UTI strings directly.
let preserveFormats: [(NSPasteboard.PasteboardType, String)] = [
(.png, "png"),
(NSPasteboard.PasteboardType("public.jpeg"), "jpg"),
(NSPasteboard.PasteboardType("com.compuserve.gif"), "gif"),
(NSPasteboard.PasteboardType("public.heic"), "heic"),
]
for (type, ext) in preserveFormats {
if let data = pasteboard.data(forType: type) {
return insertPastedImage(data, ext: ext)
}
}

// TIFF is macOS's generic image container — Cmd+Shift+Ctrl+4
// screenshots land here. Decode + re-encode to PNG so we don't
// drop a multi-MB uncompressed sibling next to the document.
if let tiff = pasteboard.data(forType: .tiff),
let png = Self.pngData(from: tiff) {
return insertPastedImage(png, ext: "png")
}

if pasteboard.canReadObject(forClasses: [NSImage.self], options: nil),
let image = NSImage(pasteboard: pasteboard),
let png = Self.pngData(from: image) {
return insertPastedPNG(png)
return insertPastedImage(png, ext: "png")
}

if let text = pasteboard.string(forType: .string)?.trimmingCharacters(in: .whitespacesAndNewlines),
Expand Down Expand Up @@ -221,14 +258,14 @@ final class ClearlyTextView: PersistentTextCheckingTextView {
}

@discardableResult
private func insertPastedPNG(_ png: Data) -> Bool {
private func insertPastedImage(_ data: Data, ext: String) -> Bool {
guard let docURL = resolveDocumentURLForPaste() else { return false }
do {
let result = try ImagePasteService.writePNG(png, besidesDocumentAt: docURL, presenter: nil)
let result = try ImagePasteService.writeImageData(data, ext: ext, besidesDocumentAt: docURL, presenter: nil)
insertText(result.markdown, replacementRange: selectedRange())
return true
} catch {
DiagnosticLog.log("Paste: failed to write sibling PNG: \(error.localizedDescription)")
DiagnosticLog.log("Paste: failed to write sibling image (\(ext)): \(error.localizedDescription)")
return false
}
}
Expand Down
62 changes: 58 additions & 4 deletions Clearly/LiveEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,7 @@ struct LiveEditorView: NSViewRepresentable {
event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
event.charactersIgnoringModifiers == "v",
let webView = self.webView,
webView.window?.isKeyWindow == true,
let text = NSPasteboard.general.string(forType: .string) else {
webView.window?.isKeyWindow == true else {
return event
}
// Only redirect paste into the editor when the webview (or one
Expand All @@ -194,8 +193,15 @@ struct LiveEditorView: NSViewRepresentable {
fr.isDescendant(of: webView) else {
return event
}
self.insertText(text)
return nil // Consume — prevents WKWebView's broken native paste
let pasteboard = NSPasteboard.general
if self.tryInsertImageFromPasteboard(pasteboard) != nil {
return nil // Consume — image was written + markdown inserted
}
if let text = pasteboard.string(forType: .string) {
self.insertText(text)
return nil // Consume — prevents WKWebView's broken native paste
}
return event
}
}

Expand Down Expand Up @@ -407,6 +413,54 @@ struct LiveEditorView: NSViewRepresentable {
call(function: "insertText", payload: ["text": text])
}

/// Reads an image from the pasteboard, writes it as a sibling file
/// next to the open document, and inserts a markdown image token via
/// CodeMirror's dispatch. Returns the markdown token on success, nil
/// when there's no usable image or no document URL.
fileprivate func tryInsertImageFromPasteboard(_ pasteboard: NSPasteboard) -> String? {
guard let docURL = parent.fileURL else { return nil }

// Preserve-format types: write original bytes with matching
// extension so animations / quality / metadata survive. JPEG /
// GIF / HEIC don't have built-in NSPasteboard.PasteboardType
// constants on macOS, so use the UTI strings directly.
let preserveFormats: [(NSPasteboard.PasteboardType, String)] = [
(.png, "png"),
(NSPasteboard.PasteboardType("public.jpeg"), "jpg"),
(NSPasteboard.PasteboardType("com.compuserve.gif"), "gif"),
(NSPasteboard.PasteboardType("public.heic"), "heic"),
]
var pickedData: Data?
var pickedExt: String?
for (type, ext) in preserveFormats {
if let data = pasteboard.data(forType: type) {
pickedData = data
pickedExt = ext
break
}
}
// TIFF is macOS's generic image container — Cmd+Shift+Ctrl+4
// screenshots land here. Decode + re-encode to PNG so we don't
// drop a multi-MB uncompressed sibling next to the document.
if pickedData == nil,
let tiff = pasteboard.data(forType: .tiff),
let bitmap = NSBitmapImageRep(data: tiff),
let png = bitmap.representation(using: .png, properties: [:]) {
pickedData = png
pickedExt = "png"
}
guard let data = pickedData, let ext = pickedExt else { return nil }

do {
let result = try ImagePasteService.writeImageData(data, ext: ext, besidesDocumentAt: docURL, presenter: nil)
self.insertText(result.markdown)
return result.markdown
} catch {
DiagnosticLog.log("LiveEditor paste: writeImageData failed: \(error.localizedDescription)")
return nil
}
}

private func scrollToOffset(_ offset: Int) {
call(function: "scrollToOffset", payload: ["offset": offset])
}
Expand Down
Loading