mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +08:00
feat: Multiple file upload, improved placeholders, local previews
This commit is contained in:
@@ -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,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user