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 && ( {title} )} + {!src && previewSrc && ( + + {placeholder?.name} + + + )} + {!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, },