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 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<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/
|
||||
* 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 {
|
||||
>
|
||||
―
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t('toolbar.image')}
|
||||
onClick={() => editor.chain().focus().insertImagePlaceholder().run()}
|
||||
>
|
||||
🖼
|
||||
</ToolbarButton>
|
||||
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
|
||||
</div>
|
||||
|
||||
<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 { 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<ReturnType> {
|
||||
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 (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
className="editor-image-placeholder"
|
||||
data-file-id={node.attrs.fileId}
|
||||
<NodeViewWrapper as="span" className="editor-image" data-file-id={fileId}>
|
||||
<img src={`/api/v1/media/${fileId}`} alt={alt} width={width ?? undefined} />
|
||||
{showControls && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@ -49,6 +49,28 @@ export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
|
||||
requestJson<T>('PATCH', path, body);
|
||||
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> {
|
||||
return apiGet<HealthResponse>('/healthz');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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',
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user