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",
- }),
- ];
- },
});