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 { 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;
}
@@ -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);
@@ -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 (
<NodeViewWrapper data-drag-handle>
@@ -31,6 +41,25 @@ export default function ImageView(props: NodeViewProps) {
{src && (
<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>
</NodeViewWrapper>
);
@@ -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
@@ -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);
@@ -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 (
<NodeViewWrapper data-drag-handle>
@@ -35,6 +46,25 @@ export default function VideoView(props: NodeViewProps) {
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>
</NodeViewWrapper>
);
@@ -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"],
@@ -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,
);
};
}, []);
+1
View File
@@ -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";
@@ -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());
}
};
@@ -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<AttachmentOptions>({
"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,
},
};
},
@@ -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();
}
};
+5 -3
View File
@@ -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<ImageOptions>({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholderId: {
placeholder: {
default: null,
rendered: false,
},
+2 -16
View File
@@ -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<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 { 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();
}
};
+5 -3
View File
@@ -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<VideoOptions>({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
placeholderId: {
placeholder: {
default: null,
rendered: false,
},