From c8be3cd85e496a6c251d9878504951fe523ffcfb Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Wed, 8 Jul 2026 11:30:25 +0200 Subject: [PATCH] Add image paste and insert in the editor (#28) Paste and drag-and-drop of image files upload via the #27 API and insert a real image node only once the upload succeeds; the in-flight state is a ProseMirror decoration, not a document node, so a failed upload cannot leave anything broken behind (it shows a transient inline error instead). The toolbar's image button opens a native file picker into the same upload path. Selecting an image reveals inline alt-text and width-preset (small/medium/full) controls. Also fixes the image node's parseDOM, which had no getAttrs and would drop the required fileId attribute on internal copy/paste. Closes #28 --- apps/web/e2e/image.spec.ts | 189 ++++++++++++++++++++ apps/web/src/editor/Toolbar.tsx | 40 ++++- apps/web/src/editor/image-upload.ts | 163 +++++++++++++++++ apps/web/src/editor/nodes/image.tsx | 79 ++++---- apps/web/src/lib/api.ts | 22 +++ apps/web/src/pages/PageEditorPage.tsx | 7 +- apps/web/src/styles/base.css | 57 +++++- packages/shared/i18n/de/editor.json | 13 +- packages/shared/i18n/en/editor.json | 13 +- packages/shared/src/editor-schema/schema.ts | 20 ++- 10 files changed, 561 insertions(+), 42 deletions(-) create mode 100644 apps/web/e2e/image.spec.ts create mode 100644 apps/web/src/editor/image-upload.ts diff --git a/apps/web/e2e/image.spec.ts b/apps/web/e2e/image.spec.ts new file mode 100644 index 0000000..5b8d66d --- /dev/null +++ b/apps/web/e2e/image.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Image paste/drop/insert pack (issue #28). Runs against the local dev + * stack (api + web); no Mailpit needed. A tiny 1x1 PNG stands in for a + * "screenshot" — only the magic bytes matter to the upload endpoint (#27). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + +async function createPage( + context: Awaited>, + title: string, +): Promise<{ pondSlug: string; pageSlug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title }, + }); + const page = await created.json(); + return { pondSlug: pond.slug, pageSlug: page.slug }; +} + +async function enterEditMode(page: Page): Promise { + await page.getByRole('button', { name: /edit|bearbeiten/i }).click(); + await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); +} + +/** Dispatches a real `ClipboardEvent` carrying an image `File`, built and + * fired entirely inside the page — Playwright's `dispatchEvent` helper only + * special-cases `dataTransfer` (for drag events), not `clipboardData`, so a + * paste-with-files has to be constructed with the native constructor here + * instead of via `evaluateHandle` + `locator.dispatchEvent`. */ +async function pasteImage(page: Page, filename: string): Promise { + await page.evaluate( + async ({ base64, name }) => { + const el = document.querySelector('.ProseMirror'); + const response = await fetch(`data:image/png;base64,${base64}`); + const blob = await response.blob(); + const file = new File([blob], name, { type: 'image/png' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + el!.dispatchEvent( + new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }), + ); + }, + { base64: PNG_BASE64, name: filename }, + ); +} + +/** Drop needs real viewport coordinates: ProseMirror resolves `posAtCoords` + * from `event.clientX/Y` before ever calling `handleDrop`, and bails out + * (never reaching our plugin) if they don't land inside the content. */ +async function dropImage(page: Page, filename: string): Promise { + const box = await page.locator('.ProseMirror').boundingBox(); + if (!box) throw new Error('.ProseMirror has no bounding box'); + const dataTransfer = await page.evaluateHandle( + async ({ base64, name }) => { + const response = await fetch(`data:image/png;base64,${base64}`); + const blob = await response.blob(); + const file = new File([blob], name, { type: 'image/png' }); + const dt = new DataTransfer(); + dt.items.add(file); + return dt; + }, + { base64: PNG_BASE64, name: filename }, + ); + await page.locator('.ProseMirror').dispatchEvent('drop', { + dataTransfer, + clientX: box.x + box.width / 2, + clientY: box.y + box.height / 2, + }); +} + +test('pasting an image inserts it at the cursor after upload', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Paste ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + await pasteImage(page, 'screenshot.png'); + + await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ + timeout: 10000, + }); + await expect(page.locator('.editor-image-upload')).toHaveCount(0); + + await context.close(); +}); + +test('toolbar file picker inserts an image', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Picker ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + + await page.getByRole('button', { name: /insert image|bild einfügen/i }).click(); + await page.locator('input[type="file"]').setInputFiles({ + name: 'picked.png', + mimeType: 'image/png', + buffer: Buffer.from(PNG_BASE64, 'base64'), + }); + + await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ + timeout: 10000, + }); + + await context.close(); +}); + +test('drag-and-drop of an image file inserts it', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Drop ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + await dropImage(page, 'dropped.png'); + + await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toBeVisible({ + timeout: 10000, + }); + + await context.close(); +}); + +test('alt text is editable and persists in the document', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Alt ${Date.now()}`); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + await pasteImage(page, 'alt-test.png'); + const image = page.locator('.ProseMirror img[src^="/api/v1/media/"]'); + await expect(image).toBeVisible({ timeout: 10000 }); + + await image.click(); + const altInput = page.locator('.editor-image__alt input'); + await expect(altInput).toBeVisible(); + await altInput.fill('A lovely test screenshot'); + await expect(image).toHaveAttribute('alt', 'A lovely test screenshot'); + + await expect(page.getByRole('status')).toHaveText(/saved|gespeichert/i, { timeout: 10000 }); + await page.reload(); + await enterEditMode(page); + await expect(page.locator('.ProseMirror img[src^="/api/v1/media/"]')).toHaveAttribute( + 'alt', + 'A lovely test screenshot', + ); + + await context.close(); +}); + +test('a failed upload never leaves a broken node in the document', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const { pondSlug, pageSlug } = await createPage(context, `E2E Fail ${Date.now()}`); + await context.route('**/api/v1/ponds/*/files', (route) => route.abort('failed')); + const page = await context.newPage(); + + await page.goto(`/p/${pondSlug}/${pageSlug}`); + await enterEditMode(page); + await page.locator('.ProseMirror').click(); + + await pasteImage(page, 'will-fail.png'); + + await expect(page.locator('.editor-image-upload--error')).toBeVisible(); + await expect(page.locator('.editor-image-upload')).toHaveCount(0, { timeout: 10000 }); + await expect(page.locator('.ProseMirror img')).toHaveCount(0); + + await context.close(); +}); diff --git a/apps/web/src/editor/Toolbar.tsx b/apps/web/src/editor/Toolbar.tsx index b3760e6..0eafb65 100644 --- a/apps/web/src/editor/Toolbar.tsx +++ b/apps/web/src/editor/Toolbar.tsx @@ -1,7 +1,7 @@ import { isAllowedLinkHref } from '@dorfteich/shared'; import type { Editor } from '@tiptap/core'; import { useEditorState } from '@tiptap/react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; interface ToolbarProps { @@ -103,6 +103,37 @@ function LinkControl({ editor, label }: { editor: Editor; label: LinkLabels }): ); } +/** File-picker path into the same async upload as paste/drop (issue #28) — + * a hidden native file input triggered by a normal toolbar button, since + * that is the only way to open the OS file dialog from a click handler. */ +function ImageInsertButton({ + editor, + label, +}: { + editor: Editor; + label: string; +}): React.JSX.Element { + const inputRef = useRef(null); + return ( + <> + inputRef.current?.click()}> + 🖼 + + { + const file = event.target.files?.[0]; + if (file) editor.chain().focus().insertImageFile(file).run(); + event.target.value = ''; + }} + /> + + ); +} + /** Keyboard-accessible toolbar for the page editor (issue #25). Table row/ * column controls stay visible but disabled outside a table, so the * toolbar's layout and tab order never shift while typing. */ @@ -244,12 +275,7 @@ export function Toolbar({ editor }: ToolbarProps): React.JSX.Element { > ― - editor.chain().focus().insertImagePlaceholder().run()} - > - 🖼 - +
diff --git a/apps/web/src/editor/image-upload.ts b/apps/web/src/editor/image-upload.ts new file mode 100644 index 0000000..9594fd8 --- /dev/null +++ b/apps/web/src/editor/image-upload.ts @@ -0,0 +1,163 @@ +import type { AttachmentView } from '@dorfteich/shared'; +import { Extension } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { EditorView } from '@tiptap/pm/view'; + +import i18n from '../i18n'; +import { apiUploadFile } from '../lib/api'; + +declare module '@tiptap/core' { + interface Commands { + imageUpload: { + /** Uploads `file` and inserts an image node at the current selection + * once it succeeds (toolbar "insert image" dialog, issue #28). */ + insertImageFile: (file: File) => ReturnType; + }; + } +} + +type UploadMeta = + | { type: 'add'; id: string; pos: number } + | { type: 'error'; id: string } + | { type: 'remove'; id: string }; + +const uploadKey = new PluginKey('imageUpload'); + +function widgetFor(id: string, status: 'uploading' | 'error'): () => HTMLElement { + return () => { + const span = document.createElement('span'); + span.className = + status === 'error' ? 'editor-image-upload editor-image-upload--error' : 'editor-image-upload'; + span.dataset.uploadId = id; + span.textContent = i18n.t(status === 'error' ? 'image.uploadFailed' : 'image.uploading', { + ns: 'editor', + }); + return span; + }; +} + +/** Current position of the in-flight decoration for `id`, mapped through + * every local edit made while the upload request was in flight. */ +function decorationAt(state: EditorState, id: string): number | null { + const set = uploadKey.getState(state); + const found = set?.find(0, state.doc.content.size, (spec) => spec.id === id); + return found?.[0]?.from ?? null; +} + +/** + * Async image upload with an in-flight placeholder (issue #28). Uploaded + * bytes only ever become a real `image` node in the document once the + * upload has actually succeeded (`POST /ponds/:id/files`, issue #27) — the + * in-flight state lives purely as a ProseMirror decoration (a widget, not a + * document node), so a failed upload cannot leave a broken node behind: on + * error the decoration is simply removed after a brief visible error state. + */ +export const ImageUpload = Extension.create<{ pondId: string }>({ + name: 'imageUpload', + + addOptions() { + return { pondId: '' }; + }, + + addCommands() { + return { + insertImageFile: + (file: File) => + ({ view }) => { + if (!this.options.pondId) return false; + startUpload(view, this.options.pondId, file, view.state.selection.from); + return true; + }, + }; + }, + + addProseMirrorPlugins() { + const options = this.options; + return [ + new Plugin({ + key: uploadKey, + state: { + init: () => DecorationSet.empty, + apply(tr, set) { + const mapped = set.map(tr.mapping, tr.doc); + const meta = tr.getMeta(uploadKey) as UploadMeta | undefined; + if (!meta) return mapped; + if (meta.type === 'add') { + return mapped.add(tr.doc, [ + Decoration.widget(meta.pos, widgetFor(meta.id, 'uploading'), { + id: meta.id, + side: -1, + }), + ]); + } + const existing = mapped.find(0, tr.doc.content.size, (spec) => spec.id === meta.id); + const without = mapped.remove(existing); + if (meta.type === 'error') { + const at = existing[0]?.from ?? 0; + return without.add(tr.doc, [ + Decoration.widget(at, widgetFor(meta.id, 'error'), { id: meta.id, side: -1 }), + ]); + } + return without; + }, + }, + props: { + decorations(state) { + return uploadKey.getState(state); + }, + handlePaste(view, event) { + const files = Array.from(event.clipboardData?.files ?? []).filter((file) => + file.type.startsWith('image/'), + ); + if (files.length === 0) return false; + const pos = view.state.selection.from; + files.forEach((file) => startUpload(view, options.pondId, file, pos)); + return true; + }, + handleDrop(view, event, _slice, moved) { + if (moved) return false; // internal drag (e.g. reordering), not a file drop + const files = Array.from(event.dataTransfer?.files ?? []).filter((file) => + file.type.startsWith('image/'), + ); + if (files.length === 0) return false; + event.preventDefault(); + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + const pos = coords?.pos ?? view.state.selection.from; + files.forEach((file) => startUpload(view, options.pondId, file, pos)); + return true; + }, + }, + }), + ]; + }, +}); + +const ERROR_VISIBLE_MS = 4000; + +function startUpload(view: EditorView, pondId: string, file: File, pos: number): void { + const id = crypto.randomUUID(); + view.dispatch(view.state.tr.setMeta(uploadKey, { type: 'add', id, pos })); + + apiUploadFile(`/ponds/${pondId}/files`, file) + .then((attachment) => { + if (view.isDestroyed) return; + const at = decorationAt(view.state, id) ?? pos; + const imageType = view.state.schema.nodes.image; + if (!imageType) return; + view.dispatch( + view.state.tr + .setMeta(uploadKey, { type: 'remove', id }) + .insert(at, imageType.create({ fileId: attachment.id, alt: '' })), + ); + }) + .catch(() => { + if (view.isDestroyed) return; + view.dispatch(view.state.tr.setMeta(uploadKey, { type: 'error', id })); + window.setTimeout(() => { + if (view.isDestroyed) return; + view.dispatch(view.state.tr.setMeta(uploadKey, { type: 'remove', id })); + }, ERROR_VISIBLE_MS); + }); +} diff --git a/apps/web/src/editor/nodes/image.tsx b/apps/web/src/editor/nodes/image.tsx index bbbdb55..e1f653d 100644 --- a/apps/web/src/editor/nodes/image.tsx +++ b/apps/web/src/editor/nodes/image.tsx @@ -1,29 +1,61 @@ import { Node } from '@tiptap/core'; import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; import { attributesFromSpec, nodeSpec } from '../spec-utils'; -declare module '@tiptap/core' { - interface Commands { - documentImage: { - insertImagePlaceholder: (alt?: string) => ReturnType; - }; - } +/** Width presets (issue #28 scope: "simple width presets", no free-form + * resizing/cropping in v1). `full` stores `null` (renders at natural size, + * constrained by the content column via CSS `max-width`). */ +const WIDTH_PRESETS = { small: 240, medium: 480, full: null } as const; +type WidthPreset = keyof typeof WIDTH_PRESETS; + +function presetFor(width: number | null): WidthPreset { + if (width === WIDTH_PRESETS.small) return 'small'; + if (width === WIDTH_PRESETS.medium) return 'medium'; + return 'full'; } -/** Real uploads and clipboard paste arrive with #27/#28; for now inserting - * an image always yields a placeholder box, since there is no file to - * resolve `fileId` against yet. */ -function ImageView({ node }: NodeViewProps): React.JSX.Element { - const alt = typeof node.attrs.alt === 'string' ? node.attrs.alt : ''; +function ImageView({ node, updateAttributes, editor, selected }: NodeViewProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const fileId = node.attrs.fileId as string; + const alt = (node.attrs.alt as string) ?? ''; + const width = node.attrs.width as number | null; + const showControls = editor.isEditable && selected; + return ( - - {alt || '\u{1F5BC}'} + + {alt} + {showControls && ( + + + + {(Object.keys(WIDTH_PRESETS) as WidthPreset[]).map((preset) => ( + + ))} + + + )} ); } @@ -42,17 +74,4 @@ export const Image = Node.create({ addNodeView() { return ReactNodeViewRenderer(ImageView); }, - addCommands() { - return { - insertImagePlaceholder: - (alt = '') => - ({ chain }) => - chain() - .insertContent({ - type: this.name, - attrs: { fileId: crypto.randomUUID(), alt }, - }) - .run(), - }; - }, }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b3b2bfa..083e291 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -49,6 +49,28 @@ export const apiPatch = (path: string, body?: unknown): Promise => requestJson('PATCH', path, body); export const apiDelete = (path: string): Promise => requestJson('DELETE', path); +/** Multipart upload (issue #27/#28) — a FormData body, unlike every other + * endpoint here, so it can't share `requestJson`'s JSON serialization; the + * error handling is kept identical. */ +export async function apiUploadFile(path: string, file: File): Promise { + const form = new FormData(); + form.append('file', file); + let response: Response; + try { + response = await fetch(`/api/v1${path}`, { method: 'POST', body: form }); + } catch { + throw new ApiError(0, { code: 'network', message: 'network error' }); + } + if (!response.ok) { + const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null; + throw new ApiError( + response.status, + parsed ?? { code: `http_${response.status}`, message: response.statusText }, + ); + } + return response.json() as Promise; +} + export function fetchHealth(): Promise { return apiGet('/healthz'); } diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 29b849b..e73f1a5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -9,6 +9,7 @@ import * as Y from 'yjs'; import { FormError } from '../components/forms'; import { documentExtensions } from '../editor/document-extensions'; +import { ImageUpload } from '../editor/image-upload'; import { Toolbar } from '../editor/Toolbar'; import { usePageStateAutosave } from '../editor/use-page-autosave'; import { decodeBase64 } from '../editor/yjs-base64'; @@ -43,7 +44,11 @@ function PageEditor({ page, mode }: { page: PageStateView; mode: Mode }): React. // editor never gets built without its 'doc'/'paragraph'/'text' nodes // while `ydoc` is still being created (see the effect above). extensions: ydoc - ? [...documentExtensions, Collaboration.configure({ document: ydoc, field: 'default' })] + ? [ + ...documentExtensions, + ImageUpload.configure({ pondId: page.pondId }), + Collaboration.configure({ document: ydoc, field: 'default' }), + ] : documentExtensions, editable: mode === 'edit', immediatelyRender: false, diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index ee7d3a0..0842888 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -592,7 +592,57 @@ button { margin: var(--space-6) 0; } -.editor-image-placeholder { +.editor-image { + position: relative; + display: inline-block; + max-width: 100%; + vertical-align: bottom; +} + +.editor-image img { + max-width: 100%; + border-radius: var(--radius); +} + +.editor-image__controls { + display: flex; + flex-direction: column; + gap: var(--space-2); + position: absolute; + top: 100%; + left: 0; + z-index: 1; + margin-top: var(--space-1); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-bg); + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); + white-space: nowrap; +} + +.editor-image__alt { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.editor-image__alt input { + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius); + font: inherit; + font-size: 0.9rem; +} + +.editor-image__widths { + display: flex; + gap: var(--space-1); +} + +.editor-image-upload { display: inline-flex; align-items: center; justify-content: center; @@ -605,3 +655,8 @@ button { color: var(--color-text-muted); font-size: 0.85rem; } + +.editor-image-upload--error { + border-color: var(--color-danger); + color: var(--color-danger); +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 04846b3..e7e1d81 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -30,7 +30,7 @@ "blockquote": "Zitat", "codeBlock": "Codeblock", "horizontalRule": "Trennlinie", - "image": "Bild-Platzhalter einfügen", + "image": "Bild einfügen", "undo": "Rückgängig", "redo": "Wiederholen", "link": { @@ -51,5 +51,16 @@ "toggleHeaderRow": "Kopfzeile umschalten", "deleteTable": "Tabelle löschen" } + }, + "image": { + "uploading": "Bild wird hochgeladen …", + "uploadFailed": "Bild-Upload fehlgeschlagen", + "altLabel": "Alt-Text", + "widthLabel": "Bildbreite", + "width": { + "small": "Klein", + "medium": "Mittel", + "full": "Voll" + } } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 3cc38ab..741f78b 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -30,7 +30,7 @@ "blockquote": "Quote", "codeBlock": "Code block", "horizontalRule": "Horizontal rule", - "image": "Insert image placeholder", + "image": "Insert image", "undo": "Undo", "redo": "Redo", "link": { @@ -51,5 +51,16 @@ "toggleHeaderRow": "Toggle header row", "deleteTable": "Delete table" } + }, + "image": { + "uploading": "Uploading image …", + "uploadFailed": "Image upload failed", + "altLabel": "Alt text", + "widthLabel": "Image width", + "width": { + "small": "Small", + "medium": "Medium", + "full": "Full" + } } } diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 6973fc4..e91bbc9 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -113,7 +113,25 @@ export const editorSchema = new Schema({ alt: { default: '', validate: 'string' }, width: { default: null }, }, - parseDOM: [{ tag: 'img[data-file-id]' }], + parseDOM: [ + { + tag: 'img[data-file-id]', + // No implicit getAttrs: `fileId` has no default (required), so + // without this ProseMirror's default attr matching (which only + // applies static `rule.attrs`) would create a node missing it — + // this is what makes internal copy/paste of an image node work. + // (No explicit param type: this package has no DOM lib, and the + // parameter type is inferred from the surrounding NodeSpec anyway.) + getAttrs: (dom) => { + const width = dom.getAttribute('width'); + return { + fileId: dom.getAttribute('data-file-id'), + alt: dom.getAttribute('alt') ?? '', + width: width ? Number(width) : null, + }; + }, + }, + ], toDOM: (node) => [ 'img', {