diff --git a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx
index 8eee02fc..61d7534e 100644
--- a/apps/client/src/features/editor/components/common/editor-paste-handler.tsx
+++ b/apps/client/src/features/editor/components/common/editor-paste-handler.tsx
@@ -1,13 +1,12 @@
-import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
-import { Slice } from "@tiptap/pm/model";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
+import { Editor } from "@tiptap/core";
export const handlePaste = (
- view: EditorView,
+ editor: Editor,
event: ClipboardEvent,
pageId: string,
creatorId?: string,
@@ -18,7 +17,7 @@ export const handlePaste = (
// we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault();
const url = clipboardData.trim();
- const { from: pos, empty } = view.state.selection;
+ const { from: pos, empty } = editor.state.selection;
const match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false;
}
- const anchorId = match[6] ? match[6].split('#')[0] : undefined;
- const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url;
- createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId);
+ const anchorId = match[6] ? match[6].split("#")[0] : undefined;
+ const urlWithoutAnchor = anchorId
+ ? url.substring(0, url.indexOf("#"))
+ : url;
+ createMentionAction(
+ urlWithoutAnchor,
+ editor.view,
+ pos,
+ creatorId,
+ anchorId,
+ );
return true;
}
if (event.clipboardData?.files.length) {
event.preventDefault();
for (const file of event.clipboardData.files) {
- const pos = view.state.selection.from;
- uploadImageAction(file, view, pos, pageId);
- uploadVideoAction(file, view, pos, pageId);
- uploadAttachmentAction(file, view, pos, pageId);
+ const pos = editor.state.selection.from;
+ uploadImageAction(file, editor, pos, pageId);
+ uploadVideoAction(file, editor, pos, pageId);
+ uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
}
@@ -54,7 +61,7 @@ export const handlePaste = (
};
export const handleFileDrop = (
- view: EditorView,
+ editor: Editor,
event: DragEvent,
moved: boolean,
pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault();
for (const file of event.dataTransfer.files) {
- const coordinates = view.posAtCoords({
+ const coordinates = editor.view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
- uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
- uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
- uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
+ uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
+ uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
+ uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}
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
index 67a3629b..7d0dabf3 100644
--- a/apps/client/src/features/editor/components/image/image-view.module.css
+++ b/apps/client/src/features/editor/components/image/image-view.module.css
@@ -1,4 +1,7 @@
.imageWrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
border-radius: 8px;
@mixin light {
background-color: var(--mantine-color-gray-0);
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 0dac0966..6aa777f9 100644
--- a/apps/client/src/features/editor/components/image/image-view.tsx
+++ b/apps/client/src/features/editor/components/image/image-view.tsx
@@ -1,19 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
+import { Group, Image, Loader, Text } from "@mantine/core";
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, aspectRatio } = node.attrs;
+ const { editor, node, selected } = props;
+ const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
+ const previewSrc = useMemo(() => {
+ editor.storage.shared.imagePreviews =
+ editor.storage.shared.imagePreviews || {};
+
+ if (placeholder?.id) {
+ return editor.storage.shared.imagePreviews[placeholder.id];
+ }
+
+ return null;
+ }, [placeholder, editor]);
return (
@@ -31,6 +41,25 @@ export default function ImageView(props: NodeViewProps) {
{src && (
)}
+ {!src && previewSrc && (
+
+
+
+
+ )}
+ {!src && !previewSrc && (
+
+
+
+ Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}...
+
+
+ )}
);
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 e3bf5622..17a8027e 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
@@ -175,7 +175,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
- uploadImageAction(file, editor.view, pos, pageId);
+ uploadImageAction(file, editor, pos, pageId);
}
}
@@ -201,12 +201,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
+ input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
- const file = input.files[0];
- const pos = editor.view.state.selection.from;
+ for (const file of input.files) {
+ const pos = editor.view.state.selection.from;
- uploadVideoAction(file, editor.view, pos, pageId);
+ uploadVideoAction(file, editor, pos, pageId);
+ }
}
// Reset the input value to allow uploading the same file again if needed
@@ -231,11 +233,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "";
+ input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
- const file = input.files[0];
- const pos = editor.view.state.selection.from;
- uploadAttachmentAction(file, editor.view, pos, pageId, true);
+ for (const file of input.files) {
+ const pos = editor.view.state.selection.from;
+
+ uploadAttachmentAction(file, editor, pos, pageId);
+ }
}
// Reset the input value to allow uploading the same file again if needed
diff --git a/apps/client/src/features/editor/components/video/video-view.module.css b/apps/client/src/features/editor/components/video/video-view.module.css
index fdb959dd..95bce4ba 100644
--- a/apps/client/src/features/editor/components/video/video-view.module.css
+++ b/apps/client/src/features/editor/components/video/video-view.module.css
@@ -1,4 +1,7 @@
.videoWrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
border-radius: 8px;
@mixin light {
background-color: var(--mantine-color-gray-0);
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index 13a9d02d..c228265e 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -1,18 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
+import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx";
import classes from "./video-view.module.css";
export default function VideoView(props: NodeViewProps) {
- const { node, selected } = props;
- const { src, width, align, aspectRatio } = node.attrs;
+ const { editor, node, selected } = props;
+ const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
if (align === "center") return "alignCenter";
return "alignCenter";
}, [align]);
+ const previewSrc = useMemo(() => {
+ editor.storage.shared.videoPreviews =
+ editor.storage.shared.videoPreviews || {};
+
+ if (placeholder?.id) {
+ return editor.storage.shared.videoPreviews[placeholder.id];
+ }
+
+ return null;
+ }, [placeholder, editor]);
return (
@@ -35,6 +46,25 @@ export default function VideoView(props: NodeViewProps) {
src={getFileUrl(src)}
/>
)}
+ {!src && previewSrc && (
+
+
+
+
+ )}
+ {!src && !previewSrc && (
+
+
+
+ Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}...
+
+
+ )}
);
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 547aeed6..3764e43c 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -42,6 +42,7 @@ import {
Heading,
Highlight,
UniqueID,
+ SharedStorage,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -107,6 +108,7 @@ export const mainExtensions = [
},
},
}),
+ SharedStorage,
Heading,
UniqueID.configure({
types: ["heading", "paragraph"],
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index f5619c91..419a32be 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -93,7 +93,7 @@ export default function PageEditor({
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
- yjsConnectionStatusAtom
+ yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
@@ -105,7 +105,7 @@ export default function PageEditor({
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current,
- [isComponentMounted, editorCreated]
+ [isComponentMounted, editorCreated],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
@@ -253,10 +253,10 @@ export default function PageEditor({
}
},
},
- handlePaste: (view, event, slice) =>
- handlePaste(view, event, pageId, currentUser?.user.id),
- handleDrop: (view, event, _slice, moved) =>
- handleFileDrop(view, event, moved, pageId),
+ handlePaste: (_view, event) =>
+ handlePaste(editor, event, pageId, currentUser?.user.id),
+ handleDrop: (_view, event, _slice, moved) =>
+ handleFileDrop(editor, event, moved, pageId),
},
onCreate({ editor }) {
if (editor) {
@@ -275,7 +275,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson);
},
},
- [pageId, editable, extensions]
+ [pageId, editable, extensions],
);
const editorIsEditable = useEditorState({
@@ -320,7 +320,7 @@ export default function PageEditor({
return () => {
document.removeEventListener(
"ACTIVE_COMMENT_EVENT",
- handleActiveCommentEvent
+ handleActiveCommentEvent,
);
};
}, []);
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index 3ff99083..24d0ac5f 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
export * from "./lib/highlight";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
+export * from "./lib/shared-storage";
diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts
index cdc78790..a3446db9 100644
--- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts
+++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts
@@ -2,7 +2,7 @@ import { Node } from "@tiptap/pm/model";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
-import { Transaction } from "@tiptap/pm/state";
+import { Command } from "@tiptap/core";
const findAttachmentNodeByPlaceholderId = (
doc: Node,
@@ -14,7 +14,7 @@ const findAttachmentNodeByPlaceholderId = (
if (result) return false;
if (
node.type.name === "attachment" &&
- node.attrs.placeholderId === placeholderId
+ node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
@@ -26,82 +26,99 @@ const findAttachmentNodeByPlaceholderId = (
};
const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
- async (file, view, pos, pageId, allowMedia) => {
+ async (file, editor, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia);
// @ts-ignore
if (!validated) return;
const placeholderId = generateNodeId();
- const initialPlaceholderNode = view.state.schema.nodes.attachment?.create({
- placeholderId,
- name: file.name,
- size: file.size,
- });
- let tr: Transaction | null = view.state.tr;
- let placeholderShown = false;
+ let placeholderInserted = false;
- if (!initialPlaceholderNode) return;
+ const insertPlaceholder = (): Command => {
+ return ({ tr, state }) => {
+ const initialPlaceholderNode = state.schema.nodes.attachment?.create({
+ placeholder: {
+ id: placeholderId,
+ },
+ name: file.name,
+ size: file.size,
+ });
- const { parent } = tr.doc.resolve(pos);
- const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+ if (!initialPlaceholderNode) return false;
- if (isEmptyTextBlock) {
- tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
- } else {
- tr.insert(pos, initialPlaceholderNode);
- }
+ const { parent } = tr.doc.resolve(pos);
+ const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+ if (isEmptyTextBlock) {
+ tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
+ } else {
+ tr.insert(pos, initialPlaceholderNode);
+ }
+
+ return true;
+ };
+ };
+ const replacePlaceholderWithAttachment = (
+ attachment: IAttachment,
+ ): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ // If the placeholder is not found or attachment is missing, abort the process
+ if (currentPos === null || !attachment) return false;
+
+ // 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,
+ });
+
+ return true;
+ };
+ };
+ const removePlaceholder = (): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ if (currentPos === null) return false;
+
+ tr.delete(currentPos, currentPos + 2);
+
+ return true;
+ };
+ };
// Only show the placeholder if the upload takes more than 250ms
- const displayPlaceholderTimeout = setTimeout(() => {
- view.dispatch(tr);
- placeholderShown = true;
- tr = null;
+ const insertPlaceholderTimeout = setTimeout(() => {
+ editor.commands.command(insertPlaceholder());
+ placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
- tr = tr ?? view.state.tr;
+ clearTimeout(insertPlaceholderTimeout);
- const { pos: currentPos = null } =
- findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
-
- // 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) {
- tr = tr ?? view.state.tr;
-
- 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) {
+ if (placeholderInserted) {
setTimeout(() => {
- view.dispatch(tr);
+ editor.commands.command(replacePlaceholderWithAttachment(attachment));
}, 100);
} else {
- view.dispatch(tr);
+ editor
+ .chain()
+ .command(insertPlaceholder())
+ .command(replacePlaceholderWithAttachment(attachment))
+ .run();
}
+ } catch (error) {
+ clearTimeout(insertPlaceholderTimeout);
+
+ editor.commands.command(removePlaceholder());
}
};
diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts
index 34a8fe42..0e37e014 100644
--- a/packages/editor-ext/src/lib/attachment/attachment.ts
+++ b/packages/editor-ext/src/lib/attachment/attachment.ts
@@ -12,7 +12,7 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip
size?: number;
attachmentId?: string;
- placeholderId?: string;
+ placeholder?: string;
}
declare module "@tiptap/core" {
@@ -75,13 +75,9 @@ export const Attachment = Node.create({
"data-attachment-id": attributes.attachmentId,
}),
},
- placeholderId: {
+ placeholder: {
default: null,
- parseHTML: (element) =>
- element.getAttribute("data-attachment-placeholder-id"),
- renderHTML: (attributes: AttachmentAttributes) => ({
- "data-attachment-placeholder-id": attributes.placeholderId,
- }),
+ rendered: false,
},
};
},
diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts
index fd8eb1e2..d5acdcff 100644
--- a/packages/editor-ext/src/lib/image/image-upload.ts
+++ b/packages/editor-ext/src/lib/image/image-upload.ts
@@ -1,12 +1,9 @@
-import {
- imageDimensionsFromData,
- imageDimensionsFromStream,
-} from "image-dimensions";
+import { imageDimensionsFromStream } from "image-dimensions";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
-import { Transaction } from "@tiptap/pm/state";
+import { Command } from "@tiptap/core";
const findImageNodeByPlaceholderId = (
doc: Node,
@@ -18,7 +15,7 @@ const findImageNodeByPlaceholderId = (
if (result) return false;
if (
node.type.name === "image" &&
- node.attrs.placeholderId === placeholderId
+ node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
@@ -30,84 +27,118 @@ const findImageNodeByPlaceholderId = (
};
const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
- async (file, view, pos, pageId) => {
+ async (file, editor, pos, pageId) => {
// check if the file is an image
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
+ const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const placeholderId = generateNodeId();
const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: undefined;
- const initialPlaceholderNode = view.state.schema.nodes.image?.create({
- placeholderId,
- aspectRatio,
- });
- let tr: Transaction | null = view.state.tr;
- let placeholderShown = false;
+ let placeholderInserted = false;
- if (!initialPlaceholderNode) return;
+ editor.storage.shared.imagePreviews =
+ editor.storage.shared.imagePreviews || {};
+ editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
- const { parent } = tr.doc.resolve(pos);
- const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+ const insertPlaceholder = (): Command => {
+ return ({ tr, state }) => {
+ const initialPlaceholderNode = state.schema.nodes.image?.create({
+ placeholder: {
+ id: placeholderId,
+ name: file.name,
+ },
+ aspectRatio,
+ });
- if (isEmptyTextBlock) {
- // Replace e.g. empty paragraph with the image
- tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
- } else {
- tr.insert(pos, initialPlaceholderNode);
- }
+ if (!initialPlaceholderNode) return false;
+ 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);
+ }
+
+ return true;
+ };
+ };
+ const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ // If the placeholder is not found or attachment is missing, abort the process
+ if (currentPos === null || !attachment) return false;
+
+ // Update the placeholder node with the actual image data
+ tr.setNodeMarkup(currentPos, undefined, {
+ src: `/api/files/${attachment.id}/${attachment.fileName}`,
+ attachmentId: attachment.id,
+ size: attachment.fileSize,
+ aspectRatio,
+ });
+
+ return true;
+ };
+ };
+ const removePlaceholder = (): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ if (currentPos === null) return false;
+
+ // Remove the placeholder node
+ tr.delete(currentPos, currentPos + 2);
+
+ return true;
+ };
+ };
// Only show the placeholder if the upload takes more than 250ms
- const displayPlaceholderTimeout = setTimeout(() => {
- view.dispatch(tr);
- placeholderShown = true;
- tr = null;
+ const insertPlaceholderTimeout = setTimeout(() => {
+ editor.commands.command(insertPlaceholder());
+ placeholderInserted = true;
}, 250);
+ const disposePreviewFile = () => {
+ URL.revokeObjectURL(objectUrl);
+
+ if (editor.storage.shared.imagePreviews) {
+ delete editor.storage.shared.imagePreviews[placeholderId];
+ }
+ };
try {
const attachment: IAttachment = await onUpload(file, pageId);
- tr = tr ?? view.state.tr;
+ clearTimeout(insertPlaceholderTimeout);
- const { pos: currentPos = null } =
- findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
-
- // 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 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) {
- tr = tr ?? view.state.tr;
-
- const { pos: currentPos = null } =
- findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
-
- if (currentPos === null) return;
-
- // Delete the image placeholder on error
- tr.delete(currentPos, currentPos + 2);
- } finally {
- clearTimeout(displayPlaceholderTimeout);
-
- // If the placeholder was shown, delay showing the image to avoid flicker
- if (placeholderShown) {
+ if (placeholderInserted) {
setTimeout(() => {
- view.dispatch(tr);
+ editor.commands.command(replacePlaceholderWithImage(attachment));
+ disposePreviewFile();
}, 100);
} else {
- view.dispatch(tr);
+ editor
+ .chain()
+ .command(insertPlaceholder())
+ .command(replacePlaceholderWithImage(attachment))
+ .run();
+ disposePreviewFile();
}
+ } catch (error) {
+ clearTimeout(insertPlaceholderTimeout);
+
+ editor.commands.command(removePlaceholder());
+ disposePreviewFile();
}
};
diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts
index 05f76f14..e6426f23 100644
--- a/packages/editor-ext/src/lib/image/image.ts
+++ b/packages/editor-ext/src/lib/image/image.ts
@@ -9,13 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes {
src?: string;
alt?: string;
- title?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number;
aspectRatio?: number;
- placeholderId?: string;
+ placeholder?: {
+ id: string;
+ name: string;
+ };
}
declare module "@tiptap/core" {
@@ -98,7 +100,7 @@ export const TiptapImage = Image.extend({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
- placeholderId: {
+ placeholder: {
default: null,
rendered: false,
},
diff --git a/packages/editor-ext/src/lib/media-utils.ts b/packages/editor-ext/src/lib/media-utils.ts
index f05c4264..02a4a1d1 100644
--- a/packages/editor-ext/src/lib/media-utils.ts
+++ b/packages/editor-ext/src/lib/media-utils.ts
@@ -1,9 +1,8 @@
-import type { EditorView } from "@tiptap/pm/view";
-import { Transaction } from "@tiptap/pm/state";
+import { Editor } from "@tiptap/core";
export type UploadFn = (
file: File,
- view: EditorView,
+ editor: Editor,
pos: number,
pageId: string,
// only applicable to file attachments
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise;
}
-
-export function insertTrailingNode(
- tr: Transaction,
- pos: number,
- view: EditorView,
-) {
- // create trailing node after decoration
- // if decoration is at the last node
- const currentDocSize = view.state.doc.content.size;
- if (pos + 1 === currentDocSize) {
- tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
- }
-}
diff --git a/packages/editor-ext/src/lib/shared-storage/index.ts b/packages/editor-ext/src/lib/shared-storage/index.ts
new file mode 100644
index 00000000..5b486420
--- /dev/null
+++ b/packages/editor-ext/src/lib/shared-storage/index.ts
@@ -0,0 +1 @@
+export { SharedStorage } from "./shared-storage";
diff --git a/packages/editor-ext/src/lib/shared-storage/shared-storage.ts b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts
new file mode 100644
index 00000000..aa008d45
--- /dev/null
+++ b/packages/editor-ext/src/lib/shared-storage/shared-storage.ts
@@ -0,0 +1,17 @@
+import { Extension } from "@tiptap/core";
+
+declare module "@tiptap/core" {
+ interface Storage {
+ shared: Record;
+ }
+}
+
+const SharedStorage = Extension.create({
+ name: "shared",
+
+ addStorage() {
+ return {};
+ },
+});
+
+export { SharedStorage };
diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts
index 03e64f63..404cf99e 100644
--- a/packages/editor-ext/src/lib/video/video-upload.ts
+++ b/packages/editor-ext/src/lib/video/video-upload.ts
@@ -1,8 +1,8 @@
-import { Transaction } from "@tiptap/pm/state";
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
+import { Command } from "@tiptap/core";
const findVideoNodeByPlaceholderId = (
doc: Node,
@@ -15,7 +15,7 @@ const findVideoNodeByPlaceholderId = (
if (
node.type.name === "video" &&
- node.attrs.placeholderId === placeholderId
+ node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
@@ -52,7 +52,7 @@ const getVideoDimensions = (
};
const handleVideoUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
- async (file, view, pos, pageId) => {
+ async (file, editor, pos, pageId) => {
// check if the file is valid
const validated = validateFn?.(file);
// @ts-ignore
@@ -62,84 +62,107 @@ const handleVideoUpload =
const videoDimensions = await getVideoDimensions(objectUrl);
const placeholderId = generateNodeId();
const aspectRatio = videoDimensions.aspectRatio;
- const initialPlaceholderNode = view.state.schema.nodes.video?.create({
- placeholderId,
- aspectRatio,
- });
- let tr: Transaction | null = view.state.tr;
- let placeholderShown = false;
+ let placeholderInserted = false;
- if (!initialPlaceholderNode) {
- URL.revokeObjectURL(objectUrl);
- return;
- }
+ editor.storage.shared.videoPreviews =
+ editor.storage.shared.videoPreviews || {};
+ editor.storage.shared.videoPreviews[placeholderId] = objectUrl;
- const { parent } = tr.doc.resolve(pos);
- const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+ const insertPlaceholder = (): Command => {
+ return ({ tr, state }) => {
+ const initialPlaceholderNode = state.schema.nodes.video?.create({
+ placeholder: {
+ id: placeholderId,
+ name: file.name,
+ },
+ aspectRatio,
+ });
- if (isEmptyTextBlock) {
- // Replace e.g. empty paragraph with the video
- tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
- } else {
- tr.insert(pos, initialPlaceholderNode);
- }
+ if (!initialPlaceholderNode) return false;
+
+ const { parent } = tr.doc.resolve(pos);
+ const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
+
+ if (isEmptyTextBlock) {
+ // Replace e.g. empty paragraph with the video
+ tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
+ } else {
+ tr.insert(pos, initialPlaceholderNode);
+ }
+
+ return true;
+ };
+ };
+ const replacePlaceholderWithVideo = (attachment: IAttachment): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ // 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 video data
+ tr.setNodeMarkup(currentPos, undefined, {
+ src: `/api/files/${attachment.id}/${attachment.fileName}`,
+ attachmentId: attachment.id,
+ title: attachment.fileName,
+ size: attachment.fileSize,
+ aspectRatio,
+ });
+
+ return true;
+ };
+ };
+ const removePlaceholder = (): Command => {
+ return ({ tr }) => {
+ const { pos: currentPos = null } =
+ findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
+
+ if (currentPos === null) return false;
+
+ tr.delete(currentPos, currentPos + 2);
+
+ return true;
+ };
+ };
// Only show the placeholder if the upload takes more than 250ms
- const displayPlaceholderTimeout = setTimeout(() => {
- view.dispatch(tr);
- placeholderShown = true;
- tr = null;
+ const insertPlaceholderTimeout = setTimeout(() => {
+ editor.commands.command(insertPlaceholder());
+ placeholderInserted = true;
}, 250);
+ const disposePreviewFile = () => {
+ URL.revokeObjectURL(objectUrl);
+
+ if (editor.storage.shared.videoPreviews) {
+ delete editor.storage.shared.videoPreviews[placeholderId];
+ }
+ };
try {
const attachment: IAttachment = await onUpload(file, pageId);
- tr = tr ?? view.state.tr;
+ clearTimeout(insertPlaceholderTimeout);
- const { pos: currentPos = null } =
- findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
-
- // 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 video data
- tr.setNodeMarkup(currentPos, undefined, {
- src: `/api/files/${attachment.id}/${attachment.fileName}`,
- attachmentId: attachment.id,
- title: attachment.fileName,
- size: attachment.fileSize,
- aspectRatio,
- });
- } catch (error) {
- tr = tr ?? view.state.tr;
-
- const { pos: currentPos = null } =
- findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
-
- if (currentPos === null) return;
-
- // Delete the video placeholder on error
- tr.delete(
- currentPos,
- currentPos + (initialPlaceholderNode.nodeSize ?? 2),
- );
- } finally {
- clearTimeout(displayPlaceholderTimeout);
-
- const dispatchFinal = () => {
- view.dispatch(tr);
- URL.revokeObjectURL(objectUrl);
- };
-
- // If the placeholder was shown, delay showing the video to avoid flicker
- if (placeholderShown) {
+ if (placeholderInserted) {
setTimeout(() => {
- dispatchFinal();
+ editor.commands.command(replacePlaceholderWithVideo(attachment));
+ disposePreviewFile();
}, 100);
} else {
- dispatchFinal();
+ editor
+ .chain()
+ .command(insertPlaceholder())
+ .command(replacePlaceholderWithVideo(attachment))
+ .run();
+ disposePreviewFile();
}
+ } catch (error) {
+ clearTimeout(insertPlaceholderTimeout);
+
+ editor.commands.command(removePlaceholder());
+ disposePreviewFile();
}
};
diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts
index 0f56e75d..31c68f89 100644
--- a/packages/editor-ext/src/lib/video/video.ts
+++ b/packages/editor-ext/src/lib/video/video.ts
@@ -7,13 +7,15 @@ export interface VideoOptions {
}
export interface VideoAttributes {
src?: string;
- title?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number;
aspectRatio?: number;
- placeholderId?: string;
+ placeholder?: {
+ id: string;
+ name: string;
+ };
}
declare module "@tiptap/core" {
@@ -89,7 +91,7 @@ export const TiptapVideo = Node.create({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
- placeholderId: {
+ placeholder: {
default: null,
rendered: false,
},