Compare commits

...

1 Commits

Author SHA1 Message Date
vineyardbovines 42e87a38d7 don't screenshot dictation placeholders as pasted images 2026-04-20 16:42:40 -04:00
2 changed files with 158 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
diff --git a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
index 2164aec4ec1d..d216db6d2927 100644
--- a/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
+++ b/node_modules/expo-paste-input/ios/ExpoPasteInputView.swift
@@ -511,14 +511,17 @@ class ExpoPasteInputView: ExpoView {
var attachmentRanges: [NSRange] = []
var mediaPayloads: [MediaPayload] = []
+ // Only track ranges for attachments we successfully extract a real payload
+ // from. Attachments without a payload (e.g. iOS dictation placeholders)
+ // are left alone — sanitizing them would delete characters the system
+ // manages itself, and emitting "unsupported" would raise a spurious error.
attributedText.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedText.length), options: []) { value, range, _ in
guard let attachment = value as? NSTextAttachment else {
return
}
- attachmentRanges.append(range)
-
if let payload = self.extractMediaPayload(from: attachment, textView: textView, range: range) {
+ attachmentRanges.append(range)
mediaPayloads.append(payload)
}
}
@@ -529,9 +532,8 @@ class ExpoPasteInputView: ExpoView {
return
}
- attachmentRanges.append(range)
-
if let payload = self.extractMediaPayload(from: adaptiveGlyph) {
+ attachmentRanges.append(range)
mediaPayloads.append(payload)
}
}
@@ -539,17 +541,12 @@ class ExpoPasteInputView: ExpoView {
attachmentRanges = uniqueRanges(attachmentRanges)
- guard !attachmentRanges.isEmpty else {
- return
- }
-
- sanitizeAttachments(in: textView, ranges: attachmentRanges)
-
guard !mediaPayloads.isEmpty else {
- handleUnsupportedPaste()
return
}
+ sanitizeAttachments(in: textView, ranges: attachmentRanges)
+
emitImagesAsync(for: mediaPayloads)
}
@@ -651,6 +648,11 @@ class ExpoPasteInputView: ExpoView {
}
private func extractMediaPayload(from attachment: NSTextAttachment, textView: UITextView, range: NSRange) -> MediaPayload? {
+ // Only accept attachments that carry real image payloads. We intentionally
+ // do not fall back to `image(forBounds:)` or rendering the text view's
+ // hierarchy, because system-inserted attachments (e.g. the iOS dictation
+ // placeholder) draw themselves via those paths and would cause us to
+ // emit a screenshot of the composer as a "pasted image".
if let fileWrapperData = attachment.fileWrapper?.regularFileContents,
let payload = extractMediaPayload(fromData: fileWrapperData) {
return payload
@@ -667,20 +669,6 @@ class ExpoPasteInputView: ExpoView {
return .image(image)
}
- let attachmentBounds = attachment.bounds.size.width > 0 && attachment.bounds.size.height > 0
- ? attachment.bounds
- : CGRect(origin: .zero, size: CGSize(width: 128, height: 128))
-
- if let image = attachment.image(forBounds: attachmentBounds, textContainer: textView.textContainer, characterIndex: range.location),
- image.size.width > 0,
- image.size.height > 0 {
- return .image(image)
- }
-
- if let renderedImage = renderTextAttachment(in: textView, range: range) {
- return .image(renderedImage)
- }
-
return nil
}
@@ -701,47 +689,6 @@ class ExpoPasteInputView: ExpoView {
return .imageData(data)
}
- private func renderTextAttachment(in textView: UITextView, range: NSRange) -> UIImage? {
- let glyphRange = textView.layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil)
- var rect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textView.textContainer)
-
- rect.origin.x += textView.textContainerInset.left - textView.contentOffset.x
- rect.origin.y += textView.textContainerInset.top - textView.contentOffset.y
- rect = rect.integral
-
- guard rect.width > 1, rect.height > 1 else {
- return nil
- }
-
- let format = UIGraphicsImageRendererFormat.default()
- format.scale = textView.window?.screen.scale ?? UIScreen.main.scale
- format.opaque = false
-
- let image = UIGraphicsImageRenderer(size: rect.size, format: format).image { _ in
- let drawRect = CGRect(
- origin: CGPoint(x: -rect.origin.x, y: -rect.origin.y),
- size: textView.bounds.size
- )
-
- if textView.window != nil {
- textView.drawHierarchy(in: drawRect, afterScreenUpdates: false)
- } else {
- guard let context = UIGraphicsGetCurrentContext() else {
- return
- }
-
- context.translateBy(x: -rect.origin.x, y: -rect.origin.y)
- textView.layer.render(in: context)
- }
- }
-
- guard image.size.width > 0, image.size.height > 0 else {
- return nil
- }
-
- return image
- }
-
@available(iOS 18.0, *)
private func handleAdaptiveImageGlyphInsertion(_ adaptiveGlyph: NSAdaptiveImageGlyph) -> Bool {
guard let payload = extractMediaPayload(from: adaptiveGlyph) else {
+22
View File
@@ -0,0 +1,22 @@
# Expo Paste Input Patch
`expo-paste-input` observes `UITextView.textDidChangeNotification` and treats any
`NSTextAttachment` in the text view's `attributedText` as a pasted image. When
it can't find a real image payload on an attachment, it falls back to
`image(forBounds:)` and, failing that, to a `drawHierarchy` screenshot of the
text view at the attachment's glyph rect.
iOS Dictation inserts its own `NSTextAttachment` (the shimmer/cursor indicator)
into the text view during dictation. Those attachments don't carry real image
data, so the fallbacks would fire — emitting a zoomed-in screenshot of the
composer as if the user had pasted an image at the end of dictation.
This patch:
- Removes the `image(forBounds:)` and `renderTextAttachment` fallbacks in
`extractMediaPayload` so the library only accepts attachments carrying a real
payload (`fileWrapper`, `contents`, or `image`).
- Only sanitizes (deletes) attachment ranges that produced a payload, and
skips the "unsupported" toast when an attachment has no payload. Unknown
system attachments like the dictation placeholder are left alone rather
than being ripped out from under iOS.