From 5bda5623f26ed728836af6fc40c309f99992ff4a Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 16:54:24 +0100 Subject: [PATCH] feat: Improved placeholder and upload handling for images --- .../editor/components/image/image-menu.tsx | 2 +- .../components/image/image-view.module.css | 10 + .../editor/components/image/image-view.tsx | 27 ++- .../components/slash-menu/menu-items.ts | 4 + package.json | 1 + .../editor-ext/src/lib/image/image-upload.ts | 188 ++++++++---------- packages/editor-ext/src/lib/image/image.ts | 21 +- pnpm-lock.yaml | 10 + 8 files changed, 137 insertions(+), 126 deletions(-) create mode 100644 apps/client/src/features/editor/components/image/image-view.module.css diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 6f2c9b9c..a1699f93 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { return false; } - return editor.isActive("image"); + return editor.isActive("image") && editor.getAttributes("image").src; }, [editor], ); diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css new file mode 100644 index 00000000..8ee9bc06 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -0,0 +1,10 @@ +.imagePlaceholder { + border-radius: 8px; + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx index dbdb8396..8fdd434d 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -3,11 +3,11 @@ import { useMemo } from "react"; import { Image } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; import clsx from "clsx"; +import classes from "./image-view.module.css"; export default function ImageView(props: NodeViewProps) { const { node, selected } = props; - const { src, width, align, title } = node.attrs; - + const { src, width, align, title, aspectRatio } = node.attrs; const alignClass = useMemo(() => { if (align === "left") return "alignLeft"; if (align === "right") return "alignRight"; @@ -17,14 +17,21 @@ export default function ImageView(props: NodeViewProps) { return ( - {title} +
+ {src && ( + {title} + )} +
); } diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 362c1686..049e1503 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -174,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (input.files?.length) { for (const file of input.files) { const pos = editor.view.state.selection.from; + uploadImageAction(file, editor.view, pos, pageId); } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/package.json b/package.json index 86333599..4ddc8a85 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "dompurify": "^3.2.6", "fractional-indexing-jittered": "^1.0.0", "highlight.js": "^11.11.1", + "image-dimensions": "^2.5.0", "ioredis": "^5.4.1", "jszip": "^3.10.1", "linkifyjs": "^4.3.2", diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 9a759903..e2494651 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,127 +1,105 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils"; +import { imageDimensionsFromData } from "image-dimensions"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; -const uploadKey = new PluginKey("image-upload"); +const findImageNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const ImageUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", placeholderClass); - image.src = src; - placeholder.appendChild(image); - const deco = Decoration.widget(pos + 1, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "image" && + node.attrs.placeholderId === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleImageUpload = + return result; +}; +const handleImageUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => async (file, view, pos, pageId) => { // check if the file is an image const validated = validateFn?.(file); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - // Replace the selection with a placeholder - if (!tr.selection.empty) tr.deleteSelection(); + const imageDimensions = imageDimensionsFromData(await file.bytes()); + const placeholderId = generateNodeId(); + const aspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : undefined; + const initialPlaceholderNode = view.state.schema.nodes.image?.create({ + placeholderId, + aspectRatio, + }); - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + let placeholderShown = false; + let tr = view.state.tr; - insertTrailingNode(tr, pos, view); + if (!initialPlaceholderNode) return; + + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; + + if (isEmptyTextBlock) { + // Replace e.g. empty paragraph with the image + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } + + // Only show the placeholder if the upload takes more than 250ms + const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); - }; + placeholderShown = true; + tr = view.state.tr; + }, 250); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + try { + const attachment: IAttachment = await onUpload(file, pageId); + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const pos = findPlaceholder(view.state, id); + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; + // Update the placeholder node with the actual image data + tr.setNodeMarkup(currentPos, undefined, { + src: `/api/files/${attachment.id}/${attachment.fileName}`, + attachmentId: attachment.id, + title: attachment.fileName, + size: attachment.fileSize, + aspectRatio, + }); + } catch (error) { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder + if (currentPos === null) return; - if (!attachment) return; + // Delete the image placeholder on error + tr.delete(currentPos, currentPos + 2); + } finally { + clearTimeout(displayPlaceholderTimeout); - const node = schema.nodes.image?.create({ - src: `/api/files/${attachment.id}/${attachment.fileName}`, - attachmentId: attachment.id, - title: attachment.fileName, - size: attachment.fileSize, - }); - if (!node) return; - - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + // If the placeholder was shown, delay showing the image to avoid flicker + if (placeholderShown) { + setTimeout(() => { + view.dispatch(tr); + }, 100); + } else { + view.dispatch(tr); + } + } }; + +export { handleImageUpload }; diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index cc8ba220..8ee2230d 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,7 +1,6 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { ImageUploadPlugin } from "./image-upload"; import { mergeAttributes, Range } from "@tiptap/core"; export interface ImageOptions extends DefaultImageOptions { @@ -15,6 +14,8 @@ export interface ImageAttributes { attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholderId?: string; } declare module "@tiptap/core" { @@ -22,7 +23,7 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range } + attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; @@ -90,6 +91,14 @@ export const TiptapImage = Image.extend({ "data-size": attributes.size, }), }, + placeholderId: { + default: null, + rendered: false, + }, + aspectRatio: { + default: null, + rendered: false, + }, }; }, @@ -140,12 +149,4 @@ export const TiptapImage = Image.extend({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - ImageUploadPlugin({ - placeholderClass: "image-upload", - }), - ]; - }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42353c3..7a6d7756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + image-dimensions: + specifier: ^2.5.0 + version: 2.5.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -6839,6 +6842,11 @@ packages: image-blob-reduce@3.0.1: resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + image-dimensions@2.5.0: + resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==} + engines: {node: '>=18'} + hasBin: true + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -17647,6 +17655,8 @@ snapshots: dependencies: pica: 7.1.1 + image-dimensions@2.5.0: {} + image-size@0.5.5: optional: true