Skip to content

Commit 14a6ecb

Browse files
authored
[mac] Fix pasting screenshots from system clipboard (#295) (#298)
NSTextView's default validateMenuItem(_:) disables Edit > Paste when the clipboard has only image data (no string/RTF), so Cmd+V on a screenshot silently no-op'd before reaching paste(_:). Force-allow paste: in the override and route raw image bytes (PNG/JPEG/GIF/HEIC pass through, TIFF → PNG) ourselves. Same image-paste path added to LiveEditorView's Cmd+V monitor for the experimental live-preview engine.
1 parent 3aa4431 commit 14a6ecb

2 files changed

Lines changed: 99 additions & 8 deletions

File tree

Clearly/ClearlyTextView.swift

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ final class ClearlyTextView: PersistentTextCheckingTextView {
135135

136136
// MARK: - Paste
137137

138+
/// NSTextView's default validation rejects `paste:` when the clipboard
139+
/// has no string/RTF data (e.g., a screenshot put TIFF on the clipboard
140+
/// via Cmd+Shift+Ctrl+4). The Edit > Paste menu item gets disabled and
141+
/// Cmd+V silently no-ops before our `paste(_:)` ever runs. Force-allow
142+
/// `paste:` so our override sees every Cmd+V regardless of clipboard
143+
/// contents — we route image data ourselves in `handleIncomingPasteboard`.
144+
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
145+
if menuItem.action == #selector(NSText.paste(_:)) {
146+
return true
147+
}
148+
return super.validateMenuItem(menuItem)
149+
}
150+
138151
override func paste(_ sender: Any?) {
139152
let pasteboard = NSPasteboard.general
140153
if handleIncomingPasteboard(pasteboard) { return }
@@ -173,10 +186,34 @@ final class ClearlyTextView: PersistentTextCheckingTextView {
173186
}
174187
}
175188

189+
// Preserve-format types: write original bytes with matching
190+
// extension so animations / quality / metadata survive. JPEG / GIF
191+
// / HEIC don't have built-in NSPasteboard.PasteboardType constants
192+
// on macOS, so use the UTI strings directly.
193+
let preserveFormats: [(NSPasteboard.PasteboardType, String)] = [
194+
(.png, "png"),
195+
(NSPasteboard.PasteboardType("public.jpeg"), "jpg"),
196+
(NSPasteboard.PasteboardType("com.compuserve.gif"), "gif"),
197+
(NSPasteboard.PasteboardType("public.heic"), "heic"),
198+
]
199+
for (type, ext) in preserveFormats {
200+
if let data = pasteboard.data(forType: type) {
201+
return insertPastedImage(data, ext: ext)
202+
}
203+
}
204+
205+
// TIFF is macOS's generic image container — Cmd+Shift+Ctrl+4
206+
// screenshots land here. Decode + re-encode to PNG so we don't
207+
// drop a multi-MB uncompressed sibling next to the document.
208+
if let tiff = pasteboard.data(forType: .tiff),
209+
let png = Self.pngData(from: tiff) {
210+
return insertPastedImage(png, ext: "png")
211+
}
212+
176213
if pasteboard.canReadObject(forClasses: [NSImage.self], options: nil),
177214
let image = NSImage(pasteboard: pasteboard),
178215
let png = Self.pngData(from: image) {
179-
return insertPastedPNG(png)
216+
return insertPastedImage(png, ext: "png")
180217
}
181218

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

