Add image paste and insert in the editor (#28)
All checks were successful
CD / Build and push images (push) Successful in 2m3s
CI / Lint, typecheck, test (push) Successful in 1m39s
CI / Auth e2e pack (push) Successful in 1m48s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s

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
This commit is contained in:
Claude Sonnet 5 2026-07-08 11:30:25 +02:00
parent 0fae699018
commit c8be3cd85e
10 changed files with 561 additions and 42 deletions

189
apps/web/e2e/image.spec.ts Normal file
View File

@ -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<ReturnType<typeof contextForUser>>,
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<void> {
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<void> {
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<void> {
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();
});

View File

@ -1,7 +1,7 @@
import { isAllowedLinkHref } from '@dorfteich/shared'; import { isAllowedLinkHref } from '@dorfteich/shared';
import type { Editor } from '@tiptap/core'; import type { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react'; import { useEditorState } from '@tiptap/react';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface ToolbarProps { 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<HTMLInputElement>(null);
return (
<>
<ToolbarButton label={label} onClick={() => inputRef.current?.click()}>
🖼
</ToolbarButton>
<input
ref={inputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => {
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/ /** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
* column controls stay visible but disabled outside a table, so the * column controls stay visible but disabled outside a table, so the
* toolbar's layout and tab order never shift while typing. */ * toolbar's layout and tab order never shift while typing. */
@ -244,12 +275,7 @@ export function Toolbar({ editor }: ToolbarProps): React.JSX.Element {
> >
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ImageInsertButton editor={editor} label={t('toolbar.image')} />
label={t('toolbar.image')}
onClick={() => editor.chain().focus().insertImagePlaceholder().run()}
>
🖼
</ToolbarButton>
</div> </div>
<div className="editor-toolbar__group"> <div className="editor-toolbar__group">

View File

@ -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<ReturnType> {
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<DecorationSet>('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<AttachmentView>(`/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);
});
}

View File

@ -1,29 +1,61 @@
import { Node } from '@tiptap/core'; import { Node } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react'; import type { NodeViewProps } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import { attributesFromSpec, nodeSpec } from '../spec-utils'; import { attributesFromSpec, nodeSpec } from '../spec-utils';
declare module '@tiptap/core' { /** Width presets (issue #28 scope: "simple width presets", no free-form
interface Commands<ReturnType> { * resizing/cropping in v1). `full` stores `null` (renders at natural size,
documentImage: { * constrained by the content column via CSS `max-width`). */
insertImagePlaceholder: (alt?: string) => ReturnType; 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 function ImageView({ node, updateAttributes, editor, selected }: NodeViewProps): React.JSX.Element {
* an image always yields a placeholder box, since there is no file to const { t } = useTranslation('editor');
* resolve `fileId` against yet. */ const fileId = node.attrs.fileId as string;
function ImageView({ node }: NodeViewProps): React.JSX.Element { const alt = (node.attrs.alt as string) ?? '';
const alt = typeof node.attrs.alt === 'string' ? node.attrs.alt : ''; const width = node.attrs.width as number | null;
const showControls = editor.isEditable && selected;
return ( return (
<NodeViewWrapper <NodeViewWrapper as="span" className="editor-image" data-file-id={fileId}>
as="span" <img src={`/api/v1/media/${fileId}`} alt={alt} width={width ?? undefined} />
className="editor-image-placeholder" {showControls && (
data-file-id={node.attrs.fileId} <span className="editor-image__controls" contentEditable={false}>
<label className="editor-image__alt">
{t('image.altLabel')}
<input
type="text"
value={alt}
onChange={(event) => updateAttributes({ alt: event.target.value })}
/>
</label>
<span className="editor-image__widths" role="group" aria-label={t('image.widthLabel')}>
{(Object.keys(WIDTH_PRESETS) as WidthPreset[]).map((preset) => (
<button
key={preset}
type="button"
className={
presetFor(width) === preset
? 'toolbar-button toolbar-button--active'
: 'toolbar-button'
}
onMouseDown={(event) => event.preventDefault()}
onClick={() => updateAttributes({ width: WIDTH_PRESETS[preset] })}
> >
{alt || '\u{1F5BC}'} {t(`image.width.${preset}`)}
</button>
))}
</span>
</span>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
} }
@ -42,17 +74,4 @@ export const Image = Node.create({
addNodeView() { addNodeView() {
return ReactNodeViewRenderer(ImageView); return ReactNodeViewRenderer(ImageView);
}, },
addCommands() {
return {
insertImagePlaceholder:
(alt = '') =>
({ chain }) =>
chain()
.insertContent({
type: this.name,
attrs: { fileId: crypto.randomUUID(), alt },
})
.run(),
};
},
}); });

View File

@ -49,6 +49,28 @@ export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
requestJson<T>('PATCH', path, body); requestJson<T>('PATCH', path, body);
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path); export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('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<T>(path: string, file: File): Promise<T> {
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<T>;
}
export function fetchHealth(): Promise<HealthResponse> { export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz'); return apiGet<HealthResponse>('/healthz');
} }

View File

@ -9,6 +9,7 @@ import * as Y from 'yjs';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { documentExtensions } from '../editor/document-extensions'; import { documentExtensions } from '../editor/document-extensions';
import { ImageUpload } from '../editor/image-upload';
import { Toolbar } from '../editor/Toolbar'; import { Toolbar } from '../editor/Toolbar';
import { usePageStateAutosave } from '../editor/use-page-autosave'; import { usePageStateAutosave } from '../editor/use-page-autosave';
import { decodeBase64 } from '../editor/yjs-base64'; 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 // editor never gets built without its 'doc'/'paragraph'/'text' nodes
// while `ydoc` is still being created (see the effect above). // while `ydoc` is still being created (see the effect above).
extensions: ydoc extensions: ydoc
? [...documentExtensions, Collaboration.configure({ document: ydoc, field: 'default' })] ? [
...documentExtensions,
ImageUpload.configure({ pondId: page.pondId }),
Collaboration.configure({ document: ydoc, field: 'default' }),
]
: documentExtensions, : documentExtensions,
editable: mode === 'edit', editable: mode === 'edit',
immediatelyRender: false, immediatelyRender: false,

View File

@ -592,7 +592,57 @@ button {
margin: var(--space-6) 0; 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; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -605,3 +655,8 @@ button {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
.editor-image-upload--error {
border-color: var(--color-danger);
color: var(--color-danger);
}

View File

@ -30,7 +30,7 @@
"blockquote": "Zitat", "blockquote": "Zitat",
"codeBlock": "Codeblock", "codeBlock": "Codeblock",
"horizontalRule": "Trennlinie", "horizontalRule": "Trennlinie",
"image": "Bild-Platzhalter einfügen", "image": "Bild einfügen",
"undo": "Rückgängig", "undo": "Rückgängig",
"redo": "Wiederholen", "redo": "Wiederholen",
"link": { "link": {
@ -51,5 +51,16 @@
"toggleHeaderRow": "Kopfzeile umschalten", "toggleHeaderRow": "Kopfzeile umschalten",
"deleteTable": "Tabelle löschen" "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"
}
} }
} }

View File

@ -30,7 +30,7 @@
"blockquote": "Quote", "blockquote": "Quote",
"codeBlock": "Code block", "codeBlock": "Code block",
"horizontalRule": "Horizontal rule", "horizontalRule": "Horizontal rule",
"image": "Insert image placeholder", "image": "Insert image",
"undo": "Undo", "undo": "Undo",
"redo": "Redo", "redo": "Redo",
"link": { "link": {
@ -51,5 +51,16 @@
"toggleHeaderRow": "Toggle header row", "toggleHeaderRow": "Toggle header row",
"deleteTable": "Delete table" "deleteTable": "Delete table"
} }
},
"image": {
"uploading": "Uploading image …",
"uploadFailed": "Image upload failed",
"altLabel": "Alt text",
"widthLabel": "Image width",
"width": {
"small": "Small",
"medium": "Medium",
"full": "Full"
}
} }
} }

View File

@ -113,7 +113,25 @@ export const editorSchema = new Schema({
alt: { default: '', validate: 'string' }, alt: { default: '', validate: 'string' },
width: { default: null }, 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) => [ toDOM: (node) => [
'img', 'img',
{ {