feat: Improved placeholder and upload handling for attachments

This commit is contained in:
Arek Nawo
2026-01-19 20:45:24 +01:00
parent 13c29545a2
commit dd8c42e1f3
4 changed files with 99 additions and 122 deletions
@@ -1,5 +1,5 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; 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 { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
@@ -21,10 +21,10 @@ export default function AttachmentView(props: NodeViewProps) {
h={25} h={25}
> >
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} /> {url ? <IconPaperclip size={20} /> : <Loader size={20} />}
<Text component="span" size="md" truncate="end"> <Text component="span" size="md" truncate="end">
{name} {url ? name : `Uploading ${name}...`}
</Text> </Text>
<Text component="span" size="sm" c="dimmed" inline> <Text component="span" size="sm" c="dimmed" inline>
@@ -32,14 +32,12 @@ export default function AttachmentView(props: NodeViewProps) {
</Text> </Text>
</Group> </Group>
{selected || hovered ? ( {url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank"> <a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file"> <ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} /> <IconDownload size={18} />
</ActionIcon> </ActionIcon>
</a> </a>
) : (
""
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -237,6 +237,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true); uploadAttachmentAction(file, editor.view, pos, pageId, true);
} }
// Reset the input value to allow uploading the same file again if needed
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -1,126 +1,102 @@
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Node } from "@tiptap/pm/model";
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import {
insertTrailingNode,
MediaUploadOptions,
UploadFn,
} from "../media-utils";
import { IAttachment } from "../types"; 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 = ({ doc.descendants((node, pos) => {
placeholderClass, if (result) return false;
}: { if (
placeholderClass: string; node.type.name === "attachment" &&
}) => node.attrs.placeholderId === placeholderId
new Plugin({ ) {
key: uploadKey, result = { node, pos };
state: { return false;
init() { }
return DecorationSet.empty; return true;
},
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);
},
},
}); });
function findPlaceholder(state: EditorState, id: {}) { return result;
const decos = uploadKey.getState(state) as DecorationSet; };
const found = decos.find(undefined, undefined, (spec) => spec.id == id); const handleAttachmentUpload =
return found.length ? found[0]?.from : null;
}
export const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId, allowMedia) => { async (file, view, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia); const validated = validateFn?.(file, allowMedia);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder const placeholderId = generateNodeId();
const tr = view.state.tr; const initialPlaceholderNode = view.state.schema.nodes.attachment?.create({
if (!tr.selection.empty) tr.deleteSelection(); placeholderId,
name: file.name,
tr.setMeta(uploadKey, { size: file.size,
add: {
id,
pos,
fileName: file.name,
},
}); });
insertTrailingNode(tr, pos, view); let placeholderShown = false;
view.dispatch(tr); let tr = view.state.tr;
await onUpload(file, pageId).then( if (!initialPlaceholderNode) return;
(attachment: IAttachment) => {
const { schema } = view.state;
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({ try {
url: `/api/files/${attachment.id}/${attachment.fileName}`, const attachment: IAttachment = await onUpload(file, pageId);
name: attachment.fileName, const { pos: currentPos = null } =
mime: attachment.mimeType, findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
size: attachment.fileSize,
attachmentId: attachment.id,
});
if (!node) return;
const transaction = view.state.tr // If the placeholder is not found or attachment is missing, abort the process
.replaceWith(pos, pos, node) if (currentPos === null || !attachment) return;
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction); // Update the placeholder node with the actual attachment data
}, tr.setNodeMarkup(currentPos, undefined, {
() => { url: `/api/files/${attachment.id}/${attachment.fileName}`,
// Deletes the placeholder on error name: attachment.fileName,
const transaction = view.state.tr mime: attachment.mimeType,
.delete(pos, pos) size: attachment.fileSize,
.setMeta(uploadKey, { remove: { id } }); attachmentId: attachment.id,
view.dispatch(transaction); });
}, } 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 };
@@ -1,6 +1,5 @@
import { Node, mergeAttributes } from "@tiptap/core"; import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { AttachmentUploadPlugin } from "./attachment-upload";
export interface AttachmentOptions { export interface AttachmentOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip mime?: string; // e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
placeholderId?: string;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -75,6 +75,14 @@ export const Attachment = Node.create<AttachmentOptions>({
"data-attachment-id": attributes.attachmentId, "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<AttachmentOptions>({
mergeAttributes( mergeAttributes(
{ "data-type": this.name }, { "data-type": this.name },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes HTMLAttributes,
), ),
[ [
"a", "a",
@@ -125,12 +133,4 @@ export const Attachment = Node.create<AttachmentOptions>({
return ReactNodeViewRenderer(this.options.view); return ReactNodeViewRenderer(this.options.view);
}, },
addProseMirrorPlugins() {
return [
AttachmentUploadPlugin({
placeholderClass: "attachment-placeholder",
}),
];
},
}); });