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
164 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
}
|