Skip to content

Commit 4749460

Browse files
feat(web): image paste (#584)
# Summary + implemented image pasting on web + handled `onPasteImages` callback The reason for blob urls is that it allows us to keep the same type for `OnPasteImagesEvent`, now `uri` is just a blob url. The other alternative was base64 encoded urls but they do not work on Android. The caveat is that we generally should clean such files by doing `URL.revokeObjectURL()`(even if someone forgets to call that, those temporary files will still be deleted it's just that they may be in the memory for a bit longer than absolutely needed). I generally think that we cannot really know the best moment to call this ourselves, only our users do. I initially thought that when the component unmounts is a good time to remove those objects, but for example: an upload to a server might have failed and a retry is happening, if our component unmounted and deleted those temporary files the application code would no longer have an image to upload. Curious to see what you think ## Test Plan + Try out image pasting ## Screenshots / Videos https://github.com/user-attachments/assets/1659b08c-0b3e-4ed6-8c83-11f074ac021c ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ❌ | | Web | ✅ | ## Checklist - [x] E2E tests are passing - [ ] Required E2E tests have been added (if applicable) --------- Co-authored-by: Kacper Żółkiewski <kacper.zolkiewski@swmansion.com>
1 parent 5e1f2e7 commit 4749460

7 files changed

Lines changed: 182 additions & 18 deletions

File tree

apps/example-web/src/App.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type BlurEvent,
1111
type EnrichedInputStyle,
1212
type OnLinkDetected,
13+
type OnPasteImagesEvent,
1314
type OnSubmitEditing,
1415
type OnChangeMentionEvent,
1516
type OnMentionDetected,
@@ -215,6 +216,16 @@ function App() {
215216
setCurrentLink(e);
216217
};
217218

219+
const handlePasteImages = (e: NativeSyntheticEvent<OnPasteImagesEvent>) => {
220+
const DEFAULT_W = 80;
221+
const DEFAULT_H = 80;
222+
for (const image of e.nativeEvent.images) {
223+
const w = image.width > 0 ? image.width : DEFAULT_W;
224+
const h = image.height > 0 ? image.height : DEFAULT_H;
225+
ref.current?.setImage(image.uri, w, h);
226+
}
227+
};
228+
218229
return (
219230
<div className="container">
220231
<h1 className="app-title">Enriched Text Input</h1>
@@ -243,6 +254,7 @@ function App() {
243254
onChangeState={handleChangeState}
244255
onSubmitEditing={handleSubmitEditing}
245256
onLinkDetected={handleOnLinkDetected}
257+
onPasteImages={handlePasteImages}
246258
onStartMention={handleStartMention}
247259
onChangeMention={handleChangeMention}
248260
onEndMention={handleEndMention}

docs/INPUT_API_REFERENCE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export interface OnKeyPressEvent {
427427
Callback invoked when the user pastes one or more images or GIFs into the input.
428428

429429
- `images` - is an array of objects containing the details (URI, MIME type, and dimensions) for each pasted image/GIF.
430+
- **Web:** each `uri` is a `blob:` URL (`URL.createObjectURL`). If you retain URIs, call `URL.revokeObjectURL` when finished so blobs can be released.
430431

431432
```ts
432433
export interface OnPasteImagesEvent {

docs/WEB.md

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,35 @@ Web support is still experimental. APIs and behavior can change in future releas
88
- Headings (h1-h6)
99
- Blockquote, code block
1010
- Ordered lists, unordered lists, checkbox lists
11-
- Images(via `setImage` ref method)
11+
- Images (via `setImage` ref method and optional `onPasteImages` when pasting image data)
1212
- Manual links (via `setLink` ref method)
1313
- Mentions
1414
- `getHTML`, `setValue`, selection mapping
1515
- Core callbacks: `onChange`, `onChangeState`, `onFocus`, `onBlur`, `onSelectionChange`
1616
- Submit props: `submitBehavior` and `onSubmitEditing`. `returnKeyType` is only a hint, it maps to [enterkeyhint](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint) (`done`, `go`, `next`, `previous`, `search`, `send`, `default`/`enter`). Not all values of `ReturnKeyTypeOptions` are supported, the behavior of this prop is heavily dependent on the browser's capabilities.
1717
- Input theming via `placeholderTextColor`, `cursorColor` and `selectionColor` props
18-
- Keyboard shortcuts for formatting
18+
- Keyboard shortcuts for formatting
1919

2020
## Keyboard shortcuts
2121

22-
| Action | Mac | Windows/Linux |
23-
| --- | --- | --- |
24-
| Bold | ⌘ B | Ctrl+B |
25-
| Italic | ⌘ I | Ctrl+I |
26-
| Underline | ⌘ U | Ctrl+U |
27-
| Strikethrough | ⌘ Shift+X | Ctrl+Shift+X |
28-
| Inline code | ⌘ Shift+C | Ctrl+Shift+C |
29-
| Code block | ⌘ Alt Shift+C | Ctrl+Alt+Shift+C |
30-
| Normal paragraph | ⌘ Alt+0 | Ctrl+Alt+0 |
22+
| Action | Mac | Windows/Linux |
23+
| ------------------- | ----------------- | ----------------------- |
24+
| Bold | ⌘ B | Ctrl+B |
25+
| Italic | ⌘ I | Ctrl+I |
26+
| Underline | ⌘ U | Ctrl+U |
27+
| Strikethrough | ⌘ Shift+X | Ctrl+Shift+X |
28+
| Inline code | ⌘ Shift+C | Ctrl+Shift+C |
29+
| Code block | ⌘ Alt Shift+C | Ctrl+Alt+Shift+C |
30+
| Normal paragraph | ⌘ Alt+0 | Ctrl+Alt+0 |
3131
| Heading `n` (h1–h6) | ⌘ Alt+1 … ⌘ Alt+6 | Ctrl+Alt+1 … Ctrl+Alt+6 |
32-
| Numbered list | ⌘ Shift+7 | Ctrl+Shift+7 |
33-
| Bulleted list | ⌘ Shift+8 | Ctrl+Shift+8 |
34-
| Checkbox list | ⌘ Shift+9 | Ctrl+Shift+9 |
35-
| Paste plain text | ⌘ Shift+V | Ctrl+Shift+V |
36-
32+
| Numbered list | ⌘ Shift+7 | Ctrl+Shift+7 |
33+
| Bulleted list | ⌘ Shift+8 | Ctrl+Shift+8 |
34+
| Checkbox list | ⌘ Shift+9 | Ctrl+Shift+9 |
35+
| Paste plain text | ⌘ Shift+V | Ctrl+Shift+V |
3736

3837
## Unsupported
3938

4039
- **`returnKeyLabel`**: ignored on web, it's not possible to set it inside a browser.
41-
- **Pasting images**: `onPasteImages` is never called.
4240
- **Automatic link detection**: `linkRegex` is ignored. Links only work when set explicitly via the `setLink` ref method.
4341
- **Context menu**: `contextMenuItems` is ignored.
4442
- **HTML normalizer flag**: `useHtmlNormalizer` is ignored; paste behavior follows the browser pipeline.

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,11 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
480480
onChangeSelection?: (e: NativeSyntheticEvent<OnChangeSelectionEvent>) => void;
481481
onKeyPress?: (e: NativeSyntheticEvent<OnKeyPressEvent>) => void;
482482
onSubmitEditing?: (e: NativeSyntheticEvent<OnSubmitEditing>) => void;
483+
/**
484+
* Web: each `images[].uri` is a `blob:` URL from `URL.createObjectURL`. If you keep
485+
* URIs around (or replace them after upload), call `URL.revokeObjectURL(uri)` when done
486+
* to avoid retaining blob memory. Native uses non-blob URIs; revoke does not apply.
487+
*/
483488
onPasteImages?: (e: NativeSyntheticEvent<OnPasteImagesEvent>) => void;
484489
contextMenuItems?: ContextMenuItem[];
485490
/**

src/web/EnrichedTextInput.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { StripBoldInStyledHeadingsPlugin } from './pmPlugins/StripBoldInStyledHe
6464
import { StrictMarksPlugin } from './pmPlugins/StrictMarksPlugin';
6565
import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/MergeAdjacentSameKindBlocksPlugin';
6666
import { StripMarksInCodeBlockPlugin } from './pmPlugins/StripMarksInCodeBlockPlugin';
67+
import { handleClipboardPasteImages } from './pasteImages';
6768
import {
6869
MentionPlugin,
6970
setMention,
@@ -73,7 +74,6 @@ import {
7374
import { StripMarksOnImagePlugin } from './pmPlugins/StripMarksOnImagePlugin';
7475
import { ShortcutPlugin } from './pmPlugins/ShortcutPlugin';
7576
import { returnKeyTypeToEnterKeyHint } from './returnKeyTypeToEnterKeyHint';
76-
7777
function runFocused(
7878
editor: Editor,
7979
apply: (chain: ChainedCommands) => ChainedCommands
@@ -105,6 +105,7 @@ export const EnrichedTextInput = ({
105105
onSubmitEditing,
106106
returnKeyType,
107107
submitBehavior,
108+
onPasteImages,
108109
onMentionDetected,
109110
onStartMention,
110111
onChangeMention,
@@ -124,6 +125,11 @@ export const EnrichedTextInput = ({
124125
htmlStyleRef.current = resolvedHtmlStyle;
125126
}, [resolvedHtmlStyle]);
126127

128+
const onPasteImagesRef = useRef(onPasteImages);
129+
useEffect(() => {
130+
onPasteImagesRef.current = onPasteImages;
131+
}, [onPasteImages]);
132+
127133
const mentionIndicatorsRef = useRef(mentionIndicators);
128134
useEffect(() => {
129135
mentionIndicatorsRef.current = mentionIndicators;
@@ -248,6 +254,12 @@ export const EnrichedTextInput = ({
248254
},
249255
editorProps: {
250256
handleKeyDown: (view, event) => handleKeyDown(view.state.doc, event),
257+
handlePaste: (_view, event) =>
258+
handleClipboardPasteImages(
259+
event,
260+
() => editorInstanceRef.current,
261+
() => onPasteImagesRef.current
262+
),
251263
attributes: {
252264
autoCapitalize,
253265
enterkeyhint: returnKeyTypeToEnterKeyHint(returnKeyType),

src/web/pasteImages.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Collect image `File`s from the clipboard and build `OnPasteImagesEvent` payloads with `blob:` URIs.
3+
*/
4+
5+
import type { Editor } from '@tiptap/react';
6+
import type { NativeSyntheticEvent } from 'react-native';
7+
8+
import type { OnPasteImagesEvent } from '../types';
9+
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
10+
import { isImageBlocked } from './formats/formatRules';
11+
import { readImageDimensionsFromBlob } from './pastedImageDimensions';
12+
13+
const isImageLikeClipboardFile = (file: File, reportedMime: string) =>
14+
reportedMime.startsWith('image/') || file.type.startsWith('image/');
15+
16+
/** Browsers often expose the same paste as two `File`s (items vs files) with different `name`. */
17+
function dedupeImageFiles(files: File[]): File[] {
18+
const seen = new Set<string>();
19+
const out: File[] = [];
20+
for (const file of files) {
21+
const key = `${file.size}\0${file.lastModified}\0${file.type}`;
22+
if (seen.has(key)) continue;
23+
seen.add(key);
24+
out.push(file);
25+
}
26+
return out;
27+
}
28+
29+
export function clipboardImageFiles(data: DataTransfer): File[] {
30+
const fromItems: File[] = [];
31+
for (const item of [...data.items]) {
32+
if (item == null || item.kind !== 'file') continue;
33+
const file = item.getAsFile();
34+
if (!file) continue;
35+
if (isImageLikeClipboardFile(file, item.type)) fromItems.push(file);
36+
}
37+
if (fromItems.length > 0) return dedupeImageFiles(fromItems);
38+
39+
const fromFiles: File[] = [];
40+
for (const file of [...data.files]) {
41+
if (isImageLikeClipboardFile(file, file.type)) fromFiles.push(file);
42+
}
43+
return dedupeImageFiles(fromFiles);
44+
}
45+
46+
export async function buildPasteImagesPayload(
47+
files: File[]
48+
): Promise<OnPasteImagesEvent['images']> {
49+
return Promise.all(
50+
files.map(async (file) => {
51+
const uri = URL.createObjectURL(file);
52+
const { width, height } = await readImageDimensionsFromBlob(file, uri);
53+
return {
54+
uri,
55+
type: file.type || 'image/png',
56+
width,
57+
height,
58+
};
59+
})
60+
);
61+
}
62+
63+
export function handleClipboardPasteImages(
64+
event: ClipboardEvent,
65+
getEditor: () => Editor | null,
66+
getOnPasteImages: () =>
67+
| ((e: NativeSyntheticEvent<OnPasteImagesEvent>) => void)
68+
| undefined
69+
): boolean {
70+
const clipboardData = event.clipboardData;
71+
if (!clipboardData) return false;
72+
73+
const files = clipboardImageFiles(clipboardData);
74+
if (files.length === 0) return false;
75+
76+
const ed = getEditor();
77+
if (!ed || isImageBlocked(ed)) return false;
78+
79+
const onPasteImages = getOnPasteImages();
80+
if (!onPasteImages) return false;
81+
82+
event.preventDefault();
83+
84+
(async () => {
85+
try {
86+
const images = await buildPasteImagesPayload(files);
87+
const editor = getEditor();
88+
if (!editor || isImageBlocked(editor)) return;
89+
onPasteImages(adaptWebToNativeEvent(event, { images }));
90+
} catch (err) {
91+
console.error(err);
92+
}
93+
})();
94+
95+
return true;
96+
}

src/web/pastedImageDimensions.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Best-effort intrinsic pixel size for pasted images (from Blob, with URL fallback).
3+
* Returns 0×0 when decode fails (caller still emits onPasteImages).
4+
*/
5+
6+
export async function readImageDimensionsFromBlob(
7+
blob: Blob,
8+
fallbackUrl: string
9+
): Promise<{ width: number; height: number }> {
10+
try {
11+
const bitmap = await createImageBitmap(blob);
12+
const { width, height } = bitmap;
13+
bitmap.close();
14+
return { width, height };
15+
} catch {
16+
return tryImageElementDimensions(fallbackUrl);
17+
}
18+
}
19+
20+
function tryImageElementDimensions(
21+
url: string
22+
): Promise<{ width: number; height: number }> {
23+
return new Promise((resolve) => {
24+
if (typeof Image === 'undefined') {
25+
resolve({ width: 0, height: 0 });
26+
return;
27+
}
28+
const img = new Image();
29+
img.onload = () => {
30+
const w = img.naturalWidth;
31+
const h = img.naturalHeight;
32+
resolve({
33+
width: Number.isFinite(w) ? w : 0,
34+
height: Number.isFinite(h) ? h : 0,
35+
});
36+
};
37+
img.onerror = () => resolve({ width: 0, height: 0 });
38+
img.src = url;
39+
});
40+
}

0 commit comments

Comments
 (0)