dorfteich/apps/web/src/editor/image-upload.ts
Claude Sonnet 5 c8be3cd85e
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
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
2026-07-08 11:30:25 +02:00

164 lines
5.8 KiB
TypeScript

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);
});
}