feat: Multiple file upload, improved placeholders, local previews

This commit is contained in:
Arek Nawo
2026-01-20 11:10:11 +01:00
parent 03ae58253a
commit dbd1308c72
18 changed files with 400 additions and 245 deletions
@@ -1,13 +1,12 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx"; import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action"; import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts"; 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 { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
export const handlePaste = ( export const handlePaste = (
view: EditorView, editor: Editor,
event: ClipboardEvent, event: ClipboardEvent,
pageId: string, pageId: string,
creatorId?: 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 // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
const url = clipboardData.trim(); 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 match = INTERNAL_LINK_REGEX.exec(url);
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href); const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
@@ -34,19 +33,27 @@ export const handlePaste = (
return false; return false;
} }
const anchorId = match[6] ? match[6].split('#')[0] : undefined; const anchorId = match[6] ? match[6].split("#")[0] : undefined;
const urlWithoutAnchor = anchorId ? url.substring(0, url.indexOf("#")) : url; const urlWithoutAnchor = anchorId
createMentionAction(urlWithoutAnchor, view, pos, creatorId, anchorId); ? url.substring(0, url.indexOf("#"))
: url;
createMentionAction(
urlWithoutAnchor,
editor.view,
pos,
creatorId,
anchorId,
);
return true; return true;
} }
if (event.clipboardData?.files.length) { if (event.clipboardData?.files.length) {
event.preventDefault(); event.preventDefault();
for (const file of event.clipboardData.files) { for (const file of event.clipboardData.files) {
const pos = view.state.selection.from; const pos = editor.state.selection.from;
uploadImageAction(file, view, pos, pageId); uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, view, pos, pageId); uploadVideoAction(file, editor, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId); uploadAttachmentAction(file, editor, pos, pageId);
} }
return true; return true;
} }
@@ -54,7 +61,7 @@ export const handlePaste = (
}; };
export const handleFileDrop = ( export const handleFileDrop = (
view: EditorView, editor: Editor,
event: DragEvent, event: DragEvent,
moved: boolean, moved: boolean,
pageId: string, pageId: string,
@@ -63,14 +70,14 @@ export const handleFileDrop = (
event.preventDefault(); event.preventDefault();
for (const file of event.dataTransfer.files) { for (const file of event.dataTransfer.files) {
const coordinates = view.posAtCoords({ const coordinates = editor.view.posAtCoords({
left: event.clientX, left: event.clientX,
top: event.clientY, top: event.clientY,
}); });
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId); uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
} }
return true; return true;
} }
@@ -1,4 +1,7 @@
.imageWrapper { .imageWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px; border-radius: 8px;
@mixin light { @mixin light {
background-color: var(--mantine-color-gray-0); background-color: var(--mantine-color-gray-0);
@@ -1,19 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Image, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Image } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./image-view.module.css"; import classes from "./image-view.module.css";
export default function ImageView(props: NodeViewProps) { export default function ImageView(props: NodeViewProps) {
const { node, selected } = props; const { editor, node, selected } = props;
const { src, width, align, title, aspectRatio } = node.attrs; const { src, width, align, title, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [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 ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -31,6 +41,25 @@ export default function ImageView(props: NodeViewProps) {
{src && ( {src && (
<Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} /> <Image radius="md" fit="contain" src={getFileUrl(src)} alt={title} />
)} )}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<Image
radius="md"
fit="contain"
src={previewSrc}
alt={placeholder?.name}
/>
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="space-between" wrap="nowrap">
<Loader size={20} />
<Text component="span" size="md" truncate="end">
Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}...
</Text>
</Group>
)}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -175,7 +175,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
for (const file of input.files) { for (const file of input.files) {
const pos = editor.view.state.selection.from; 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"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; 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 // 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"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; for (const file of input.files) {
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
uploadAttachmentAction(file, editor.view, pos, pageId, true);
uploadAttachmentAction(file, editor, pos, pageId);
}
} }
// Reset the input value to allow uploading the same file again if needed // Reset the input value to allow uploading the same file again if needed
@@ -1,4 +1,7 @@
.videoWrapper { .videoWrapper {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px; border-radius: 8px;
@mixin light { @mixin light {
background-color: var(--mantine-color-gray-0); background-color: var(--mantine-color-gray-0);
@@ -1,18 +1,29 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts"; import { getFileUrl } from "@/lib/config.ts";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./video-view.module.css"; import classes from "./video-view.module.css";
export default function VideoView(props: NodeViewProps) { export default function VideoView(props: NodeViewProps) {
const { node, selected } = props; const { editor, node, selected } = props;
const { src, width, align, aspectRatio } = node.attrs; const { src, width, align, aspectRatio, placeholder } = node.attrs;
const alignClass = useMemo(() => { const alignClass = useMemo(() => {
if (align === "left") return "alignLeft"; if (align === "left") return "alignLeft";
if (align === "right") return "alignRight"; if (align === "right") return "alignRight";
if (align === "center") return "alignCenter"; if (align === "center") return "alignCenter";
return "alignCenter"; return "alignCenter";
}, [align]); }, [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 ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -35,6 +46,25 @@ export default function VideoView(props: NodeViewProps) {
src={getFileUrl(src)} src={getFileUrl(src)}
/> />
)} )}
{!src && previewSrc && (
<Group pos="relative" h="100%" w="100%">
<video
className={classes.video}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
<Group justify="space-between" wrap="nowrap">
<Loader size={20} />
<Text component="span" size="md" truncate="end">
Uploading{placeholder?.name ? ` ${placeholder?.name}` : ""}...
</Text>
</Group>
)}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
); );
@@ -42,6 +42,7 @@ import {
Heading, Heading,
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -107,6 +108,7 @@ export const mainExtensions = [
}, },
}, },
}), }),
SharedStorage,
Heading, Heading,
UniqueID.configure({ UniqueID.configure({
types: ["heading", "paragraph"], types: ["heading", "paragraph"],
@@ -93,7 +93,7 @@ export default function PageEditor({
const [isLocalSynced, setIsLocalSynced] = useState(false); const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false); const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
@@ -105,7 +105,7 @@ export default function PageEditor({
currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; currentUser?.user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const canScroll = useCallback( const canScroll = useCallback(
() => isComponentMounted.current && editorCreated.current, () => isComponentMounted.current && editorCreated.current,
[isComponentMounted, editorCreated] [isComponentMounted, editorCreated],
); );
const { handleScrollTo } = useEditorScroll({ canScroll }); const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId // Providers only created once per pageId
@@ -253,10 +253,10 @@ export default function PageEditor({
} }
}, },
}, },
handlePaste: (view, event, slice) => handlePaste: (_view, event) =>
handlePaste(view, event, pageId, currentUser?.user.id), handlePaste(editor, event, pageId, currentUser?.user.id),
handleDrop: (view, event, _slice, moved) => handleDrop: (_view, event, _slice, moved) =>
handleFileDrop(view, event, moved, pageId), handleFileDrop(editor, event, moved, pageId),
}, },
onCreate({ editor }) { onCreate({ editor }) {
if (editor) { if (editor) {
@@ -275,7 +275,7 @@ export default function PageEditor({
debouncedUpdateContent(editorJson); debouncedUpdateContent(editorJson);
}, },
}, },
[pageId, editable, extensions] [pageId, editable, extensions],
); );
const editorIsEditable = useEditorState({ const editorIsEditable = useEditorState({
@@ -320,7 +320,7 @@ export default function PageEditor({
return () => { return () => {
document.removeEventListener( document.removeEventListener(
"ACTIVE_COMMENT_EVENT", "ACTIVE_COMMENT_EVENT",
handleActiveCommentEvent handleActiveCommentEvent,
); );
}; };
}, []); }, []);
+1
View File
@@ -23,3 +23,4 @@ export * from "./lib/subpages";
export * from "./lib/highlight"; export * from "./lib/highlight";
export * from "./lib/heading/heading"; export * from "./lib/heading/heading";
export * from "./lib/unique-id"; export * from "./lib/unique-id";
export * from "./lib/shared-storage";
@@ -2,7 +2,7 @@ import { Node } from "@tiptap/pm/model";
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils"; import { generateNodeId } from "../utils";
import { Transaction } from "@tiptap/pm/state"; import { Command } from "@tiptap/core";
const findAttachmentNodeByPlaceholderId = ( const findAttachmentNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -14,7 +14,7 @@ const findAttachmentNodeByPlaceholderId = (
if (result) return false; if (result) return false;
if ( if (
node.type.name === "attachment" && node.type.name === "attachment" &&
node.attrs.placeholderId === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
return false; return false;
@@ -26,82 +26,99 @@ const findAttachmentNodeByPlaceholderId = (
}; };
const handleAttachmentUpload = const handleAttachmentUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId, allowMedia) => { async (file, editor, pos, pageId, allowMedia) => {
const validated = validateFn?.(file, allowMedia); const validated = validateFn?.(file, allowMedia);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
const placeholderId = generateNodeId(); 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 placeholderInserted = false;
let placeholderShown = 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); if (!initialPlaceholderNode) return false;
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) { const { parent } = tr.doc.resolve(pos);
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
} else {
tr.insert(pos, initialPlaceholderNode);
}
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 // Only show the placeholder if the upload takes more than 250ms
const displayPlaceholderTimeout = setTimeout(() => { const insertPlaceholderTimeout = setTimeout(() => {
view.dispatch(tr); editor.commands.command(insertPlaceholder());
placeholderShown = true; placeholderInserted = true;
tr = null;
}, 250); }, 250);
try { try {
const attachment: IAttachment = await onUpload(file, pageId); const attachment: IAttachment = await onUpload(file, pageId);
tr = tr ?? view.state.tr; clearTimeout(insertPlaceholderTimeout);
const { pos: currentPos = null } = if (placeholderInserted) {
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) {
setTimeout(() => { setTimeout(() => {
view.dispatch(tr); editor.commands.command(replacePlaceholderWithAttachment(attachment));
}, 100); }, 100);
} else { } else {
view.dispatch(tr); editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAttachment(attachment))
.run();
} }
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
} }
}; };
@@ -12,7 +12,7 @@ export interface AttachmentAttributes {
mime?: string; // e.g. application/zip mime?: string; // e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
placeholderId?: string; placeholder?: string;
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -75,13 +75,9 @@ export const Attachment = Node.create<AttachmentOptions>({
"data-attachment-id": attributes.attachmentId, "data-attachment-id": attributes.attachmentId,
}), }),
}, },
placeholderId: { placeholder: {
default: null, default: null,
parseHTML: (element) => rendered: false,
element.getAttribute("data-attachment-placeholder-id"),
renderHTML: (attributes: AttachmentAttributes) => ({
"data-attachment-placeholder-id": attributes.placeholderId,
}),
}, },
}; };
}, },
@@ -1,12 +1,9 @@
import { import { imageDimensionsFromStream } from "image-dimensions";
imageDimensionsFromData,
imageDimensionsFromStream,
} from "image-dimensions";
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils"; import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model"; import { Node } from "@tiptap/pm/model";
import { Transaction } from "@tiptap/pm/state"; import { Command } from "@tiptap/core";
const findImageNodeByPlaceholderId = ( const findImageNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -18,7 +15,7 @@ const findImageNodeByPlaceholderId = (
if (result) return false; if (result) return false;
if ( if (
node.type.name === "image" && node.type.name === "image" &&
node.attrs.placeholderId === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
return false; return false;
@@ -30,84 +27,118 @@ const findImageNodeByPlaceholderId = (
}; };
const handleImageUpload = const handleImageUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is an image // check if the file is an image
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
if (!validated) return; if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream()); const imageDimensions = await imageDimensionsFromStream(file.stream());
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = imageDimensions const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height ? imageDimensions.width / imageDimensions.height
: undefined; : undefined;
const initialPlaceholderNode = view.state.schema.nodes.image?.create({
placeholderId,
aspectRatio,
});
let tr: Transaction | null = view.state.tr; let placeholderInserted = false;
let placeholderShown = 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 insertPlaceholder = (): Command => {
const isEmptyTextBlock = parent.isTextblock && !parent.childCount; return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.image?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (isEmptyTextBlock) { if (!initialPlaceholderNode) return false;
// Replace e.g. empty paragraph with the image
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) {
// 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 // Only show the placeholder if the upload takes more than 250ms
const displayPlaceholderTimeout = setTimeout(() => { const insertPlaceholderTimeout = setTimeout(() => {
view.dispatch(tr); editor.commands.command(insertPlaceholder());
placeholderShown = true; placeholderInserted = true;
tr = null;
}, 250); }, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.imagePreviews) {
delete editor.storage.shared.imagePreviews[placeholderId];
}
};
try { try {
const attachment: IAttachment = await onUpload(file, pageId); const attachment: IAttachment = await onUpload(file, pageId);
tr = tr ?? view.state.tr; clearTimeout(insertPlaceholderTimeout);
const { pos: currentPos = null } = if (placeholderInserted) {
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) {
setTimeout(() => { setTimeout(() => {
view.dispatch(tr); editor.commands.command(replacePlaceholderWithImage(attachment));
disposePreviewFile();
}, 100); }, 100);
} else { } else {
view.dispatch(tr); editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithImage(attachment))
.run();
disposePreviewFile();
} }
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
} }
}; };
+5 -3
View File
@@ -9,13 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
export interface ImageAttributes { export interface ImageAttributes {
src?: string; src?: string;
alt?: string; alt?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number; aspectRatio?: number;
placeholderId?: string; placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -98,7 +100,7 @@ export const TiptapImage = Image.extend<ImageOptions>({
"data-aspect-ratio": attributes.aspectRatio, "data-aspect-ratio": attributes.aspectRatio,
}), }),
}, },
placeholderId: { placeholder: {
default: null, default: null,
rendered: false, rendered: false,
}, },
+2 -16
View File
@@ -1,9 +1,8 @@
import type { EditorView } from "@tiptap/pm/view"; import { Editor } from "@tiptap/core";
import { Transaction } from "@tiptap/pm/state";
export type UploadFn = ( export type UploadFn = (
file: File, file: File,
view: EditorView, editor: Editor,
pos: number, pos: number,
pageId: string, pageId: string,
// only applicable to file attachments // only applicable to file attachments
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
validateFn?: (file: File, allowMedia?: boolean) => void; validateFn?: (file: File, allowMedia?: boolean) => void;
onUpload: (file: File, pageId: string) => Promise<any>; onUpload: (file: File, pageId: string) => Promise<any>;
} }
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());
}
}
@@ -0,0 +1 @@
export { SharedStorage } from "./shared-storage";
@@ -0,0 +1,17 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Storage {
shared: Record<string, any>;
}
}
const SharedStorage = Extension.create({
name: "shared",
addStorage() {
return {};
},
});
export { SharedStorage };
@@ -1,8 +1,8 @@
import { Transaction } from "@tiptap/pm/state";
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types"; import { IAttachment } from "../types";
import { generateNodeId } from "../utils"; import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model"; import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findVideoNodeByPlaceholderId = ( const findVideoNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -15,7 +15,7 @@ const findVideoNodeByPlaceholderId = (
if ( if (
node.type.name === "video" && node.type.name === "video" &&
node.attrs.placeholderId === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
return false; return false;
@@ -52,7 +52,7 @@ const getVideoDimensions = (
}; };
const handleVideoUpload = const handleVideoUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn => ({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, view, pos, pageId) => { async (file, editor, pos, pageId) => {
// check if the file is valid // check if the file is valid
const validated = validateFn?.(file); const validated = validateFn?.(file);
// @ts-ignore // @ts-ignore
@@ -62,84 +62,107 @@ const handleVideoUpload =
const videoDimensions = await getVideoDimensions(objectUrl); const videoDimensions = await getVideoDimensions(objectUrl);
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = videoDimensions.aspectRatio; const aspectRatio = videoDimensions.aspectRatio;
const initialPlaceholderNode = view.state.schema.nodes.video?.create({
placeholderId,
aspectRatio,
});
let tr: Transaction | null = view.state.tr; let placeholderInserted = false;
let placeholderShown = false;
if (!initialPlaceholderNode) { editor.storage.shared.videoPreviews =
URL.revokeObjectURL(objectUrl); editor.storage.shared.videoPreviews || {};
return; editor.storage.shared.videoPreviews[placeholderId] = objectUrl;
}
const { parent } = tr.doc.resolve(pos); const insertPlaceholder = (): Command => {
const isEmptyTextBlock = parent.isTextblock && !parent.childCount; return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.video?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
aspectRatio,
});
if (isEmptyTextBlock) { if (!initialPlaceholderNode) return false;
// Replace e.g. empty paragraph with the video
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode); const { parent } = tr.doc.resolve(pos);
} else { const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
tr.insert(pos, initialPlaceholderNode);
} 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 // Only show the placeholder if the upload takes more than 250ms
const displayPlaceholderTimeout = setTimeout(() => { const insertPlaceholderTimeout = setTimeout(() => {
view.dispatch(tr); editor.commands.command(insertPlaceholder());
placeholderShown = true; placeholderInserted = true;
tr = null;
}, 250); }, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.videoPreviews) {
delete editor.storage.shared.videoPreviews[placeholderId];
}
};
try { try {
const attachment: IAttachment = await onUpload(file, pageId); const attachment: IAttachment = await onUpload(file, pageId);
tr = tr ?? view.state.tr; clearTimeout(insertPlaceholderTimeout);
const { pos: currentPos = null } = if (placeholderInserted) {
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) {
setTimeout(() => { setTimeout(() => {
dispatchFinal(); editor.commands.command(replacePlaceholderWithVideo(attachment));
disposePreviewFile();
}, 100); }, 100);
} else { } else {
dispatchFinal(); editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithVideo(attachment))
.run();
disposePreviewFile();
} }
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
} }
}; };
+5 -3
View File
@@ -7,13 +7,15 @@ export interface VideoOptions {
} }
export interface VideoAttributes { export interface VideoAttributes {
src?: string; src?: string;
title?: string;
align?: string; align?: string;
attachmentId?: string; attachmentId?: string;
size?: number; size?: number;
width?: number; width?: number;
aspectRatio?: number; aspectRatio?: number;
placeholderId?: string; placeholder?: {
id: string;
name: string;
};
} }
declare module "@tiptap/core" { declare module "@tiptap/core" {
@@ -89,7 +91,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
"data-aspect-ratio": attributes.aspectRatio, "data-aspect-ratio": attributes.aspectRatio,
}), }),
}, },
placeholderId: { placeholder: {
default: null, default: null,
rendered: false, rendered: false,
}, },