From dd8c42e1f3506a8054081009513cd7727055996e Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:45:24 +0100 Subject: [PATCH] feat: Improved placeholder and upload handling for attachments --- .../components/attachment/attachment-view.tsx | 10 +- .../components/slash-menu/menu-items.ts | 3 + .../src/lib/attachment/attachment-upload.ts | 188 ++++++++---------- .../src/lib/attachment/attachment.ts | 20 +- 4 files changed, 99 insertions(+), 122 deletions(-) diff --git a/apps/client/src/features/editor/components/attachment/attachment-view.tsx b/apps/client/src/features/editor/components/attachment/attachment-view.tsx index d38585202..48ab6cd69 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -1,5 +1,5 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { Group, Text, Paper, ActionIcon } from "@mantine/core"; +import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { useHover } from "@mantine/hooks"; @@ -21,10 +21,10 @@ export default function AttachmentView(props: NodeViewProps) { h={25} > - + {url ? : } - {name} + {url ? name : `Uploading ${name}...`} @@ -32,14 +32,12 @@ export default function AttachmentView(props: NodeViewProps) { - {selected || hovered ? ( + {url && (selected || hovered) && ( - ) : ( - "" )} 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 11a889255..e3bf5622e 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 @@ -237,6 +237,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { const pos = editor.view.state.selection.from; uploadAttachmentAction(file, editor.view, pos, pageId, true); } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts index 0d2ac6c73..e50be56ed 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -1,126 +1,102 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { Node } from "@tiptap/pm/model"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; -const uploadKey = new PluginKey("attachment-upload"); +const findAttachmentNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const AttachmentUploadPlugin = ({ - 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, fileName } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", placeholderClass); - - const uploadingText = document.createElement("span"); - uploadingText.setAttribute("class", "uploading-text"); - uploadingText.textContent = `Uploading ${fileName}`; - - placeholder.appendChild(uploadingText); - - const realPos = pos + 1; - const deco = Decoration.widget(realPos, 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 === "attachment" && + 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 handleAttachmentUpload = + return result; +}; +const handleAttachmentUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => async (file, view, pos, pageId, allowMedia) => { const validated = validateFn?.(file, allowMedia); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); - - tr.setMeta(uploadKey, { - add: { - id, - pos, - fileName: file.name, - }, + const placeholderId = generateNodeId(); + const initialPlaceholderNode = view.state.schema.nodes.attachment?.create({ + placeholderId, + name: file.name, + size: file.size, }); - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + let placeholderShown = false; + let tr = view.state.tr; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + if (!initialPlaceholderNode) return; - const pos = findPlaceholder(view.state, id); + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; - if (pos == null) return; + if (isEmptyTextBlock) { + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } - if (!attachment) return; + // Only show the placeholder if the upload takes more than 250ms + const displayPlaceholderTimeout = setTimeout(() => { + view.dispatch(tr); + placeholderShown = true; + tr = view.state.tr; + }, 250); - const node = schema.nodes.attachment?.create({ - url: `/api/files/${attachment.id}/${attachment.fileName}`, - name: attachment.fileName, - mime: attachment.mimeType, - size: attachment.fileSize, - attachmentId: attachment.id, - }); - if (!node) return; + try { + const attachment: IAttachment = await onUpload(file, pageId); + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; + + // Update the placeholder node with the actual attachment data + tr.setNodeMarkup(currentPos, undefined, { + url: `/api/files/${attachment.id}/${attachment.fileName}`, + name: attachment.fileName, + mime: attachment.mimeType, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + } catch (error) { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return; + + // Delete the placeholder on error + tr.delete( + currentPos, + currentPos + (initialPlaceholderNode.nodeSize ?? 1), + ); + } finally { + clearTimeout(displayPlaceholderTimeout); + + // If the placeholder was shown, delay showing the attachment to avoid flicker + if (placeholderShown) { + setTimeout(() => { + view.dispatch(tr); + }, 100); + } else { + view.dispatch(tr); + } + } }; + +export { handleAttachmentUpload }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index bd1814f5f..34a8fe42e 100644 --- a/packages/editor-ext/src/lib/attachment/attachment.ts +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -1,6 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { AttachmentUploadPlugin } from "./attachment-upload"; export interface AttachmentOptions { HTMLAttributes: Record; @@ -13,6 +12,7 @@ export interface AttachmentAttributes { mime?: string; // e.g. application/zip size?: number; attachmentId?: string; + placeholderId?: string; } declare module "@tiptap/core" { @@ -75,6 +75,14 @@ export const Attachment = Node.create({ "data-attachment-id": attributes.attachmentId, }), }, + placeholderId: { + default: null, + parseHTML: (element) => + element.getAttribute("data-attachment-placeholder-id"), + renderHTML: (attributes: AttachmentAttributes) => ({ + "data-attachment-placeholder-id": attributes.placeholderId, + }), + }, }; }, @@ -92,7 +100,7 @@ export const Attachment = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "a", @@ -125,12 +133,4 @@ export const Attachment = Node.create({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - AttachmentUploadPlugin({ - placeholderClass: "attachment-placeholder", - }), - ]; - }, });