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.",
|
||||
"Image": "Image.",
|
||||
"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.",
|
||||
"Toggle block": "Toggle block.",
|
||||
"Callout": "Callout.",
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
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 { IconDownload, IconPaperclip } from "@tabler/icons-react";
|
||||
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
|
||||
import { useHover } from "@mantine/hooks";
|
||||
import { formatBytes } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export default function AttachmentView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected } = props;
|
||||
const { url, name, size } = node.attrs;
|
||||
const { editor, node, getPos, selected } = props;
|
||||
const { url, name, size, mime, attachmentId } = node.attrs;
|
||||
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 (
|
||||
<NodeViewWrapper>
|
||||
<Paper withBorder p="4px" ref={ref} data-drag-handle>
|
||||
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
|
||||
</Group>
|
||||
|
||||
{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">
|
||||
<ActionIcon variant="default" aria-label="download file">
|
||||
<IconDownload size={18} />
|
||||
</ActionIcon>
|
||||
</a>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Group, Loader, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { isInternalFileUrl } from "@docmost/editor-ext";
|
||||
import classes from "./audio-view.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -10,6 +11,11 @@ export default function AudioView(props: NodeViewProps) {
|
||||
const { editor, node } = props;
|
||||
const { src, placeholder } = node.attrs;
|
||||
|
||||
const safeSrc = useMemo(() => {
|
||||
if (!src || !isInternalFileUrl(src)) return null;
|
||||
return getFileUrl(src);
|
||||
}, [src]);
|
||||
|
||||
const previewSrc = useMemo(() => {
|
||||
editor.storage.shared.audioPreviews =
|
||||
editor.storage.shared.audioPreviews || {};
|
||||
@@ -23,16 +29,16 @@ export default function AudioView(props: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={`${classes.audioWrapper} ${!src ? classes.skeleton : ''}`}>
|
||||
{src && (
|
||||
<div className={`${classes.audioWrapper} ${!safeSrc ? classes.skeleton : ''}`}>
|
||||
{safeSrc && (
|
||||
<audio
|
||||
className={classes.audio}
|
||||
preload="metadata"
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
src={safeSrc}
|
||||
/>
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
{!safeSrc && previewSrc && (
|
||||
<Group pos="relative" w="100%">
|
||||
<audio
|
||||
className={classes.audio}
|
||||
@@ -43,7 +49,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && (
|
||||
{!safeSrc && !previewSrc && (
|
||||
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
|
||||
<Loader size={20} style={{ flexShrink: 0 }} />
|
||||
<Text component="span" size="sm" truncate="end">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { uploadPdfAction } from "../pdf/upload-pdf-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"pdf",
|
||||
"attachment",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
@@ -63,6 +66,7 @@ export const handlePaste = (
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadPdfAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
@@ -229,6 +233,7 @@ export const handleFileDrop = (
|
||||
|
||||
uploadImageAction(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);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
.cornerHandle {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -42,13 +42,13 @@
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 28px;
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 28px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
|
||||
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
const constraintsRef = useRef({ 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 drag = dragRef.current;
|
||||
if (!drag || !wrapperRef.current) return;
|
||||
|
||||
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
|
||||
<iframe
|
||||
className={classes.embedIframe}
|
||||
src={sanitizeUrl(embedUrl)}
|
||||
allow="encrypted-media"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
|
||||
allowFullScreen
|
||||
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,
|
||||
IconMusic,
|
||||
IconPaperclip,
|
||||
IconFileTypePdf,
|
||||
IconPhoto,
|
||||
IconTable,
|
||||
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 { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-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 IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
@@ -259,10 +261,41 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
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",
|
||||
description: "Upload any file from your device.",
|
||||
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
|
||||
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
|
||||
icon: IconPaperclip,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Drawio,
|
||||
Excalidraw,
|
||||
Embed,
|
||||
TiptapPdf,
|
||||
SearchAndReplace,
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
@@ -75,6 +76,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-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 { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
@@ -318,6 +320,9 @@ export const mainExtensions = [
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
}),
|
||||
TiptapPdf.configure({
|
||||
view: PdfView,
|
||||
}),
|
||||
Subpages.configure({
|
||||
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 VideoMenu from "@/features/editor/components/video/video-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 {
|
||||
handleFileDrop,
|
||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
||||
<ImageMenu editor={editor} />
|
||||
<VideoMenu editor={editor} />
|
||||
<AudioMenu editor={editor} />
|
||||
<PdfMenu editor={editor} />
|
||||
<CalloutMenu editor={editor} />
|
||||
<SubpagesMenu 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 {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
TrailingNode,
|
||||
Attachment,
|
||||
Drawio,
|
||||
@@ -88,6 +89,7 @@ export const tiptapExtensions = [
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
Callout,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
|
||||
@@ -103,6 +103,7 @@ export function isAttachmentNode(nodeType: string) {
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'pdf',
|
||||
'excalidraw',
|
||||
'drawio',
|
||||
];
|
||||
|
||||
@@ -457,6 +457,10 @@ export class AttachmentController {
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
res.header('Accept-Ranges', 'bytes');
|
||||
res.header(
|
||||
'Content-Security-Policy',
|
||||
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
||||
);
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
|
||||
@@ -443,7 +443,16 @@ export class ImportAttachmentService {
|
||||
|
||||
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>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
@@ -603,7 +612,7 @@ export class ImportAttachmentService {
|
||||
// Post-process DOM elements to add file sizes after uploads complete
|
||||
// This avoids blocking file operations during initial DOM processing
|
||||
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()) {
|
||||
const $el = $(element);
|
||||
@@ -618,7 +627,14 @@ export class ImportAttachmentService {
|
||||
if (processedEntry) {
|
||||
try {
|
||||
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) {
|
||||
this.logger.debug(
|
||||
`Could not get size for ${processedEntry.abs}:`,
|
||||
|
||||
@@ -28,5 +28,6 @@ export * from "./lib/shared-storage";
|
||||
export * from "./lib/recreate-transform";
|
||||
export * from "./lib/columns";
|
||||
export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { normalizeFileUrl } from "../media-utils";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "../utils";
|
||||
|
||||
export interface AudioOptions {
|
||||
view: any;
|
||||
@@ -45,9 +46,15 @@ export const TiptapAudio = Node.create<AudioOptions>({
|
||||
return {
|
||||
src: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("src"),
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute("src");
|
||||
const sanitized = sanitizeUrl(src);
|
||||
return isInternalFileUrl(sanitized) ? sanitized : "";
|
||||
},
|
||||
renderHTML: (attributes) => ({
|
||||
src: attributes.src,
|
||||
src: isInternalFileUrl(attributes.src)
|
||||
? sanitizeUrl(attributes.src)
|
||||
: "",
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
@@ -113,7 +120,10 @@ export const TiptapAudio = Node.create<AudioOptions>({
|
||||
return ({ node, HTMLAttributes }) => {
|
||||
const dom = document.createElement("div");
|
||||
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.preload = "metadata";
|
||||
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;
|
||||
}
|
||||
|
||||
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";
|
||||
export const generateNodeId = customAlphabet(alphabet, 12);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user