mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 18:16:57 +08:00
Merge branch 'feat/asset-placeholders' into tiptap3-migration
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
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";
|
||||
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,26 +22,28 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
wrap="nowrap"
|
||||
h={25}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<IconPaperclip size={20} />
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
|
||||
{url ? (
|
||||
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<Text component="span" size="md" truncate="end">
|
||||
{name}
|
||||
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
|
||||
{url ? name : t("Uploading {{name}}", { name })}
|
||||
</Text>
|
||||
|
||||
<Text component="span" size="sm" c="dimmed" inline>
|
||||
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{formatBytes(size)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{selected || hovered ? (
|
||||
{url && (selected || hovered) && (
|
||||
<a href={getFileUrl(url)} target="_blank">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("image");
|
||||
return editor.isActive("image") && editor.getAttributes("image").src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.imageWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
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: 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,70 @@
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ImageView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { src, width, align, title } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
<Image
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.imageWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{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="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -197,12 +201,18 @@ 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;
|
||||
uploadVideoAction(file, editor.view, pos, pageId);
|
||||
for (const file of input.files) {
|
||||
const pos = editor.view.state.selection.from;
|
||||
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -223,12 +233,18 @@ 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, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.videoWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
|
||||
@mixin light {
|
||||
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: 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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -1,29 +1,75 @@
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function VideoView(props: NodeViewProps) {
|
||||
const { node, selected } = props;
|
||||
const { src, width, align } = node.attrs;
|
||||
|
||||
const { t } = useTranslation();
|
||||
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>
|
||||
<video
|
||||
preload="metadata"
|
||||
width={width}
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
className={clsx(selected ? "ProseMirror-selectednode" : "", alignClass)}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
selected && "ProseMirror-selectednode",
|
||||
classes.videoWrapper,
|
||||
alignClass,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: aspectRatio ? aspectRatio : src ? undefined : "16 / 9",
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{src && (
|
||||
<video
|
||||
className={classes.video}
|
||||
preload="metadata"
|
||||
controls
|
||||
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="center" wrap="nowrap" gap="xs" maw="100%" px="md">
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
{placeholder?.name
|
||||
? t("Uploading {{name}}", { name: placeholder.name })
|
||||
: t("Uploading file")}
|
||||
</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"],
|
||||
|
||||
@@ -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<Editor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
isComponentMounted.current = true;
|
||||
@@ -93,7 +94,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();
|
||||
@@ -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, slice) =>
|
||||
handlePaste(view, event, pageId, currentUser?.user.id),
|
||||
handleDrop: (view, event, _slice, moved) =>
|
||||
handleFileDrop(view, 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 }) {
|
||||
@@ -275,7 +287,7 @@ export default function PageEditor({
|
||||
debouncedUpdateContent(editorJson);
|
||||
},
|
||||
},
|
||||
[pageId, editable, extensions]
|
||||
[pageId, editable, extensions],
|
||||
);
|
||||
|
||||
const editorIsEditable = useEditorState({
|
||||
@@ -320,7 +332,7 @@ export default function PageEditor({
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"ACTIVE_COMMENT_EVENT",
|
||||
handleActiveCommentEvent
|
||||
handleActiveCommentEvent,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user