223260
@discardableResult
224-
private func insertPastedPNG(_ png: Data) -> Bool {
261+
private func insertPastedImage(_ data: Data, ext: String) -> Bool {
225262
guard let docURL = resolveDocumentURLForPaste() else { return false }
226263
do {
227-
let result = try ImagePasteService.writePNG(png, besidesDocumentAt: docURL, presenter: nil)
264+
let result = try ImagePasteService.writeImageData(data, ext: ext, besidesDocumentAt: docURL, presenter: nil)
228265
insertText(result.markdown, replacementRange: selectedRange())
229266
return true
230267
} catch {
231-
DiagnosticLog.log("Paste: failed to write sibling PNG: \(error.localizedDescription)")
268+
DiagnosticLog.log("Paste: failed to write sibling image (\(ext)): \(error.localizedDescription)")
232269
return false
233270
}
234271
}

Clearly/LiveEditorView.swift

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,7 @@ struct LiveEditorView: NSViewRepresentable {
182182
event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
183183
event.charactersIgnoringModifiers == "v",
184184
let webView = self.webView,
185-
webView.window?.isKeyWindow == true,
186-
let text = NSPasteboard.general.string(forType: .string) else {
185+
webView.window?.isKeyWindow == true else {
187186
return event
188187
}
189188
// Only redirect paste into the editor when the webview (or one
@@ -194,8 +193,15 @@ struct LiveEditorView: NSViewRepresentable {
194193
fr.isDescendant(of: webView) else {
195194
return event
196195
}
197-
self.insertText(text)
198-
return nil // Consume — prevents WKWebView's broken native paste
196+
let pasteboard = NSPasteboard.general
197+
if self.tryInsertImageFromPasteboard(pasteboard) != nil {
198+
return nil // Consume — image was written + markdown inserted
199+
}
200+
if let text = pasteboard.string(forType: .string) {
201+
self.insertText(text)
202+
return nil // Consume — prevents WKWebView's broken native paste
203+
}
204+
return event
199205
}
200206
}
201207

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

416+
/// Reads an image from the pasteboard, writes it as a sibling file
417+
/// next to the open document, and inserts a markdown image token via
418+
/// CodeMirror's dispatch. Returns the markdown token on success, nil
419+
/// when there's no usable image or no document URL.
420+
fileprivate func tryInsertImageFromPasteboard(_ pasteboard: NSPasteboard) -> String? {
421+
guard let docURL = parent.fileURL else { return nil }
422+
423+
// Preserve-format types: write original bytes with matching
424+
// extension so animations / quality / metadata survive. JPEG /
425+
// GIF / HEIC don't have built-in NSPasteboard.PasteboardType
426+
// constants on macOS, so use the UTI strings directly.
427+
let preserveFormats: [(NSPasteboard.PasteboardType, String)] = [
428+
(.png, "png"),
429+
(NSPasteboard.PasteboardType("public.jpeg"), "jpg"),
430+
(NSPasteboard.PasteboardType("com.compuserve.gif"), "gif"),
431+
(NSPasteboard.PasteboardType("public.heic"), "heic"),
432+
]
433+
var pickedData: Data?
434+
var pickedExt: String?
435+
for (type, ext) in preserveFormats {
436+
if let data = pasteboard.data(forType: type) {
437+
pickedData = data
438+
pickedExt = ext
439+
break
440+
}
441+
}
442+
// TIFF is macOS's generic image container — Cmd+Shift+Ctrl+4
443+
// screenshots land here. Decode + re-encode to PNG so we don't
444+
// drop a multi-MB uncompressed sibling next to the document.
445+
if pickedData == nil,
446+
let tiff = pasteboard.data(forType: .tiff),
447+
let bitmap = NSBitmapImageRep(data: tiff),
448+
let png = bitmap.representation(using: .png, properties: [:]) {
449+
pickedData = png
450+
pickedExt = "png"
451+
}
452+
guard let data = pickedData, let ext = pickedExt else { return nil }
453+
454+
do {
455+
let result = try ImagePasteService.writeImageData(data, ext: ext, besidesDocumentAt: docURL, presenter: nil)
456+
self.insertText(result.markdown)
457+
return result.markdown
458+
} catch {
459+
DiagnosticLog.log("LiveEditor paste: writeImageData failed: \(error.localizedDescription)")
460+
return nil
461+
}
462+
}
463+
410464
private func scrollToOffset(_ offset: Int) {
411465
call(function: "scrollToOffset", payload: ["offset": offset])
412466
}

0 commit comments

Comments
 (0)