mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: PDF embed
This commit is contained in:
@@ -353,6 +353,11 @@
|
|||||||
"Quote": "Quote.",
|
"Quote": "Quote.",
|
||||||
"Image": "Image.",
|
"Image": "Image.",
|
||||||
"Audio": "Audio.",
|
"Audio": "Audio.",
|
||||||
|
"Embed PDF": "Embed PDF",
|
||||||
|
"Upload and embed a PDF file.": "Upload and embed a PDF file.",
|
||||||
|
"Embed as PDF": "Embed as PDF",
|
||||||
|
"Failed to load PDF": "Failed to load PDF",
|
||||||
|
"Convert to attachment": "Convert to attachment",
|
||||||
"File attachment": "File attachment.",
|
"File attachment": "File attachment.",
|
||||||
"Toggle block": "Toggle block.",
|
"Toggle block": "Toggle block.",
|
||||||
"Callout": "Callout.",
|
"Callout": "Callout.",
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
|
import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
import { formatBytes } from "@/lib";
|
import { formatBytes } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export default function AttachmentView(props: NodeViewProps) {
|
export default function AttachmentView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, selected } = props;
|
const { editor, node, getPos, selected } = props;
|
||||||
const { url, name, size } = node.attrs;
|
const { url, name, size, mime, attachmentId } = node.attrs;
|
||||||
const { hovered, ref } = useHover();
|
const { hovered, ref } = useHover();
|
||||||
|
|
||||||
|
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
|
||||||
|
|
||||||
|
const handleEmbedAsPdf = useCallback(() => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined || !url) return;
|
||||||
|
|
||||||
|
const nodeSize = node.nodeSize;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.insertContentAt(
|
||||||
|
{ from: pos, to: pos + nodeSize },
|
||||||
|
{
|
||||||
|
type: "pdf",
|
||||||
|
attrs: {
|
||||||
|
src: url,
|
||||||
|
name,
|
||||||
|
attachmentId,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}, [editor, getPos, node, url, name, attachmentId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||||
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{url && (selected || hovered) && (
|
{url && (selected || hovered) && (
|
||||||
|
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
{isPdf && editor.isEditable && (
|
||||||
|
<Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
|
||||||
|
<ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
|
||||||
|
<IconFileTypePdf size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<a href={getFileUrl(url)} target="_blank">
|
<a href={getFileUrl(url)} target="_blank">
|
||||||
<ActionIcon variant="default" aria-label="download file">
|
<ActionIcon variant="default" aria-label="download file">
|
||||||
<IconDownload size={18} />
|
<IconDownload size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</a>
|
</a>
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import { Group, Loader, Text } from "@mantine/core";
|
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 { isInternalFileUrl } from "@docmost/editor-ext";
|
||||||
import classes from "./audio-view.module.css";
|
import classes from "./audio-view.module.css";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -10,6 +11,11 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
const { editor, node } = props;
|
const { editor, node } = props;
|
||||||
const { src, placeholder } = node.attrs;
|
const { src, placeholder } = node.attrs;
|
||||||
|
|
||||||
|
const safeSrc = useMemo(() => {
|
||||||
|
if (!src || !isInternalFileUrl(src)) return null;
|
||||||
|
return getFileUrl(src);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
const previewSrc = useMemo(() => {
|
const previewSrc = useMemo(() => {
|
||||||
editor.storage.shared.audioPreviews =
|
editor.storage.shared.audioPreviews =
|
||||||
editor.storage.shared.audioPreviews || {};
|
editor.storage.shared.audioPreviews || {};
|
||||||
@@ -23,16 +29,16 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper data-drag-handle>
|
<NodeViewWrapper data-drag-handle>
|
||||||
<div className={`${classes.audioWrapper} ${!src ? classes.skeleton : ''}`}>
|
<div className={`${classes.audioWrapper} ${!safeSrc ? classes.skeleton : ''}`}>
|
||||||
{src && (
|
{safeSrc && (
|
||||||
<audio
|
<audio
|
||||||
className={classes.audio}
|
className={classes.audio}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
controls
|
controls
|
||||||
src={getFileUrl(src)}
|
src={safeSrc}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!src && previewSrc && (
|
{!safeSrc && previewSrc && (
|
||||||
<Group pos="relative" w="100%">
|
<Group pos="relative" w="100%">
|
||||||
<audio
|
<audio
|
||||||
className={classes.audio}
|
className={classes.audio}
|
||||||
@@ -43,7 +49,7 @@ export default function AudioView(props: NodeViewProps) {
|
|||||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{!src && !previewSrc && (
|
{!safeSrc && !previewSrc && (
|
||||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
|
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
|
||||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||||
<Text component="span" size="sm" truncate="end">
|
<Text component="span" size="sm" truncate="end">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { uploadPdfAction } from "../pdf/upload-pdf-action";
|
||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
@@ -12,6 +13,8 @@ import {
|
|||||||
const ATTACHMENT_NODE_TYPES = [
|
const ATTACHMENT_NODE_TYPES = [
|
||||||
"image",
|
"image",
|
||||||
"video",
|
"video",
|
||||||
|
"audio",
|
||||||
|
"pdf",
|
||||||
"attachment",
|
"attachment",
|
||||||
"excalidraw",
|
"excalidraw",
|
||||||
"drawio",
|
"drawio",
|
||||||
@@ -63,6 +66,7 @@ export const handlePaste = (
|
|||||||
const pos = editor.state.selection.from;
|
const pos = editor.state.selection.from;
|
||||||
uploadImageAction(file, editor, pos, pageId);
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
uploadVideoAction(file, editor, pos, pageId);
|
uploadVideoAction(file, editor, pos, pageId);
|
||||||
|
uploadPdfAction(file, editor, pos, pageId);
|
||||||
uploadAttachmentAction(file, editor, pos, pageId);
|
uploadAttachmentAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -229,6 +233,7 @@ export const handleFileDrop = (
|
|||||||
|
|
||||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
|
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
|
|
||||||
.cornerHandle {
|
.cornerHandle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 36px;
|
width: 24px;
|
||||||
height: 36px;
|
height: 24px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
@@ -42,13 +42,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
width: 28px;
|
width: 20px;
|
||||||
height: 3px;
|
height: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 28px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::before,
|
&:hover::before,
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
|||||||
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
|
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
|
||||||
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
|
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragRef.current && wrapperRef.current) {
|
||||||
|
widthRef.current = initialWidth;
|
||||||
|
heightRef.current = initialHeight;
|
||||||
|
wrapperRef.current.style.width = `${initialWidth}px`;
|
||||||
|
wrapperRef.current.style.height = `${initialHeight}px`;
|
||||||
|
}
|
||||||
|
}, [initialWidth, initialHeight]);
|
||||||
|
|
||||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag || !wrapperRef.current) return;
|
if (!drag || !wrapperRef.current) return;
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
|
|||||||
<iframe
|
<iframe
|
||||||
className={classes.embedIframe}
|
className={classes.embedIframe}
|
||||||
src={sanitizeUrl(embedUrl)}
|
src={sanitizeUrl(embedUrl)}
|
||||||
allow="encrypted-media"
|
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
loading="lazy"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import {
|
||||||
|
EditorMenuProps,
|
||||||
|
ShouldShowProps,
|
||||||
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconDownload,
|
||||||
|
IconPaperclip,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { isInternalFileUrl } from "@docmost/editor-ext";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
export function PdfMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const editorState = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
if (!ctx.editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfAttrs = ctx.editor.getAttributes("pdf");
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPdf: ctx.editor.isActive("pdf"),
|
||||||
|
src: pdfAttrs?.src || null,
|
||||||
|
name: pdfAttrs?.name || null,
|
||||||
|
attachmentId: pdfAttrs?.attachmentId || null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("pdf") && editor.getAttributes("pdf").src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "pdf";
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src || !isInternalFileUrl(editorState.src)) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleConvertToAttachment = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const { from } = selection;
|
||||||
|
const node = editor.state.doc.nodeAt(from);
|
||||||
|
if (!node || node.type.name !== "pdf") return;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.insertContentAt(
|
||||||
|
{ from, to: from + node.nodeSize },
|
||||||
|
{
|
||||||
|
type: "attachment",
|
||||||
|
attrs: {
|
||||||
|
url: node.attrs.src,
|
||||||
|
name: node.attrs.name,
|
||||||
|
attachmentId: node.attrs.attachmentId,
|
||||||
|
size: node.attrs.size,
|
||||||
|
mime: "application/pdf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}, [editor, editorState]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey={`pdf-menu`}
|
||||||
|
updateDelay={0}
|
||||||
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
|
options={{
|
||||||
|
placement: "top",
|
||||||
|
offset: 8,
|
||||||
|
flip: false,
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Convert to attachment")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleConvertToAttachment}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Convert to attachment")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconPaperclip size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PdfMenu;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.pdfWrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdfContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdfResizeWrapper {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdfIframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdfError {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Group, Loader, Text } from "@mantine/core";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { ResizableWrapper } from "../common/resizable-wrapper";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import classes from "./pdf-view.module.css";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { isInternalFileUrl } from "@docmost/editor-ext";
|
||||||
|
import { IconFileTypePdf } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function PdfView(props: NodeViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { editor, node, getPos, selected, updateAttributes } = props;
|
||||||
|
const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const safeSrc = useMemo(() => {
|
||||||
|
if (!src || !isInternalFileUrl(src)) return null;
|
||||||
|
return getFileUrl(src);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos !== undefined) {
|
||||||
|
editor.commands.setNodeSelection(pos);
|
||||||
|
}
|
||||||
|
}, [editor, getPos]);
|
||||||
|
|
||||||
|
const handleResize = useCallback(
|
||||||
|
(newWidth: number, newHeight: number) => {
|
||||||
|
updateAttributes({ width: newWidth, height: newHeight });
|
||||||
|
},
|
||||||
|
[updateAttributes],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!src || !safeSrc) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={`${classes.pdfWrapper} ${classes.skeleton}`} style={{ height: 600 }}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle>
|
||||||
|
<div className={classes.pdfError} onClick={handleSelect}>
|
||||||
|
<IconFileTypePdf size={32} stroke={1.5} />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("Failed to load PDF")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper data-drag-handle className={classes.pdfNodeView}>
|
||||||
|
<div className={classes.pdfContainer}>
|
||||||
|
<ResizableWrapper
|
||||||
|
initialWidth={nodeWidth || 800}
|
||||||
|
initialHeight={nodeHeight || 600}
|
||||||
|
minWidth={200}
|
||||||
|
maxWidth={1200}
|
||||||
|
minHeight={200}
|
||||||
|
maxHeight={1200}
|
||||||
|
onResize={handleResize}
|
||||||
|
isEditable={editor.isEditable}
|
||||||
|
selected={selected}
|
||||||
|
className={clsx(classes.pdfResizeWrapper, {
|
||||||
|
"ProseMirror-selectednode": selected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
className={classes.pdfIframe}
|
||||||
|
src={safeSrc}
|
||||||
|
loading="lazy"
|
||||||
|
frameBorder="0"
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
onLoad={(e) => {
|
||||||
|
try {
|
||||||
|
const iframe = e.currentTarget;
|
||||||
|
const status = iframe.contentDocument?.querySelector("pre")?.textContent;
|
||||||
|
if (status && status.includes('"statusCode":404')) {
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// cross-origin - can't inspect, assume OK
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!selected && <div className={classes.clickOverlay} onClick={handleSelect} />}
|
||||||
|
</ResizableWrapper>
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { handlePdfUpload } from "@docmost/editor-ext";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { getFileUploadSizeLimit } from "@/lib/config.ts";
|
||||||
|
import { formatBytes } from "@/lib";
|
||||||
|
import i18n from "@/i18n.ts";
|
||||||
|
|
||||||
|
export const uploadPdfAction = handlePdfUpload({
|
||||||
|
onUpload: async (file: File, pageId: string): Promise<any> => {
|
||||||
|
try {
|
||||||
|
return await uploadFile(file, pageId);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: err?.response.data.message,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
validateFn: (file) => {
|
||||||
|
if (file.type !== "application/pdf") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > getFileUploadSizeLimit()) {
|
||||||
|
notifications.show({
|
||||||
|
color: "red",
|
||||||
|
message: i18n.t("File exceeds the {{limit}} attachment limit", {
|
||||||
|
limit: formatBytes(getFileUploadSizeLimit()),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
IconMovie,
|
IconMovie,
|
||||||
IconMusic,
|
IconMusic,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
|
IconFileTypePdf,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconTable,
|
IconTable,
|
||||||
IconTypography,
|
IconTypography,
|
||||||
@@ -33,6 +34,7 @@ import { uploadImageAction } from "@/features/editor/components/image/upload-ima
|
|||||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||||
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
|
||||||
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
|
||||||
|
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
|
||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
@@ -259,10 +261,41 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
input.click();
|
input.click();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Embed PDF",
|
||||||
|
description: "Upload and embed a PDF file.",
|
||||||
|
searchTerms: ["pdf", "document", "embed"],
|
||||||
|
icon: IconFileTypePdf,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
if (!pageId) return;
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "application/pdf";
|
||||||
|
input.style.display = "none";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
for (const file of input.files) {
|
||||||
|
const pos = editor.view.state.selection.from;
|
||||||
|
|
||||||
|
uploadPdfAction(file, editor, pos, pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "File attachment",
|
title: "File attachment",
|
||||||
description: "Upload any file from your device.",
|
description: "Upload any file from your device.",
|
||||||
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
|
||||||
icon: IconPaperclip,
|
icon: IconPaperclip,
|
||||||
command: ({ editor, range }) => {
|
command: ({ editor, range }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
Drawio,
|
Drawio,
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
Embed,
|
Embed,
|
||||||
|
TiptapPdf,
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
Mention,
|
Mention,
|
||||||
TableDndExtension,
|
TableDndExtension,
|
||||||
@@ -75,6 +76,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
|||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
|
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
@@ -318,6 +320,9 @@ export const mainExtensions = [
|
|||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
}),
|
}),
|
||||||
|
TiptapPdf.configure({
|
||||||
|
view: PdfView,
|
||||||
|
}),
|
||||||
Subpages.configure({
|
Subpages.configure({
|
||||||
view: SubpagesView,
|
view: SubpagesView,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
|||||||
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||||
import AudioMenu from "@/features/editor/components/audio/audio-menu.tsx";
|
import AudioMenu from "@/features/editor/components/audio/audio-menu.tsx";
|
||||||
|
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
|
||||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||||
import {
|
import {
|
||||||
handleFileDrop,
|
handleFileDrop,
|
||||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
|||||||
<ImageMenu editor={editor} />
|
<ImageMenu editor={editor} />
|
||||||
<VideoMenu editor={editor} />
|
<VideoMenu editor={editor} />
|
||||||
<AudioMenu editor={editor} />
|
<AudioMenu editor={editor} />
|
||||||
|
<PdfMenu editor={editor} />
|
||||||
<CalloutMenu editor={editor} />
|
<CalloutMenu editor={editor} />
|
||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-image, .node-video, .node-excalidraw, .node-drawio {
|
.node-image, .node-video, .node-pdf, .node-excalidraw, .node-drawio {
|
||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TiptapAudio,
|
TiptapAudio,
|
||||||
|
TiptapPdf,
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
Attachment,
|
Attachment,
|
||||||
Drawio,
|
Drawio,
|
||||||
@@ -88,6 +89,7 @@ export const tiptapExtensions = [
|
|||||||
TiptapImage,
|
TiptapImage,
|
||||||
TiptapVideo,
|
TiptapVideo,
|
||||||
TiptapAudio,
|
TiptapAudio,
|
||||||
|
TiptapPdf,
|
||||||
Callout,
|
Callout,
|
||||||
Attachment,
|
Attachment,
|
||||||
CustomCodeBlock,
|
CustomCodeBlock,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function isAttachmentNode(nodeType: string) {
|
|||||||
'image',
|
'image',
|
||||||
'video',
|
'video',
|
||||||
'audio',
|
'audio',
|
||||||
|
'pdf',
|
||||||
'excalidraw',
|
'excalidraw',
|
||||||
'drawio',
|
'drawio',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -457,6 +457,10 @@ export class AttachmentController {
|
|||||||
const rangeHeader = req.headers.range;
|
const rangeHeader = req.headers.range;
|
||||||
|
|
||||||
res.header('Accept-Ranges', 'bytes');
|
res.header('Accept-Ranges', 'bytes');
|
||||||
|
res.header(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
||||||
|
);
|
||||||
|
|
||||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||||
res.header(
|
res.header(
|
||||||
|
|||||||
@@ -443,7 +443,16 @@ export class ImportAttachmentService {
|
|||||||
|
|
||||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
||||||
|
|
||||||
if (ext === '.mp4') {
|
if (ext === '.pdf') {
|
||||||
|
const $pdf = $('<div>')
|
||||||
|
.attr('data-type', 'pdf')
|
||||||
|
.attr('src', apiFilePath)
|
||||||
|
.attr('data-attachment-id', attachmentId)
|
||||||
|
.attr('width', '800')
|
||||||
|
.attr('height', '600');
|
||||||
|
$a.replaceWith($pdf);
|
||||||
|
unwrapFromParagraph($, $pdf);
|
||||||
|
} else if (ext === '.mp4') {
|
||||||
const $video = $('<video>')
|
const $video = $('<video>')
|
||||||
.attr('src', apiFilePath)
|
.attr('src', apiFilePath)
|
||||||
.attr('data-attachment-id', attachmentId)
|
.attr('data-attachment-id', attachmentId)
|
||||||
@@ -603,7 +612,7 @@ export class ImportAttachmentService {
|
|||||||
// Post-process DOM elements to add file sizes after uploads complete
|
// Post-process DOM elements to add file sizes after uploads complete
|
||||||
// This avoids blocking file operations during initial DOM processing
|
// This avoids blocking file operations during initial DOM processing
|
||||||
const elementsNeedingSize = $(
|
const elementsNeedingSize = $(
|
||||||
'[data-attachment-id]:not([data-attachment-size])',
|
'[data-attachment-id]:not([data-attachment-size]):not([data-size])',
|
||||||
);
|
);
|
||||||
for (const element of elementsNeedingSize.toArray()) {
|
for (const element of elementsNeedingSize.toArray()) {
|
||||||
const $el = $(element);
|
const $el = $(element);
|
||||||
@@ -618,7 +627,14 @@ export class ImportAttachmentService {
|
|||||||
if (processedEntry) {
|
if (processedEntry) {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(processedEntry.abs);
|
const stat = await fs.stat(processedEntry.abs);
|
||||||
$el.attr('data-attachment-size', stat.size.toString());
|
const sizeStr = stat.size.toString();
|
||||||
|
const tagName = $el.prop('tagName')?.toLowerCase();
|
||||||
|
// audio and pdf nodes use data-size, attachment nodes use data-attachment-size
|
||||||
|
if (tagName === 'audio' || $el.attr('data-type') === 'pdf') {
|
||||||
|
$el.attr('data-size', sizeStr);
|
||||||
|
} else {
|
||||||
|
$el.attr('data-attachment-size', sizeStr);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Could not get size for ${processedEntry.abs}:`,
|
`Could not get size for ${processedEntry.abs}:`,
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ export * from "./lib/shared-storage";
|
|||||||
export * from "./lib/recreate-transform";
|
export * from "./lib/recreate-transform";
|
||||||
export * from "./lib/columns";
|
export * from "./lib/columns";
|
||||||
export * from "./lib/status";
|
export * from "./lib/status";
|
||||||
|
export * from "./lib/pdf";
|
||||||
export * from "./lib/resizable-nodeview";
|
export * from "./lib/resizable-nodeview";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { normalizeFileUrl } from "../media-utils";
|
import { normalizeFileUrl } from "../media-utils";
|
||||||
|
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||||
|
|
||||||
export interface AudioOptions {
|
export interface AudioOptions {
|
||||||
view: any;
|
view: any;
|
||||||
@@ -45,9 +46,15 @@ export const TiptapAudio = Node.create<AudioOptions>({
|
|||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
default: "",
|
default: "",
|
||||||
parseHTML: (element) => element.getAttribute("src"),
|
parseHTML: (element) => {
|
||||||
|
const src = element.getAttribute("src");
|
||||||
|
const sanitized = sanitizeUrl(src);
|
||||||
|
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||||
|
},
|
||||||
renderHTML: (attributes) => ({
|
renderHTML: (attributes) => ({
|
||||||
src: attributes.src,
|
src: isInternalFileUrl(attributes.src)
|
||||||
|
? sanitizeUrl(attributes.src)
|
||||||
|
: "",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
attachmentId: {
|
attachmentId: {
|
||||||
@@ -113,7 +120,10 @@ export const TiptapAudio = Node.create<AudioOptions>({
|
|||||||
return ({ node, HTMLAttributes }) => {
|
return ({ node, HTMLAttributes }) => {
|
||||||
const dom = document.createElement("div");
|
const dom = document.createElement("div");
|
||||||
const audio = document.createElement("audio");
|
const audio = document.createElement("audio");
|
||||||
audio.src = normalizeFileUrl(node.attrs.src);
|
const src = node.attrs.src;
|
||||||
|
if (src && isInternalFileUrl(src)) {
|
||||||
|
audio.src = normalizeFileUrl(src);
|
||||||
|
}
|
||||||
audio.controls = true;
|
audio.controls = true;
|
||||||
audio.preload = "metadata";
|
audio.preload = "metadata";
|
||||||
audio.style.width = "100%";
|
audio.style.width = "100%";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { TiptapPdf } from "./pdf";
|
||||||
|
export * from "./pdf-upload";
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 findPdfNodeByPlaceholderId = (
|
||||||
|
doc: Node,
|
||||||
|
placeholderId: string,
|
||||||
|
): { node: Node; pos: number } | null => {
|
||||||
|
let result: { node: Node; pos: number } | null = null;
|
||||||
|
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (result) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type.name === "pdf" &&
|
||||||
|
node.attrs.placeholder?.id === placeholderId
|
||||||
|
) {
|
||||||
|
result = { node, pos };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdfUpload =
|
||||||
|
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||||
|
async (file, editor, pos, pageId) => {
|
||||||
|
const validated = validateFn?.(file);
|
||||||
|
// @ts-ignore
|
||||||
|
if (!validated) return;
|
||||||
|
|
||||||
|
const placeholderId = generateNodeId();
|
||||||
|
|
||||||
|
let placeholderInserted = false;
|
||||||
|
|
||||||
|
const insertPlaceholder = (): Command => {
|
||||||
|
return ({ tr, state }) => {
|
||||||
|
const initialPlaceholderNode = state.schema.nodes.pdf?.create({
|
||||||
|
placeholder: {
|
||||||
|
id: placeholderId,
|
||||||
|
name: file.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initialPlaceholderNode) return false;
|
||||||
|
|
||||||
|
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 replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
|
||||||
|
return ({ tr }) => {
|
||||||
|
const { pos: currentPos = null } =
|
||||||
|
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
|
|
||||||
|
if (currentPos === null || !attachment) return;
|
||||||
|
|
||||||
|
tr.setNodeMarkup(currentPos, undefined, {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||||
|
name: attachment.fileName,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlaceholder = (): Command => {
|
||||||
|
return ({ tr }) => {
|
||||||
|
const { pos: currentPos = null } =
|
||||||
|
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||||
|
|
||||||
|
if (currentPos === null) return false;
|
||||||
|
|
||||||
|
tr.delete(currentPos, currentPos + 2);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertPlaceholderTimeout = setTimeout(() => {
|
||||||
|
editor.commands.command(insertPlaceholder());
|
||||||
|
placeholderInserted = true;
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachment: IAttachment = await onUpload(file, pageId);
|
||||||
|
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
|
||||||
|
if (placeholderInserted) {
|
||||||
|
setTimeout(() => {
|
||||||
|
editor.commands.command(replacePlaceholderWithPdf(attachment));
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.command(insertPlaceholder())
|
||||||
|
.command(replacePlaceholderWithPdf(attachment))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(insertPlaceholderTimeout);
|
||||||
|
editor.commands.command(removePlaceholder());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { handlePdfUpload };
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||||
|
|
||||||
|
export type PdfOptions = {
|
||||||
|
view: any;
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfAttributes = {
|
||||||
|
src?: string;
|
||||||
|
name?: string;
|
||||||
|
attachmentId?: string;
|
||||||
|
size?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
placeholder?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
pdfBlock: {
|
||||||
|
setPdf: (attributes: PdfAttributes) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TiptapPdf = Node.create<PdfOptions>({
|
||||||
|
name: "pdf",
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
isolating: true,
|
||||||
|
atom: true,
|
||||||
|
defining: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
view: null,
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: "",
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const src = element.getAttribute("src");
|
||||||
|
const sanitized = sanitizeUrl(src);
|
||||||
|
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
default: undefined,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-name"),
|
||||||
|
renderHTML: (attributes: PdfAttributes) => ({
|
||||||
|
"data-name": attributes.name,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
attachmentId: {
|
||||||
|
default: undefined,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||||
|
renderHTML: (attributes: PdfAttributes) => ({
|
||||||
|
"data-attachment-id": attributes.attachmentId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
|
renderHTML: (attributes: PdfAttributes) => ({
|
||||||
|
"data-size": attributes.size,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: 800,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("width");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: PdfAttributes) => ({
|
||||||
|
width: attributes.width,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: 600,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: PdfAttributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
default: null,
|
||||||
|
rendered: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
[
|
||||||
|
"iframe",
|
||||||
|
{
|
||||||
|
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
|
||||||
|
width: HTMLAttributes.width || 800,
|
||||||
|
height: HTMLAttributes.height || 600,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setPdf:
|
||||||
|
(attrs: PdfAttributes) =>
|
||||||
|
({ commands }) => {
|
||||||
|
return commands.insertContent({
|
||||||
|
type: "pdf",
|
||||||
|
attrs,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -382,6 +382,12 @@ export function sanitizeUrl(url: string | undefined): string {
|
|||||||
return sanitized === "about:blank" ? "" : sanitized;
|
return sanitized === "about:blank" ? "" : sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isInternalFileUrl(url: string | undefined): boolean {
|
||||||
|
if (!url) return false;
|
||||||
|
const normalized = url.trim();
|
||||||
|
return normalized.startsWith("/api/files/") || normalized.startsWith("/files/");
|
||||||
|
}
|
||||||
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
const alphabet = "abcdefghijklmnopqrstuvwxyz";
|
||||||
export const generateNodeId = customAlphabet(alphabet, 12);
|
export const generateNodeId = customAlphabet(alphabet, 12);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user