feat: PDF embed

This commit is contained in:
Philipinho
2026-03-28 16:15:52 +00:00
parent c2b41d72bf
commit d8ec0472ef
25 changed files with 835 additions and 28 deletions
@@ -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}:`,
+1
View File
@@ -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";
+13 -3
View File
@@ -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%";
+2
View File
@@ -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 };
+156
View File
@@ -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);
},
});
+6
View File
@@ -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);