diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 8cb33378..0cdfbee0 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -328,6 +328,8 @@ "Upload any image from your device.": "Upload any image from your device.", "Upload any video from your device.": "Upload any video from your device.", "Upload any file from your device.": "Upload any file from your device.", + "Uploading {{name}}": "Uploading {{name}}", + "Uploading file": "Uploading file", "Table": "Table", "Insert a table.": "Insert a table.", "Insert collapsible block.": "Insert collapsible block.", 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 d3858520..e3281e64 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -1,11 +1,13 @@ 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"; import { formatBytes } from "@/lib"; +import { useTranslation } from "react-i18next"; export default function AttachmentView(props: NodeViewProps) { + const { t } = useTranslation(); const { node, selected } = props; const { url, name, size } = node.attrs; const { hovered, ref } = useHover(); @@ -20,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) { wrap="nowrap" h={25} > - - + + {url ? ( + + ) : ( + + )} - - {name} + + {url ? name : t("Uploading {{name}}", { name })} - + {formatBytes(size)} - {selected || hovered ? ( + {url && (selected || hovered) && ( - ) : ( - "" )} 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-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index 6f2c9b9c..a1699f93 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { return false; } - return editor.isActive("image"); + return editor.isActive("image") && editor.getAttributes("image").src; }, [editor], ); 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 new file mode 100644 index 00000000..5d02184b --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -0,0 +1,27 @@ +.imageWrapper { + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 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 dbdb8396..defb64c4 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -1,30 +1,70 @@ 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"; +import { useTranslation } from "react-i18next"; export default function ImageView(props: NodeViewProps) { - const { node, selected } = props; - const { src, width, align, title } = node.attrs; - + const { t } = useTranslation(); + 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 ( - {title} +
+ {src && ( + {title} + )} + {!src && previewSrc && ( + + {placeholder?.name} + + + )} + {!src && !previewSrc && ( + + + + {placeholder?.name + ? t("Uploading {{name}}", { name: placeholder.name }) + : t("Uploading file")} + + + )} +
); } 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 362c1686..bebefed4 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 @@ -174,9 +174,13 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (input.files?.length) { for (const file of input.files) { const pos = editor.view.state.selection.from; - uploadImageAction(file, editor.view, pos, pageId); + + uploadImageAction(file, editor, pos, pageId); } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, @@ -197,12 +201,18 @@ 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; - uploadVideoAction(file, editor.view, pos, pageId); + for (const file of input.files) { + const pos = editor.view.state.selection.from; + + uploadVideoAction(file, editor, pos, pageId); + } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, @@ -223,12 +233,18 @@ 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, true); + } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/apps/client/src/features/editor/components/video/video-menu.tsx b/apps/client/src/features/editor/components/video/video-menu.tsx index 57a012a8..dfece398 100644 --- a/apps/client/src/features/editor/components/video/video-menu.tsx +++ b/apps/client/src/features/editor/components/video/video-menu.tsx @@ -20,7 +20,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { const editorState = useEditorState({ editor, - selector: ctx => { + selector: (ctx) => { if (!ctx.editor) { return null; } @@ -43,7 +43,7 @@ export function VideoMenu({ editor }: EditorMenuProps) { return false; } - return editor.isActive("video"); + return editor.isActive("video") && editor.getAttributes("video").src; }, [editor], ); 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 new file mode 100644 index 00000000..c0e7f99d --- /dev/null +++ b/apps/client/src/features/editor/components/video/video-view.module.css @@ -0,0 +1,33 @@ +.videoWrapper { + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + + @mixin light { + background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%); + background-size: 400% 400%; + } + + @mixin dark { + background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%); + background-size: 400% 400%; + } + + @keyframes pulse { + 0% { + background-position: 0% 0%; + } + 100% { + background-position: -135% 0%; + } + } +} +.video { + display: block; + width: 100%; + height: 100%; + border-radius: 8px; +} 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 d47d9a4a..e2473afc 100644 --- a/apps/client/src/features/editor/components/video/video-view.tsx +++ b/apps/client/src/features/editor/components/video/video-view.tsx @@ -1,29 +1,75 @@ 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"; +import { useTranslation } from "react-i18next"; export default function VideoView(props: NodeViewProps) { - const { node, selected } = props; - const { src, width, align } = node.attrs; - + const { t } = useTranslation(); + 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 ( - ); } diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index cb7e1290..7de38464 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..da8bd84a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -16,6 +16,7 @@ import { onSyncedParameters, } from "@hocuspocus/provider"; import { + Editor, EditorContent, EditorProvider, useEditor, @@ -79,7 +80,7 @@ export default function PageEditor({ }: PageEditorProps) { const collaborationURL = useCollaborationUrl(); const isComponentMounted = useRef(false); - const editorCreated = useRef(false); + const editorRef = useRef(null); useEffect(() => { isComponentMounted.current = true; @@ -93,7 +94,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(); @@ -104,8 +105,8 @@ export default function PageEditor({ const userPageEditMode = currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; const canScroll = useCallback( - () => isComponentMounted.current && editorCreated.current, - [isComponentMounted, editorCreated] + () => Boolean(isComponentMounted.current && editorRef.current), + [isComponentMounted], ); const { handleScrollTo } = useEditorScroll({ canScroll }); // Providers only created once per pageId @@ -253,10 +254,21 @@ 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) => { + if (!editorRef.current) return false; + + return handlePaste( + editorRef.current, + event, + pageId, + currentUser?.user.id, + ); + }, + handleDrop: (_view, event, _slice, moved) => { + if (!editorRef.current) return false; + + return handleFileDrop(editorRef.current, event, moved, pageId); + }, }, onCreate({ editor }) { if (editor) { @@ -265,7 +277,7 @@ export default function PageEditor({ // @ts-ignore editor.storage.pageId = pageId; handleScrollTo(editor); - editorCreated.current = true; + editorRef.current = editor; } }, onUpdate({ editor }) { @@ -275,7 +287,7 @@ export default function PageEditor({ debouncedUpdateContent(editorJson); }, }, - [pageId, editable, extensions] + [pageId, editable, extensions], ); const editorIsEditable = useEditorState({ @@ -320,7 +332,7 @@ export default function PageEditor({ return () => { document.removeEventListener( "ACTIVE_COMMENT_EVENT", - handleActiveCommentEvent + handleActiveCommentEvent, ); }; }, []); diff --git a/package.json b/package.json index 9875871b..bffe70ca 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "dompurify": "^3.2.6", "fractional-indexing-jittered": "^1.0.0", "highlight.js": "^11.11.1", + "image-dimensions": "^2.5.0", "ioredis": "^5.4.1", "jszip": "^3.10.1", "linkifyjs": "^4.3.2", 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 0d2ac6c7..a3446db9 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -1,126 +1,125 @@ -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"; +import { Command } from "@tiptap/core"; -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.placeholder?.id === 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) => { + async (file, editor, 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(); + const placeholderId = generateNodeId(); - tr.setMeta(uploadKey, { - add: { - id, - pos, - fileName: file.name, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.attachment?.create({ + placeholder: { + id: placeholderId, + }, + name: file.name, + size: file.size, + }); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + if (!initialPlaceholderNode) return false; - 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; + return true; + }; + }; + const replacePlaceholderWithAttachment = ( + attachment: IAttachment, + ): Command => { + return ({ tr }) => { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const node = schema.nodes.attachment?.create({ + // 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, }); - if (!node) return; - 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); - }, - ); + 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 insertPlaceholderTimeout = setTimeout(() => { + editor.commands.command(insertPlaceholder()); + placeholderInserted = true; + }, 250); + + try { + const attachment: IAttachment = await onUpload(file, pageId); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithAttachment(attachment)); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithAttachment(attachment)) + .run(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + } }; + +export { handleAttachmentUpload }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index bd1814f5..0e37e014 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; + placeholder?: string; } declare module "@tiptap/core" { @@ -75,6 +75,10 @@ export const Attachment = Node.create({ "data-attachment-id": attributes.attachmentId, }), }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -92,7 +96,7 @@ export const Attachment = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "a", @@ -125,12 +129,4 @@ export const Attachment = Node.create({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - AttachmentUploadPlugin({ - placeholderClass: "attachment-placeholder", - }), - ]; - }, }); diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 9a759903..d5acdcff 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,127 +1,145 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils"; +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 { Command } from "@tiptap/core"; -const uploadKey = new PluginKey("image-upload"); +const findImageNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const ImageUploadPlugin = ({ - 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, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", placeholderClass); - image.src = src; - placeholder.appendChild(image); - const deco = Decoration.widget(pos + 1, 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 === "image" && + node.attrs.placeholder?.id === 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 handleImageUpload = + return result; +}; +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; - // A fresh object to act as the ID for this upload - const id = {}; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - // Replace the selection with a placeholder - if (!tr.selection.empty) tr.deleteSelection(); + const objectUrl = URL.createObjectURL(file); + const imageDimensions = await imageDimensionsFromStream(file.stream()); + const placeholderId = generateNodeId(); + const aspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : undefined; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + let placeholderInserted = false; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + editor.storage.shared.imagePreviews = + editor.storage.shared.imagePreviews || {}; + editor.storage.shared.imagePreviews[placeholderId] = objectUrl; + + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.image?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + 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) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return false; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.image?.create({ + // 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, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + 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 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); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithImage(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithImage(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleImageUpload }; diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index cc8ba220..e6426f23 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,7 +1,6 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { ImageUploadPlugin } from "./image-upload"; import { mergeAttributes, Range } from "@tiptap/core"; export interface ImageOptions extends DefaultImageOptions { @@ -10,11 +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; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -22,7 +25,7 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range } + attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; @@ -90,6 +93,17 @@ export const TiptapImage = Image.extend({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ImageAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -140,12 +154,4 @@ export const TiptapImage = Image.extend({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - ImageUploadPlugin({ - placeholderClass: "image-upload", - }), - ]; - }, }); 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 1e976ecc..404cf99e 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -1,132 +1,169 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +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 uploadKey = new PluginKey("video-upload"); +const findVideoNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const VideoUploadPlugin = ({ - 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, src } = action.add; + doc.descendants((node, pos) => { + if (result) return false; - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "video-placeholder"); - const video = document.createElement("video"); - video.setAttribute("class", placeholderClass); - video.src = src; - placeholder.appendChild(video); - const deco = Decoration.widget(pos + 1, 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); - }, - }, + if ( + node.type.name === "video" && + node.attrs.placeholder?.id === 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; -} + return result; +}; +const getVideoDimensions = ( + url: string, +): Promise< + { width: number; height: number; aspectRatio: number } | undefined +> => { + return new Promise< + { width: number; height: number; aspectRatio: number } | undefined + >((resolve) => { + const video = document.createElement("video"); -export const handleVideoUpload = + video.preload = "metadata"; + video.onloadedmetadata = () => { + const width = video.videoWidth; + const height = video.videoHeight; + const aspectRatio = height > 0 ? width / height : 1; + + resolve({ width, height, aspectRatio }); + }; + video.onerror = () => { + resolve(undefined); + }; + video.src = url; + }); +}; +const handleVideoUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => - async (file, view, pos, pageId) => { - // check if the file is an image + async (file, editor, pos, pageId) => { + // check if the file is valid const validated = validateFn?.(file); // @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 objectUrl = URL.createObjectURL(file); + const videoDimensions = await getVideoDimensions(objectUrl); + const placeholderId = generateNodeId(); + const aspectRatio = videoDimensions.aspectRatio; - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + let placeholderInserted = false; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + editor.storage.shared.videoPreviews = + editor.storage.shared.videoPreviews || {}; + editor.storage.shared.videoPreviews[placeholderId] = objectUrl; - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + const insertPlaceholder = (): Command => { + return ({ tr, state }) => { + const initialPlaceholderNode = state.schema.nodes.video?.create({ + placeholder: { + id: placeholderId, + name: file.name, + }, + aspectRatio, + }); + + 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) || {}; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - const pos = findPlaceholder(view.state, id); - - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) return; - - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - - if (!attachment) return; - - const node = schema.nodes.video?.create({ + // 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, }); - if (!node) return; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the image placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + 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 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); + + clearTimeout(insertPlaceholderTimeout); + + if (placeholderInserted) { + setTimeout(() => { + editor.commands.command(replacePlaceholderWithVideo(attachment)); + disposePreviewFile(); + }, 100); + } else { + editor + .chain() + .command(insertPlaceholder()) + .command(replacePlaceholderWithVideo(attachment)) + .run(); + disposePreviewFile(); + } + } catch (error) { + clearTimeout(insertPlaceholderTimeout); + + editor.commands.command(removePlaceholder()); + disposePreviewFile(); + } }; + +export { handleVideoUpload }; diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 40f6db32..31c68f89 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,6 +1,5 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; -import { VideoUploadPlugin } from "./video-upload"; -import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core"; +import { Range, Node } from "@tiptap/core"; export interface VideoOptions { view: any; @@ -8,11 +7,15 @@ export interface VideoOptions { } export interface VideoAttributes { src?: string; - title?: string; align?: string; attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholder?: { + id: string; + name: string; + }; } declare module "@tiptap/core" { @@ -20,7 +23,7 @@ declare module "@tiptap/core" { videoBlock: { setVideo: (attributes: VideoAttributes) => ReturnType; setVideoAt: ( - attributes: VideoAttributes & { pos: number | Range } + attributes: VideoAttributes & { pos: number | Range }, ) => ReturnType; setVideoAlign: (align: "left" | "center" | "right") => ReturnType; setVideoWidth: (width: number) => ReturnType; @@ -81,6 +84,17 @@ export const TiptapVideo = Node.create({ "data-align": attributes.align, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: VideoAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholder: { + default: null, + rendered: false, + }, }; }, @@ -131,12 +145,4 @@ export const TiptapVideo = Node.create({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - VideoUploadPlugin({ - placeholderClass: "video-upload", - }), - ]; - }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eea76ecb..f5fa82bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + image-dimensions: + specifier: ^2.5.0 + version: 2.5.0 ioredis: specifier: ^5.4.1 version: 5.4.1 @@ -6878,6 +6881,11 @@ packages: image-blob-reduce@3.0.1: resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + image-dimensions@2.5.0: + resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==} + engines: {node: '>=18'} + hasBin: true + image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -17744,6 +17752,8 @@ snapshots: dependencies: pica: 7.1.1 + image-dimensions@2.5.0: {} + image-size@0.5.5: optional: true