Skip to content
Open
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
12 changes: 12 additions & 0 deletions apps/example-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type BlurEvent,
type EnrichedInputStyle,
type OnLinkDetected,
type OnPasteImagesEvent,
type OnChangeMentionEvent,
type OnMentionDetected,
} from 'react-native-enriched';
Expand Down Expand Up @@ -210,6 +211,16 @@ function App() {
setCurrentLink(e);
};

const handlePasteImages = (e: NativeSyntheticEvent<OnPasteImagesEvent>) => {
const DEFAULT_W = 80;
const DEFAULT_H = 80;
for (const image of e.nativeEvent.images) {
const w = image.width > 0 ? image.width : DEFAULT_W;
const h = image.height > 0 ? image.height : DEFAULT_H;
ref.current?.setImage(image.uri, w, h);
}
};

return (
<div className="container">
<h1 className="app-title">Enriched Text Input</h1>
Expand Down Expand Up @@ -237,6 +248,7 @@ function App() {
onChangeHtml={handleOnChangeHtml}
onChangeState={handleChangeState}
onLinkDetected={handleOnLinkDetected}
onPasteImages={handlePasteImages}
onStartMention={handleStartMention}
onChangeMention={handleChangeMention}
onEndMention={handleEndMention}
Expand Down
1 change: 1 addition & 0 deletions docs/INPUT_API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ export interface OnKeyPressEvent {
Callback invoked when the user pastes one or more images or GIFs into the input.

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

```ts
export interface OnPasteImagesEvent {
Expand Down
3 changes: 1 addition & 2 deletions docs/WEB.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Web support is still experimental. APIs and behavior can change in future releas
- Headings (h1-h6)
- Blockquote, code block
- Ordered lists, unordered lists, checkbox lists
- Images(via `setImage` ref method)
- Images (via `setImage` ref method and optional `onPasteImages` when pasting image data)
- Manual links (via `setLink` ref method)
- Mentions
- `getHTML`, `setValue`, selection mapping
Expand All @@ -17,7 +17,6 @@ Web support is still experimental. APIs and behavior can change in future releas

## Unsupported

- **Pasting images**: `onPasteImages` is never called.
- **Automatic link detection**: `linkRegex` is ignored. Links only work when set explicitly via the `setLink` ref method.
- **Submit and keyboard props**: `onSubmitEditing`, `returnKeyType`, `returnKeyLabel`, and `submitBehavior` have no effect.
- **Context menu**: `contextMenuItems` is ignored.
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@ export interface EnrichedTextInputProps extends Omit<ViewProps, 'children'> {
onChangeSelection?: (e: NativeSyntheticEvent<OnChangeSelectionEvent>) => void;
onKeyPress?: (e: NativeSyntheticEvent<OnKeyPressEvent>) => void;
onSubmitEditing?: (e: NativeSyntheticEvent<OnSubmitEditing>) => void;
/**
* Web: each `images[].uri` is a `blob:` URL from `URL.createObjectURL`. If you keep
* URIs around (or replace them after upload), call `URL.revokeObjectURL(uri)` when done
* to avoid retaining blob memory. Native uses non-blob URIs; revoke does not apply.
*/
onPasteImages?: (e: NativeSyntheticEvent<OnPasteImagesEvent>) => void;
contextMenuItems?: ContextMenuItem[];
/**
Expand Down
19 changes: 18 additions & 1 deletion src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ import { createStripBoldInStyledHeadingsPlugin } from './pmPlugins/stripBoldInSt
import { StrictMarksPlugin } from './pmPlugins/strictMarksPlugin';
import { MergeAdjacentSameKindBlocksPlugin } from './pmPlugins/mergeAdjacentSameKindBlocksPlugin';
import { StripMarksInCodeBlockPlugin } from './pmPlugins/stripMarksInCodeBlockPlugin';
import { handleClipboardPasteImages } from './pasteImages';
import {
createMentionPlugin,
setMention,
startMention,
subscribeMentionEvents,
} from './pmPlugins/mentionPlugin';

import { StripMarksOnImagePlugin } from './pmPlugins/stripMarksOnImagePlugin';
function runFocused(
editor: Editor,
Expand Down Expand Up @@ -98,6 +98,7 @@ export const EnrichedTextInput = ({
onChangeHtml,
onChangeState,
onLinkDetected,
onPasteImages,
onMentionDetected,
onStartMention,
onChangeMention,
Expand All @@ -122,6 +123,12 @@ export const EnrichedTextInput = ({
[]
);

const editorRef = useRef<Editor | null>(null);
const onPasteImagesRef = useRef(onPasteImages);
useEffect(() => {
onPasteImagesRef.current = onPasteImages;
}, [onPasteImages]);

const mentionIndicatorsRef = useRef(mentionIndicators);
useEffect(() => {
mentionIndicatorsRef.current = mentionIndicators;
Expand Down Expand Up @@ -215,6 +222,12 @@ export const EnrichedTextInput = ({
onKeyPress?.(adaptWebToNativeEvent(event, { key: event.key }));
return false;
},
handlePaste: (_view, event) =>
handleClipboardPasteImages(
event,
() => editorRef.current,
() => onPasteImagesRef.current
),
attributes: {
autoCapitalize,
},
Expand All @@ -223,6 +236,10 @@ export const EnrichedTextInput = ({
[tiptapContent, extensions]
);

useEffect(() => {
editorRef.current = editor;
}, [editor]);

useEffect(() => {
editor?.commands.normalizeBoldInStyledHeadings();
}, [editor, resolvedHtmlStyle]);
Expand Down
96 changes: 96 additions & 0 deletions src/web/pasteImages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Collect image `File`s from the clipboard and build `OnPasteImagesEvent` payloads with `blob:` URIs.
*/

import type { Editor } from '@tiptap/react';
import type { NativeSyntheticEvent } from 'react-native';

import type { OnPasteImagesEvent } from '../types';
import { adaptWebToNativeEvent } from './adaptWebToNativeEvent';
import { isImageBlocked } from './formats/formatRules';
import { readImageDimensionsFromBlob } from './pastedImageDimensions';

const isImageLikeClipboardFile = (file: File, reportedMime: string) =>
reportedMime.startsWith('image/') || file.type.startsWith('image/');

/** Browsers often expose the same paste as two `File`s (items vs files) with different `name`. */
function dedupeImageFiles(files: File[]): File[] {
const seen = new Set<string>();
const out: File[] = [];
for (const file of files) {
const key = `${file.size}\0${file.lastModified}\0${file.type}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(file);
}
return out;
}

export function clipboardImageFiles(data: DataTransfer): File[] {
const fromItems: File[] = [];
for (const item of [...data.items]) {
if (item == null || item.kind !== 'file') continue;
const file = item.getAsFile();
if (!file) continue;
if (isImageLikeClipboardFile(file, item.type)) fromItems.push(file);
}
if (fromItems.length > 0) return dedupeImageFiles(fromItems);

const fromFiles: File[] = [];
for (const file of [...data.files]) {
if (isImageLikeClipboardFile(file, file.type)) fromFiles.push(file);
}
return dedupeImageFiles(fromFiles);
}

export async function buildPasteImagesPayload(
files: File[]
): Promise<OnPasteImagesEvent['images']> {
return Promise.all(
files.map(async (file) => {
const uri = URL.createObjectURL(file);
const { width, height } = await readImageDimensionsFromBlob(file, uri);
return {
uri,
type: file.type || 'image/png',
width,
height,
};
})
);
}

export function handleClipboardPasteImages(
event: ClipboardEvent,
getEditor: () => Editor | null,
getOnPasteImages: () =>
| ((e: NativeSyntheticEvent<OnPasteImagesEvent>) => void)
| undefined
): boolean {
const clipboardData = event.clipboardData;
if (!clipboardData) return false;

const files = clipboardImageFiles(clipboardData);
if (files.length === 0) return false;

const ed = getEditor();
if (!ed || isImageBlocked(ed)) return false;

const onPasteImages = getOnPasteImages();
if (!onPasteImages) return false;

event.preventDefault();

(async () => {
try {
const images = await buildPasteImagesPayload(files);
const editor = getEditor();
if (!editor || isImageBlocked(editor)) return;
onPasteImages(adaptWebToNativeEvent(event, { images }));
} catch (err) {
console.error(err);
}
})();

return true;
}
40 changes: 40 additions & 0 deletions src/web/pastedImageDimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Best-effort intrinsic pixel size for pasted images (from Blob, with URL fallback).
* Returns 0×0 when decode fails (caller still emits onPasteImages).
*/

export async function readImageDimensionsFromBlob(
blob: Blob,
fallbackUrl: string
): Promise<{ width: number; height: number }> {
try {
const bitmap = await createImageBitmap(blob);
const { width, height } = bitmap;
bitmap.close();
return { width, height };
} catch {
return tryImageElementDimensions(fallbackUrl);
}
}

function tryImageElementDimensions(
url: string
): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
if (typeof Image === 'undefined') {
resolve({ width: 0, height: 0 });
return;
}
const img = new Image();
img.onload = () => {
const w = img.naturalWidth;
const h = img.naturalHeight;
resolve({
width: Number.isFinite(w) ? w : 0,
height: Number.isFinite(h) ? h : 0,
});
};
img.onerror = () => resolve({ width: 0, height: 0 });
img.src = url;
});
}
Loading