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
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:
parent
0fae699018
commit
c8be3cd85e
189
apps/web/e2e/image.spec.ts
Normal file
189
apps/web/e2e/image.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -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">
|
||||||
|
|||||||
163
apps/web/src/editor/image-upload.ts
Normal file
163
apps/web/src/editor/image-upload.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user