Skip to content
File

Blob: src/client/components/editor/lib/media-actions.ts

typescript384 lines
1import type { Editor, JSONContent, Range } from "@tiptap/core";
2import { NodeSelection } from "@tiptap/pm/state";
3import { uploadFile } from "@/client/lib/uploads";
4import { toast } from "@/client/components/toast-store";
5import { MAX_UPLOAD_SIZE } from "@/shared/constants";
6import type { EditorRuntimeSnapshot } from "../editor-runtime-context";
7import type { EditorAffordance } from "@/client/lib/affordance/editor";
8 
9export const IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/heic"];
10const IMAGE_MIME_SET = new Set<string>(IMAGE_MIME_TYPES);
11export const IMAGE_TARGET_MISSING_MESSAGE = "That image block no longer exists.";
12 
13export interface UploadContext {
14 workspaceId: string | undefined;
15 pageId: string;
16 shareToken: string | undefined;
17}
18 
19export interface ImageNodeTarget {
20 pos: number;
21 dom: HTMLElement | null;
22}
23 
24export interface InsertedImagePlaceholder {
25 target: ImageNodeTarget;
26 nextPos: number;
27}
28 
29const localImagePreviews = new Map<string, string>();
30 
31export function getLocalImagePreview(pendingInsertId: string | null): string | null {
32 if (pendingInsertId === null) return null;
33 return localImagePreviews.get(pendingInsertId) ?? null;
34}
35 
36function setLocalImagePreview(pendingInsertId: string, blobUrl: string): void {
37 const existing = localImagePreviews.get(pendingInsertId);
38 if (existing && existing !== blobUrl) URL.revokeObjectURL(existing);
39 localImagePreviews.set(pendingInsertId, blobUrl);
40}
41 
42function clearLocalImagePreview(pendingInsertId: string): void {
43 const existing = localImagePreviews.get(pendingInsertId);
44 if (!existing) return;
45 URL.revokeObjectURL(existing);
46 localImagePreviews.delete(pendingInsertId);
47}
48 
49function validateImageFile(file: File): void {
50 if (!IMAGE_MIME_SET.has(file.type)) throw new Error("File type not allowed");
51 if (file.size > MAX_UPLOAD_SIZE) throw new Error("File too large (max 10MB)");
52}
53 
54async function probeImageDimensions(file: File): Promise<{ naturalWidth: number; naturalHeight: number } | null> {
55 const url = URL.createObjectURL(file);
56 try {
57 return await new Promise<{ naturalWidth: number; naturalHeight: number } | null>((resolve) => {
58 const img = new Image();
59 img.onload = () => resolve({ naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight });
60 img.onerror = () => resolve(null);
61 img.src = url;
62 });
63 } finally {
64 URL.revokeObjectURL(url);
65 }
66}
67 
68interface CreateImageFileHandlerConfigOptions {
69 getRuntime: () => EditorRuntimeSnapshot;
70 getAffordance: () => EditorAffordance;
71}
72 
73function findInsertedImagePos(doc: Editor["state"]["doc"], pendingInsertId: string | null): number | null {
74 let foundPos: number | null = null;
75 
76 doc.descendants((node, pos) => {
77 if (node.type.name !== "image") return true;
78 if (pendingInsertId !== null && node.attrs.pendingInsertId !== pendingInsertId) return true;
79 if (pendingInsertId === null && node.attrs.src !== "") return true;
80 foundPos = pos;
81 return false;
82 });
83 
84 return foundPos;
85}
86 
87export function resolveShareUrl(src: string, shareToken?: string): string {
88 if (shareToken && src.startsWith("/uploads/")) {
89 return `${src}?share=${shareToken}`;
90 }
91 return src;
92}
93 
94export function runtimeToUploadContext(runtime: EditorRuntimeSnapshot): UploadContext {
95 return {
96 workspaceId: runtime.workspaceId,
97 pageId: runtime.pageId,
98 shareToken: runtime.shareToken,
99 };
100}
101 
102export function createImageNodeTarget(editor: Editor, pos: number): ImageNodeTarget {
103 try {
104 const dom = editor.view.nodeDOM(pos);
105 return { pos, dom: dom instanceof HTMLElement ? dom : null };
106 } catch {
107 return { pos, dom: null };
108 }
109}
110 
111export function resolveImageTargetPos(editor: Editor, target: ImageNodeTarget): number | null {
112 if (target.dom?.isConnected) {
113 try {
114 const pos = editor.view.posAtDOM(target.dom, 0);
115 if (editor.state.doc.nodeAt(pos)?.type.name === "image") {
116 return pos;
117 }
118 } catch {
119 // Fall through to the last known document position.
120 }
121 }
122 
123 try {
124 return editor.state.doc.nodeAt(target.pos)?.type.name === "image" ? target.pos : null;
125 } catch {
126 return null;
127 }
128}
129 
130export function getImageTargetDom(editor: Editor, target: ImageNodeTarget): HTMLElement | null {
131 const pos = resolveImageTargetPos(editor, target);
132 if (pos !== null) {
133 try {
134 const dom = editor.view.nodeDOM(pos);
135 if (dom instanceof HTMLElement) return dom;
136 } catch {
137 // Fall through to any still-connected captured DOM node.
138 }
139 }
140 
141 return target.dom?.isConnected ? target.dom : null;
142}
143 
144export function updateImageAttributesAtTarget(
145 editor: Editor,
146 target: ImageNodeTarget,
147 attrs: Record<string, unknown>,
148): boolean {
149 const pos = resolveImageTargetPos(editor, target);
150 if (pos === null) return false;
151 
152 const node = editor.state.doc.nodeAt(pos);
153 if (node?.type.name !== "image") return false;
154 
155 const tr = editor.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs });
156 editor.view.dispatch(tr);
157 return true;
158}
159 
160export function replaceImageSourceAtTarget(editor: Editor, target: ImageNodeTarget, src: string): boolean {
161 const pos = resolveImageTargetPos(editor, target);
162 if (pos === null) return false;
163 
164 const node = editor.state.doc.nodeAt(pos);
165 if (node?.type.name !== "image") return false;
166 
167 const nextAttrs: Record<string, unknown> = { ...node.attrs, src };
168 if ("pendingInsertId" in node.attrs) {
169 nextAttrs.pendingInsertId = null;
170 }
171 
172 const tr = editor.state.tr.setNodeMarkup(pos, undefined, nextAttrs);
173 editor.view.dispatch(tr);
174 return true;
175}
176 
177export function deleteImageAtTarget(editor: Editor, target: ImageNodeTarget): boolean {
178 const pos = resolveImageTargetPos(editor, target);
179 if (pos === null) return false;
180 
181 const node = editor.state.doc.nodeAt(pos);
182 if (!node) return false;
183 
184 const tr = editor.state.tr.delete(pos, pos + node.nodeSize);
185 editor.view.dispatch(tr);
186 return true;
187}
188 
189function createPendingImageContent(editor: Editor): { content: JSONContent; pendingInsertId: string | null } | null {
190 if (!editor.isEditable) return null;
191 
192 const imageType = editor.state.schema.nodes.image;
193 if (!imageType) return null;
194 
195 const supportsPendingInsertId = "pendingInsertId" in (imageType.spec.attrs ?? {});
196 const pendingInsertId = supportsPendingInsertId ? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : null;
197 const content: JSONContent = {
198 type: "image",
199 attrs: pendingInsertId === null ? { src: "" } : { src: "", pendingInsertId },
200 };
201 
202 return { content, pendingInsertId };
203}
204 
205function finalizeInsertedImagePlaceholder(
206 editor: Editor,
207 pendingInsertId: string | null,
208): InsertedImagePlaceholder | null {
209 const insertedPos = findInsertedImagePos(editor.state.doc, pendingInsertId);
210 if (insertedPos === null) return null;
211 
212 const node = editor.state.doc.nodeAt(insertedPos);
213 if (!node || node.type.name !== "image") return null;
214 
215 const tr = editor.state.tr;
216 if (pendingInsertId !== null) {
217 tr.setNodeMarkup(insertedPos, undefined, { ...node.attrs, pendingInsertId: null });
218 }
219 
220 const resolvedNode = tr.doc.nodeAt(insertedPos);
221 if (!resolvedNode || resolvedNode.type.name !== "image") return null;
222 
223 tr.setSelection(NodeSelection.create(tr.doc, insertedPos));
224 editor.view.dispatch(tr);
225 editor.commands.focus(null, { scrollIntoView: false });
226 
227 return {
228 target: createImageNodeTarget(editor, insertedPos),
229 nextPos: insertedPos + resolvedNode.nodeSize,
230 };
231}
232 
233export function insertImagePlaceholderAtRange(editor: Editor, range: Range): InsertedImagePlaceholder | null {
234 const pending = createPendingImageContent(editor);
235 if (!pending) return null;
236 
237 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).insertContent(pending.content).run();
238 return finalizeInsertedImagePlaceholder(editor, pending.pendingInsertId);
239}
240 
241export function insertImagePlaceholderAtPos(editor: Editor, pos: number): InsertedImagePlaceholder | null {
242 const pending = createPendingImageContent(editor);
243 if (!pending) return null;
244 
245 editor.chain().focus(null, { scrollIntoView: false }).insertContentAt(pos, pending.content).run();
246 return finalizeInsertedImagePlaceholder(editor, pending.pendingInsertId);
247}
248 
249function collectImageTargetsAtRange(editor: Editor, range: Range, fileCount: number): ImageNodeTarget[] {
250 const targets: ImageNodeTarget[] = [];
251 const first = insertImagePlaceholderAtRange(editor, range);
252 if (!first) return targets;
253 
254 targets.push(first.target);
255 let insertPos = first.nextPos;
256 for (const _file of Array.from({ length: Math.max(0, fileCount - 1) })) {
257 const placeholder = insertImagePlaceholderAtPos(editor, insertPos);
258 if (!placeholder) break;
259 targets.push(placeholder.target);
260 insertPos = placeholder.nextPos;
261 }
262 
263 return targets;
264}
265 
266function collectImageTargetsAtPos(editor: Editor, pos: number, fileCount: number): ImageNodeTarget[] {
267 const targets: ImageNodeTarget[] = [];
268 let insertPos = pos;
269 
270 for (const _file of Array.from({ length: fileCount })) {
271 const placeholder = insertImagePlaceholderAtPos(editor, insertPos);
272 if (!placeholder) break;
273 targets.push(placeholder.target);
274 insertPos = placeholder.nextPos;
275 }
276 
277 return targets;
278}
279 
280async function uploadFilesAtTargets(editor: Editor, ctx: UploadContext, files: File[], targets: ImageNodeTarget[]) {
281 for (const [index, file] of files.entries()) {
282 const target = targets[index];
283 if (!target) break;
284 await uploadAndReplaceImageAtTarget(editor, ctx, file, target);
285 }
286}
287 
288export function createImageFileHandlerConfig({ getRuntime, getAffordance }: CreateImageFileHandlerConfigOptions) {
289 return {
290 allowedMimeTypes: IMAGE_MIME_TYPES,
291 onPaste: (editor: Editor, files: File[]) => {
292 const runtime = getRuntime();
293 if (!editor.isEditable || !getAffordance().canInsertImages || !runtime.workspaceId || files.length === 0) return;
294 
295 const targets = collectImageTargetsAtRange(editor, editor.state.selection, files.length);
296 if (targets.length === 0) return;
297 
298 void uploadFilesAtTargets(editor, runtimeToUploadContext(runtime), files, targets);
299 },
300 onDrop: (editor: Editor, files: File[], pos: number) => {
301 const runtime = getRuntime();
302 if (!editor.isEditable || !getAffordance().canInsertImages || !runtime.workspaceId || files.length === 0) return;
303 
304 const targets = collectImageTargetsAtPos(editor, pos, files.length);
305 if (targets.length === 0) return;
306 
307 void uploadFilesAtTargets(editor, runtimeToUploadContext(runtime), files, targets);
308 },
309 };
310}
311 
312export async function uploadAndReplaceImageAtTarget(
313 editor: Editor,
314 ctx: UploadContext,
315 file: File,
316 target: ImageNodeTarget,
317): Promise<boolean> {
318 if (!ctx.workspaceId) return false;
319 if (resolveImageTargetPos(editor, target) === null) {
320 toast.error(IMAGE_TARGET_MISSING_MESSAGE);
321 return false;
322 }
323 
324 try {
325 validateImageFile(file);
326 } catch (e) {
327 toast.error(e instanceof Error ? e.message : "Upload failed");
328 return false;
329 }
330 
331 const pendingInsertId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
332 const markerSet = updateImageAttributesAtTarget(editor, target, { pendingInsertId });
333 if (!markerSet) {
334 toast.error(IMAGE_TARGET_MISSING_MESSAGE);
335 return false;
336 }
337 
338 setLocalImagePreview(pendingInsertId, URL.createObjectURL(file));
339 
340 try {
341 const dims = await probeImageDimensions(file);
342 if (dims) {
343 updateImageAttributesAtTarget(editor, target, {
344 naturalWidth: dims.naturalWidth,
345 naturalHeight: dims.naturalHeight,
346 });
347 }
348 
349 const src = await uploadFile(ctx.workspaceId, file, ctx.pageId, ctx.shareToken);
350 if (!replaceImageSourceAtTarget(editor, target, src)) {
351 toast.error(IMAGE_TARGET_MISSING_MESSAGE);
352 return false;
353 }
354 return true;
355 } catch (e) {
356 toast.error(e instanceof Error ? e.message : "Upload failed");
357 updateImageAttributesAtTarget(editor, target, { pendingInsertId: null });
358 return false;
359 } finally {
360 clearLocalImagePreview(pendingInsertId);
361 }
362}
363 
364export function triggerFileUploadAtTarget(
365 editor: Editor,
366 ctx: UploadContext,
367 target: ImageNodeTarget,
368 onComplete?: () => void,
369): void {
370 if (!ctx.workspaceId) return;
371 const input = document.createElement("input");
372 input.type = "file";
373 input.accept = "image/*";
374 input.onchange = () => {
375 const file = input.files?.[0];
376 if (!file) return;
377 void (async () => {
378 await uploadAndReplaceImageAtTarget(editor, ctx, file, target);
379 onComplete?.();
380 })();
381 };
382 input.click();
383}