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,
);
};
}, []);