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