File
Blob: src/client/components/editor/lib/media-actions.ts
| 1 | import type { Editor, JSONContent, Range } from "@tiptap/core"; |
| 2 | import { NodeSelection } from "@tiptap/pm/state"; |
| 3 | import { uploadFile } from "@/client/lib/uploads"; |
| 4 | import { toast } from "@/client/components/toast-store"; |
| 5 | import { MAX_UPLOAD_SIZE } from "@/shared/constants"; |
| 6 | import type { EditorRuntimeSnapshot } from "../editor-runtime-context"; |
| 7 | import type { EditorAffordance } from "@/client/lib/affordance/editor"; |
| 8 | |
| 9 | export const IMAGE_MIME_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/heic"]; |
| 10 | const IMAGE_MIME_SET = new Set<string>(IMAGE_MIME_TYPES); |
| 11 | export const IMAGE_TARGET_MISSING_MESSAGE = "That image block no longer exists."; |
| 12 | |
| 13 | export interface UploadContext { |
| 14 | workspaceId: string | undefined; |
| 15 | pageId: string; |
| 16 | shareToken: string | undefined; |
| 17 | } |
| 18 | |
| 19 | export interface ImageNodeTarget { |
| 20 | pos: number; |
| 21 | dom: HTMLElement | null; |
| 22 | } |
| 23 | |
| 24 | export interface InsertedImagePlaceholder { |
| 25 | target: ImageNodeTarget; |
| 26 | nextPos: number; |
| 27 | } |
| 28 | |
| 29 | const localImagePreviews = new Map<string, string>(); |
| 30 | |
| 31 | export function getLocalImagePreview(pendingInsertId: string | null): string | null { |
| 32 | if (pendingInsertId === null) return null; |
| 33 | return localImagePreviews.get(pendingInsertId) ?? null; |
| 34 | } |
| 35 | |
| 36 | function 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 | |
| 42 | function clearLocalImagePreview(pendingInsertId: string): void { |
| 43 | const existing = localImagePreviews.get(pendingInsertId); |
| 44 | if (!existing) return; |
| 45 | URL.revokeObjectURL(existing); |
| 46 | localImagePreviews.delete(pendingInsertId); |
| 47 | } |
| 48 | |
| 49 | function 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 | |
| 54 | async 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 | |
| 68 | interface CreateImageFileHandlerConfigOptions { |
| 69 | getRuntime: () => EditorRuntimeSnapshot; |
| 70 | getAffordance: () => EditorAffordance; |
| 71 | } |
| 72 | |
| 73 | function 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 | |
| 87 | export function resolveShareUrl(src: string, shareToken?: string): string { |
| 88 | if (shareToken && src.startsWith("/uploads/")) { |
| 89 | return `${src}?share=${shareToken}`; |
| 90 | } |
| 91 | return src; |
| 92 | } |
| 93 | |
| 94 | export function runtimeToUploadContext(runtime: EditorRuntimeSnapshot): UploadContext { |
| 95 | return { |
| 96 | workspaceId: runtime.workspaceId, |
| 97 | pageId: runtime.pageId, |
| 98 | shareToken: runtime.shareToken, |
| 99 | }; |
| 100 | } |
| 101 | |
| 102 | export 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 | |
| 111 | export 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 | |
| 130 | export 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 | |
| 144 | export 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 | |
| 160 | export 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 | |
| 177 | export 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 | |
| 189 | function 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 | |
| 205 | function 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 | |
| 233 | export 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 | |
| 241 | export 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 | |
| 249 | function 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 | |
| 266 | function 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 | |
| 280 | async 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 | |
| 288 | export 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 | |
| 312 | export 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 | |
| 364 | export 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 | } |