From 5bda5623f26ed728836af6fc40c309f99992ff4a Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 16:54:24 +0100 Subject: [PATCH 01/11] feat: Improved placeholder and upload handling for images --- .../editor/components/image/image-menu.tsx | 2 +- .../components/image/image-view.module.css | 10 + .../editor/components/image/image-view.tsx | 27 ++- .../components/slash-menu/menu-items.ts | 4 + package.json | 1 + .../editor-ext/src/lib/image/image-upload.ts | 188 ++++++++---------- packages/editor-ext/src/lib/image/image.ts | 21 +- pnpm-lock.yaml | 10 + 8 files changed, 137 insertions(+), 126 deletions(-) create mode 100644 apps/client/src/features/editor/components/image/image-view.module.css 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..8ee9bc06 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-view.module.css @@ -0,0 +1,10 @@ +.imagePlaceholder { + border-radius: 8px; + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} 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..8fdd434d 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -3,11 +3,11 @@ 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 } = node.attrs; - + const { src, width, align, title, aspectRatio } = node.attrs; const alignClass = useMemo(() => { if (align === "left") return "alignLeft"; if (align === "right") return "alignRight"; @@ -17,14 +17,21 @@ export default function ImageView(props: NodeViewProps) { return ( - {title} +
+ {src && ( + {title} + )} +
); } 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..049e1503 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); } } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/package.json b/package.json index 86333599..4ddc8a85 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,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/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 9a759903..e2494651 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,127 +1,105 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils"; +import { imageDimensionsFromData } from "image-dimensions"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; +import { Node } from "@tiptap/pm/model"; -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.placeholderId === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleImageUpload = + return result; +}; +const handleImageUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => async (file, view, 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 imageDimensions = imageDimensionsFromData(await file.bytes()); + const placeholderId = generateNodeId(); + const aspectRatio = imageDimensions + ? imageDimensions.width / imageDimensions.height + : undefined; + const initialPlaceholderNode = view.state.schema.nodes.image?.create({ + placeholderId, + aspectRatio, + }); - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + let placeholderShown = false; + let tr = view.state.tr; - insertTrailingNode(tr, pos, view); + if (!initialPlaceholderNode) return; + + 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); + } + + // Only show the placeholder if the upload takes more than 250ms + const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); - }; + placeholderShown = true; + tr = view.state.tr; + }, 250); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + try { + const attachment: IAttachment = await onUpload(file, pageId); + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const pos = findPlaceholder(view.state, id); + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) 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) { + const { pos: currentPos = null } = + findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder + if (currentPos === null) return; - if (!attachment) return; + // Delete the image placeholder on error + tr.delete(currentPos, currentPos + 2); + } finally { + clearTimeout(displayPlaceholderTimeout); - const node = schema.nodes.image?.create({ - src: `/api/files/${attachment.id}/${attachment.fileName}`, - attachmentId: attachment.id, - title: attachment.fileName, - size: attachment.fileSize, - }); - 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); - }, - ); + // If the placeholder was shown, delay showing the image to avoid flicker + if (placeholderShown) { + setTimeout(() => { + view.dispatch(tr); + }, 100); + } else { + view.dispatch(tr); + } + } }; + +export { handleImageUpload }; diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index cc8ba220..8ee2230d 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 { @@ -15,6 +14,8 @@ export interface ImageAttributes { attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholderId?: string; } declare module "@tiptap/core" { @@ -22,7 +23,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 +91,14 @@ export const TiptapImage = Image.extend({ "data-size": attributes.size, }), }, + placeholderId: { + default: null, + rendered: false, + }, + aspectRatio: { + default: null, + rendered: false, + }, }; }, @@ -140,12 +149,4 @@ export const TiptapImage = Image.extend({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - ImageUploadPlugin({ - placeholderClass: "image-upload", - }), - ]; - }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e42353c3..7a6d7756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,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 @@ -6839,6 +6842,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'} @@ -17647,6 +17655,8 @@ snapshots: dependencies: pica: 7.1.1 + image-dimensions@2.5.0: {} + image-size@0.5.5: optional: true From 0c5c83a17acec758dd8a85f4f89d9d7b4c69b89a Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:14:48 +0100 Subject: [PATCH 02/11] feat: Improved placeholder and upload handling for videos --- .../components/slash-menu/menu-items.ts | 4 + .../editor/components/video/video-menu.tsx | 4 +- .../components/video/video-view.module.css | 15 ++ .../editor/components/video/video-view.tsx | 32 ++- .../editor-ext/src/lib/video/video-upload.ts | 222 +++++++++--------- packages/editor-ext/src/lib/video/video.ts | 26 +- 6 files changed, 173 insertions(+), 130 deletions(-) create mode 100644 apps/client/src/features/editor/components/video/video-view.module.css 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 049e1503..11a88925 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 @@ -205,8 +205,12 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (input.files?.length) { const file = input.files[0]; const pos = editor.view.state.selection.from; + uploadVideoAction(file, editor.view, pos, pageId); } + + // 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..8e6b2f63 --- /dev/null +++ b/apps/client/src/features/editor/components/video/video-view.module.css @@ -0,0 +1,15 @@ +.videoWrapper { + border-radius: 8px; + @mixin light { + background-color: var(--mantine-color-gray-0); + } + + @mixin dark { + background-color: var(--mantine-color-dark-7); + } +} +.video { + display: block; + width: 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..13a9d02d 100644 --- a/apps/client/src/features/editor/components/video/video-view.tsx +++ b/apps/client/src/features/editor/components/video/video-view.tsx @@ -2,11 +2,11 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; 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 } = node.attrs; - + const { src, width, align, aspectRatio } = node.attrs; const alignClass = useMemo(() => { if (align === "left") return "alignLeft"; if (align === "right") return "alignRight"; @@ -16,14 +16,26 @@ export default function VideoView(props: NodeViewProps) { return ( - ); } diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts index 1e976ecc..c3d9a9d4 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -1,132 +1,140 @@ -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"; -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.placeholderId === placeholderId + ) { + result = { node, pos }; + return false; + } + + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} + 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 + // 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 initialPlaceholderNode = view.state.schema.nodes.video?.create({ + placeholderId, + aspectRatio, + }); - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); + let placeholderShown = false; + let tr = view.state.tr; - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); + if (!initialPlaceholderNode) { + URL.revokeObjectURL(objectUrl); + return; + } - insertTrailingNode(tr, pos, view); + 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); + } + + // Only show the placeholder if the upload takes more than 250ms + const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); - }; + placeholderShown = true; + tr = view.state.tr; + }, 250); - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + try { + const attachment: IAttachment = await onUpload(file, pageId); + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const pos = findPlaceholder(view.state, id); + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; - // If the content around the placeholder has been deleted, drop - // the image - if (pos == null) 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) { + const { pos: currentPos = null } = + findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder + if (currentPos === null) return; - if (!attachment) return; + // Delete the video placeholder on error + tr.delete( + currentPos, + currentPos + (initialPlaceholderNode.nodeSize ?? 2), + ); + } finally { + clearTimeout(displayPlaceholderTimeout); - const node = schema.nodes.video?.create({ - src: `/api/files/${attachment.id}/${attachment.fileName}`, - attachmentId: attachment.id, - title: attachment.fileName, - size: attachment.fileSize, - }); - if (!node) return; + const dispatchFinal = () => { + view.dispatch(tr); + URL.revokeObjectURL(objectUrl); + }; - 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); - }, - ); + // If the placeholder was shown, delay showing the video to avoid flicker + if (placeholderShown) { + setTimeout(() => { + dispatchFinal(); + }, 100); + } else { + dispatchFinal(); + } + } }; + +export { handleVideoUpload }; diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 40f6db32..0f56e75d 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; @@ -13,6 +12,8 @@ export interface VideoAttributes { attachmentId?: string; size?: number; width?: number; + aspectRatio?: number; + placeholderId?: string; } declare module "@tiptap/core" { @@ -20,7 +21,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 +82,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, + }), + }, + placeholderId: { + default: null, + rendered: false, + }, }; }, @@ -131,12 +143,4 @@ export const TiptapVideo = Node.create({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - VideoUploadPlugin({ - placeholderClass: "video-upload", - }), - ]; - }, }); From 13c29545a292fc48ddc75d6d95b45103f498aefb Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:15:14 +0100 Subject: [PATCH 03/11] refactor: Image node and view clean-up --- .../editor/components/image/image-view.module.css | 2 +- .../features/editor/components/image/image-view.tsx | 4 ++-- packages/editor-ext/src/lib/image/image.ts | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) 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 8ee9bc06..67a3629b 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,4 @@ -.imagePlaceholder { +.imageWrapper { 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 8fdd434d..0dac0966 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -19,8 +19,8 @@ export default function ImageView(props: NodeViewProps) {
({ "data-size": attributes.size, }), }, - placeholderId: { - default: null, - rendered: false, - }, aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ImageAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, + placeholderId: { default: null, rendered: false, }, From dd8c42e1f3506a8054081009513cd7727055996e Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:45:24 +0100 Subject: [PATCH 04/11] feat: Improved placeholder and upload handling for attachments --- .../components/attachment/attachment-view.tsx | 10 +- .../components/slash-menu/menu-items.ts | 3 + .../src/lib/attachment/attachment-upload.ts | 188 ++++++++---------- .../src/lib/attachment/attachment.ts | 20 +- 4 files changed, 99 insertions(+), 122 deletions(-) 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..48ab6cd6 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -1,5 +1,5 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; -import { Group, Text, Paper, ActionIcon } from "@mantine/core"; +import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core"; import { getFileUrl } from "@/lib/config.ts"; import { IconDownload, IconPaperclip } from "@tabler/icons-react"; import { useHover } from "@mantine/hooks"; @@ -21,10 +21,10 @@ export default function AttachmentView(props: NodeViewProps) { h={25} > - + {url ? : } - {name} + {url ? name : `Uploading ${name}...`} @@ -32,14 +32,12 @@ export default function AttachmentView(props: NodeViewProps) { - {selected || hovered ? ( + {url && (selected || hovered) && ( - ) : ( - "" )} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 11a88925..e3bf5622 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -237,6 +237,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { const pos = editor.view.state.selection.from; uploadAttachmentAction(file, editor.view, pos, pageId, true); } + + // Reset the input value to allow uploading the same file again if needed + input.value = ""; }; input.click(); }, diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts index 0d2ac6c7..e50be56e 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -1,126 +1,102 @@ -import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { - insertTrailingNode, - MediaUploadOptions, - UploadFn, -} from "../media-utils"; +import { Node } from "@tiptap/pm/model"; +import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; +import { generateNodeId } from "../utils"; -const uploadKey = new PluginKey("attachment-upload"); +const findAttachmentNodeByPlaceholderId = ( + doc: Node, + placeholderId: string, +): { node: Node; pos: number } | null => { + let result: { node: Node; pos: number } | null = null; -export const AttachmentUploadPlugin = ({ - placeholderClass, -}: { - placeholderClass: string; -}) => - new Plugin({ - key: uploadKey, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - //@-ts-expect-error - not yet sure what the type I need here - const action = tr.getMeta(this); - if (action?.add) { - const { id, pos, fileName } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", placeholderClass); - - const uploadingText = document.createElement("span"); - uploadingText.setAttribute("class", "uploading-text"); - uploadingText.textContent = `Uploading ${fileName}`; - - placeholder.appendChild(uploadingText); - - const realPos = pos + 1; - const deco = Decoration.widget(realPos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action?.remove) { - set = set.remove( - set.find( - undefined, - undefined, - (spec) => spec.id == action.remove.id, - ), - ); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, + doc.descendants((node, pos) => { + if (result) return false; + if ( + node.type.name === "attachment" && + node.attrs.placeholderId === placeholderId + ) { + result = { node, pos }; + return false; + } + return true; }); -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state) as DecorationSet; - const found = decos.find(undefined, undefined, (spec) => spec.id == id); - return found.length ? found[0]?.from : null; -} - -export const handleAttachmentUpload = + return result; +}; +const handleAttachmentUpload = ({ validateFn, onUpload }: MediaUploadOptions): UploadFn => async (file, view, pos, pageId, allowMedia) => { const validated = validateFn?.(file, allowMedia); // @ts-ignore if (!validated) return; - // A fresh object to act as the ID for this upload - const id = {}; - // Replace the selection with a placeholder - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); - - tr.setMeta(uploadKey, { - add: { - id, - pos, - fileName: file.name, - }, + const placeholderId = generateNodeId(); + const initialPlaceholderNode = view.state.schema.nodes.attachment?.create({ + placeholderId, + name: file.name, + size: file.size, }); - insertTrailingNode(tr, pos, view); - view.dispatch(tr); + let placeholderShown = false; + let tr = view.state.tr; - await onUpload(file, pageId).then( - (attachment: IAttachment) => { - const { schema } = view.state; + if (!initialPlaceholderNode) return; - const pos = findPlaceholder(view.state, id); + const { parent } = tr.doc.resolve(pos); + const isEmptyTextBlock = parent.isTextblock && !parent.childCount; - if (pos == null) return; + if (isEmptyTextBlock) { + tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); + } else { + tr.insert(pos, initialPlaceholderNode); + } - if (!attachment) return; + // Only show the placeholder if the upload takes more than 250ms + const displayPlaceholderTimeout = setTimeout(() => { + view.dispatch(tr); + placeholderShown = true; + tr = view.state.tr; + }, 250); - const node = schema.nodes.attachment?.create({ - url: `/api/files/${attachment.id}/${attachment.fileName}`, - name: attachment.fileName, - mime: attachment.mimeType, - size: attachment.fileSize, - attachmentId: attachment.id, - }); - if (!node) return; + try { + const attachment: IAttachment = await onUpload(file, pageId); + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; - const transaction = view.state.tr - .replaceWith(pos, pos, node) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - () => { - // Deletes the placeholder on error - const transaction = view.state.tr - .delete(pos, pos) - .setMeta(uploadKey, { remove: { id } }); - view.dispatch(transaction); - }, - ); + // If the placeholder is not found or attachment is missing, abort the process + if (currentPos === null || !attachment) return; + + // Update the placeholder node with the actual attachment data + tr.setNodeMarkup(currentPos, undefined, { + url: `/api/files/${attachment.id}/${attachment.fileName}`, + name: attachment.fileName, + mime: attachment.mimeType, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + } catch (error) { + const { pos: currentPos = null } = + findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; + + if (currentPos === null) return; + + // Delete the placeholder on error + tr.delete( + currentPos, + currentPos + (initialPlaceholderNode.nodeSize ?? 1), + ); + } finally { + clearTimeout(displayPlaceholderTimeout); + + // If the placeholder was shown, delay showing the attachment to avoid flicker + if (placeholderShown) { + setTimeout(() => { + view.dispatch(tr); + }, 100); + } else { + view.dispatch(tr); + } + } }; + +export { handleAttachmentUpload }; diff --git a/packages/editor-ext/src/lib/attachment/attachment.ts b/packages/editor-ext/src/lib/attachment/attachment.ts index bd1814f5..34a8fe42 100644 --- a/packages/editor-ext/src/lib/attachment/attachment.ts +++ b/packages/editor-ext/src/lib/attachment/attachment.ts @@ -1,6 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { AttachmentUploadPlugin } from "./attachment-upload"; export interface AttachmentOptions { HTMLAttributes: Record; @@ -13,6 +12,7 @@ export interface AttachmentAttributes { mime?: string; // e.g. application/zip size?: number; attachmentId?: string; + placeholderId?: string; } declare module "@tiptap/core" { @@ -75,6 +75,14 @@ export const Attachment = Node.create({ "data-attachment-id": attributes.attachmentId, }), }, + placeholderId: { + default: null, + parseHTML: (element) => + element.getAttribute("data-attachment-placeholder-id"), + renderHTML: (attributes: AttachmentAttributes) => ({ + "data-attachment-placeholder-id": attributes.placeholderId, + }), + }, }; }, @@ -92,7 +100,7 @@ export const Attachment = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "a", @@ -125,12 +133,4 @@ export const Attachment = Node.create({ return ReactNodeViewRenderer(this.options.view); }, - - addProseMirrorPlugins() { - return [ - AttachmentUploadPlugin({ - placeholderClass: "attachment-placeholder", - }), - ]; - }, }); From b9b5ddb8481b16547d95ef5be6c611d321af06b6 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:45:45 +0100 Subject: [PATCH 05/11] fix: Video view styles --- .../src/features/editor/components/video/video-view.module.css | 1 + 1 file changed, 1 insertion(+) 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 8e6b2f63..fdb959dd 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 @@ -11,5 +11,6 @@ .video { display: block; width: 100%; + height: 100%; border-radius: 8px; } From b99d803b81a2bae5921057b1cb61bf149fad6661 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 20:53:59 +0100 Subject: [PATCH 06/11] fix: Transaction handling on asset upload --- .../editor-ext/src/lib/attachment/attachment-upload.ts | 10 ++++++++-- packages/editor-ext/src/lib/image/image-upload.ts | 10 ++++++++-- packages/editor-ext/src/lib/video/video-upload.ts | 10 ++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/editor-ext/src/lib/attachment/attachment-upload.ts b/packages/editor-ext/src/lib/attachment/attachment-upload.ts index e50be56e..cdc78790 100644 --- a/packages/editor-ext/src/lib/attachment/attachment-upload.ts +++ b/packages/editor-ext/src/lib/attachment/attachment-upload.ts @@ -2,6 +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"; const findAttachmentNodeByPlaceholderId = ( doc: Node, @@ -37,8 +38,8 @@ const handleAttachmentUpload = size: file.size, }); + let tr: Transaction | null = view.state.tr; let placeholderShown = false; - let tr = view.state.tr; if (!initialPlaceholderNode) return; @@ -55,11 +56,14 @@ const handleAttachmentUpload = const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); placeholderShown = true; - tr = view.state.tr; + tr = null; }, 250); try { const attachment: IAttachment = await onUpload(file, pageId); + + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; @@ -75,6 +79,8 @@ const handleAttachmentUpload = attachmentId: attachment.id, }); } catch (error) { + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {}; diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index e2494651..004a5956 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -3,6 +3,7 @@ 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"; const findImageNodeByPlaceholderId = ( doc: Node, @@ -42,8 +43,8 @@ const handleImageUpload = aspectRatio, }); + let tr: Transaction | null = view.state.tr; let placeholderShown = false; - let tr = view.state.tr; if (!initialPlaceholderNode) return; @@ -61,11 +62,14 @@ const handleImageUpload = const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); placeholderShown = true; - tr = view.state.tr; + tr = null; }, 250); try { const attachment: IAttachment = await onUpload(file, pageId); + + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; @@ -81,6 +85,8 @@ const handleImageUpload = aspectRatio, }); } catch (error) { + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findImageNodeByPlaceholderId(tr.doc, placeholderId) || {}; diff --git a/packages/editor-ext/src/lib/video/video-upload.ts b/packages/editor-ext/src/lib/video/video-upload.ts index c3d9a9d4..03e64f63 100644 --- a/packages/editor-ext/src/lib/video/video-upload.ts +++ b/packages/editor-ext/src/lib/video/video-upload.ts @@ -1,3 +1,4 @@ +import { Transaction } from "@tiptap/pm/state"; import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; import { generateNodeId } from "../utils"; @@ -66,8 +67,8 @@ const handleVideoUpload = aspectRatio, }); + let tr: Transaction | null = view.state.tr; let placeholderShown = false; - let tr = view.state.tr; if (!initialPlaceholderNode) { URL.revokeObjectURL(objectUrl); @@ -88,11 +89,14 @@ const handleVideoUpload = const displayPlaceholderTimeout = setTimeout(() => { view.dispatch(tr); placeholderShown = true; - tr = view.state.tr; + tr = null; }, 250); try { const attachment: IAttachment = await onUpload(file, pageId); + + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; @@ -108,6 +112,8 @@ const handleVideoUpload = aspectRatio, }); } catch (error) { + tr = tr ?? view.state.tr; + const { pos: currentPos = null } = findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {}; From 03ae58253a2e906f556eec6bccfb5f93f58128e0 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 19 Jan 2026 23:42:13 +0100 Subject: [PATCH 07/11] fix: Use imageDimensionsFromStream --- packages/editor-ext/src/lib/image/image-upload.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/editor-ext/src/lib/image/image-upload.ts b/packages/editor-ext/src/lib/image/image-upload.ts index 004a5956..fd8eb1e2 100644 --- a/packages/editor-ext/src/lib/image/image-upload.ts +++ b/packages/editor-ext/src/lib/image/image-upload.ts @@ -1,4 +1,7 @@ -import { imageDimensionsFromData } from "image-dimensions"; +import { + imageDimensionsFromData, + imageDimensionsFromStream, +} from "image-dimensions"; import { MediaUploadOptions, UploadFn } from "../media-utils"; import { IAttachment } from "../types"; import { generateNodeId } from "../utils"; @@ -33,7 +36,7 @@ const handleImageUpload = // @ts-ignore if (!validated) return; - const imageDimensions = imageDimensionsFromData(await file.bytes()); + const imageDimensions = await imageDimensionsFromStream(file.stream()); const placeholderId = generateNodeId(); const aspectRatio = imageDimensions ? imageDimensions.width / imageDimensions.height From dbd1308c7275a50020cc7eb86b6dd2be63641468 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Tue, 20 Jan 2026 11:10:11 +0100 Subject: [PATCH 08/11] feat: Multiple file upload, improved placeholders, local previews --- .../common/editor-paste-handler.tsx | 39 +++-- .../components/image/image-view.module.css | 3 + .../editor/components/image/image-view.tsx | 35 +++- .../components/slash-menu/menu-items.ts | 19 ++- .../components/video/video-view.module.css | 3 + .../editor/components/video/video-view.tsx | 34 +++- .../features/editor/extensions/extensions.ts | 2 + .../src/features/editor/page-editor.tsx | 16 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/attachment/attachment-upload.ts | 131 ++++++++------- .../src/lib/attachment/attachment.ts | 10 +- .../editor-ext/src/lib/image/image-upload.ts | 147 ++++++++++------- packages/editor-ext/src/lib/image/image.ts | 8 +- packages/editor-ext/src/lib/media-utils.ts | 18 +-- .../src/lib/shared-storage/index.ts | 1 + .../src/lib/shared-storage/shared-storage.ts | 17 ++ .../editor-ext/src/lib/video/video-upload.ts | 153 ++++++++++-------- packages/editor-ext/src/lib/video/video.ts | 8 +- 18 files changed, 400 insertions(+), 245 deletions(-) create mode 100644 packages/editor-ext/src/lib/shared-storage/index.ts create mode 100644 packages/editor-ext/src/lib/shared-storage/shared-storage.ts 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, }, From dcf9228344480e86a6894b1e2e773cbbbedb3643 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Tue, 20 Jan 2026 13:51:53 +0100 Subject: [PATCH 09/11] fix: Drag & drop, paste upload --- .../src/features/editor/page-editor.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 419a32be..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; @@ -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) => - handlePaste(editor, event, pageId, currentUser?.user.id), - handleDrop: (_view, event, _slice, moved) => - handleFileDrop(editor, 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 }) { From 8ea92668b20941842eb477f14dcacf93367d0ae5 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Tue, 20 Jan 2026 13:52:02 +0100 Subject: [PATCH 10/11] fix: Allow media as attachment --- .../src/features/editor/components/slash-menu/menu-items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 17a8027e..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 @@ -239,7 +239,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { for (const file of input.files) { const pos = editor.view.state.selection.from; - uploadAttachmentAction(file, editor, pos, pageId); + uploadAttachmentAction(file, editor, pos, pageId, true); } } From b0f3bec4d1cb23b410c415f81339caebeb740fea Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:03:49 +0000 Subject: [PATCH 11/11] * add skeleton pulse animation * add translation strings * fix attachment view responsiveness --- .../public/locales/en-US/translation.json | 2 ++ .../components/attachment/attachment-view.tsx | 16 +++++++++++----- .../components/image/image-view.module.css | 18 ++++++++++++++++-- .../editor/components/image/image-view.tsx | 12 ++++++++---- .../components/video/video-view.module.css | 18 ++++++++++++++++-- .../editor/components/video/video-view.tsx | 12 ++++++++---- 6 files changed, 61 insertions(+), 17 deletions(-) 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 48ab6cd6..e3281e64 100644 --- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx +++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx @@ -4,8 +4,10 @@ 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,14 +22,18 @@ export default function AttachmentView(props: NodeViewProps) { wrap="nowrap" h={25} > - - {url ? : } + + {url ? ( + + ) : ( + + )} - - {url ? name : `Uploading ${name}...`} + + {url ? name : t("Uploading {{name}}", { name })} - + {formatBytes(size)} 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 7d0dabf3..5d02184b 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 @@ -3,11 +3,25 @@ justify-content: center; align-items: center; border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + @mixin light { - background-color: var(--mantine-color-gray-0); + 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-color: var(--mantine-color-dark-7); + 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 6aa777f9..defb64c4 100644 --- a/apps/client/src/features/editor/components/image/image-view.tsx +++ b/apps/client/src/features/editor/components/image/image-view.tsx @@ -4,8 +4,10 @@ import { useMemo } from "react"; 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 { t } = useTranslation(); const { editor, node, selected } = props; const { src, width, align, title, aspectRatio, placeholder } = node.attrs; const alignClass = useMemo(() => { @@ -53,10 +55,12 @@ export default function ImageView(props: NodeViewProps) { )} {!src && !previewSrc && ( - - - - Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}... + + + + {placeholder?.name + ? t("Uploading {{name}}", { name: placeholder.name }) + : t("Uploading file")} )} 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 95bce4ba..c0e7f99d 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 @@ -3,12 +3,26 @@ justify-content: center; align-items: center; border-radius: 8px; + overflow: hidden; + animation: pulse 1.2s ease-in-out infinite; + @mixin light { - background-color: var(--mantine-color-gray-0); + 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-color: var(--mantine-color-dark-7); + 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 { 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 c228265e..e2473afc 100644 --- a/apps/client/src/features/editor/components/video/video-view.tsx +++ b/apps/client/src/features/editor/components/video/video-view.tsx @@ -4,8 +4,10 @@ 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 { t } = useTranslation(); const { editor, node, selected } = props; const { src, width, align, aspectRatio, placeholder } = node.attrs; const alignClass = useMemo(() => { @@ -58,10 +60,12 @@ export default function VideoView(props: NodeViewProps) { )} {!src && !previewSrc && ( - - - - Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}... + + + + {placeholder?.name + ? t("Uploading {{name}}", { name: placeholder.name }) + : t("Uploading file")} )}