feat: draw.io (diagrams.net) integration (#215)

* draw.io init

* updates
This commit is contained in:
Philip Okugbe
2024-09-01 12:26:20 +01:00
committed by GitHub
parent 38e9eef2dc
commit 87b99f8646
14 changed files with 555 additions and 110 deletions

View File

@ -38,6 +38,7 @@
"react-arborist": "^3.4.0", "react-arborist": "^3.4.0",
"react-clear-modal": "^2.0.9", "react-clear-modal": "^2.0.9",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0", "react-moveable": "^0.56.0",

View File

@ -0,0 +1,20 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
function IconDrawio({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#F08705"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
</svg>
);
}
export default IconDrawio;

View File

@ -0,0 +1,82 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
},
[editor]
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio';
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` });
},
[editor]
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{editor.getAttributes('drawio')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)}
</div>
</BaseBubbleMenu>
);
}
export default DrawioMenu;

View File

@ -0,0 +1,173 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
import { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from 'react-drawio';
import { IAttachment } from '@/lib/types';
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
export default function DrawioView(props: NodeViewProps) {
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
let base64data = (reader.result || '') as string;
setInitialXML(base64data);
};
}
} catch (err) {
console.error(err);
} finally {
open();
}
};
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = 'diagram.drawio.svg';
const drawioSVGFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
updateAttributes({
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
};
return (
<NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Body>
<div style={{ height: '100vh' }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
urlParameters={{
ui: 'kennedy',
spin: true,
libraries: true,
saveAndExit: true,
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== 'save') {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
// If the exit is triggered by another event, then do nothing
if (data.parentEvent) {
return;
}
close();
}}
/>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
{src ? (
<div style={{ position: 'relative' }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
)}
/>
{selected && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
</div>
) : (
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit drawio diagram
</Text>
</div>
</Card>
)}
</NodeViewWrapper>
);
}

View File

@ -36,15 +36,16 @@ export default function ExcalidrawView(props: NodeViewProps) {
} }
try { try {
let data = null;
if (src) { if (src) {
const url = getFileUrl(src); const url = getFileUrl(src);
const request = await fetch(url, { credentials: 'include' }); const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
});
data = await loadFromBlob(await request.blob(), null, null); const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
} }
setExcalidrawData(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@ -69,7 +70,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg); let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, 'https://unpkg.com/@excalidraw/excalidraw@latest'); svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
'https://unpkg.com/@excalidraw/excalidraw@latest'
);
const fileName = 'diagram.excalidraw.svg'; const fileName = 'diagram.excalidraw.svg';
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); const excalidrawSvgFile = await svgStringToFile(svgString, fileName);

View File

@ -17,122 +17,123 @@ import {
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import { import {
CommandProps, CommandProps,
SlashMenuGroupedItemsType, SlashMenuGroupedItemsType,
} from "@/features/editor/components/slash-menu/types"; } from '@/features/editor/components/slash-menu/types';
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 "@/features/editor/components/attachment/upload-attachment-action.tsx"; import { uploadAttachmentAction } from '@/features/editor/components/attachment/upload-attachment-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';
const CommandGroups: SlashMenuGroupedItemsType = { const CommandGroups: SlashMenuGroupedItemsType = {
basic: [ basic: [
{ {
title: "Text", title: 'Text',
description: "Just start typing with plain text.", description: 'Just start typing with plain text.',
searchTerms: ["p", "paragraph"], searchTerms: ['p', 'paragraph'],
icon: IconTypography, icon: IconTypography,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.toggleNode("paragraph", "paragraph") .toggleNode('paragraph', 'paragraph')
.run(); .run();
}, },
}, },
{ {
title: "To-do list", title: 'To-do list',
description: "Track tasks with a to-do list.", description: 'Track tasks with a to-do list.',
searchTerms: ["todo", "task", "list", "check", "checkbox"], searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'],
icon: IconCheckbox, icon: IconCheckbox,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run(); editor.chain().focus().deleteRange(range).toggleTaskList().run();
}, },
}, },
{ {
title: "Heading 1", title: 'Heading 1',
description: "Big section heading.", description: 'Big section heading.',
searchTerms: ["title", "big", "large"], searchTerms: ['title', 'big', 'large'],
icon: IconH1, icon: IconH1,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.setNode("heading", { level: 1 }) .setNode('heading', { level: 1 })
.run(); .run();
}, },
}, },
{ {
title: "Heading 2", title: 'Heading 2',
description: "Medium section heading.", description: 'Medium section heading.',
searchTerms: ["subtitle", "medium"], searchTerms: ['subtitle', 'medium'],
icon: IconH2, icon: IconH2,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.setNode("heading", { level: 2 }) .setNode('heading', { level: 2 })
.run(); .run();
}, },
}, },
{ {
title: "Heading 3", title: 'Heading 3',
description: "Small section heading.", description: 'Small section heading.',
searchTerms: ["subtitle", "small"], searchTerms: ['subtitle', 'small'],
icon: IconH3, icon: IconH3,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.setNode("heading", { level: 3 }) .setNode('heading', { level: 3 })
.run(); .run();
}, },
}, },
{ {
title: "Bullet list", title: 'Bullet list',
description: "Create a simple bullet list.", description: 'Create a simple bullet list.',
searchTerms: ["unordered", "point", "list"], searchTerms: ['unordered', 'point', 'list'],
icon: IconList, icon: IconList,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run(); editor.chain().focus().deleteRange(range).toggleBulletList().run();
}, },
}, },
{ {
title: "Numbered list", title: 'Numbered list',
description: "Create a list with numbering.", description: 'Create a list with numbering.',
searchTerms: ["numbered", "ordered", "list"], searchTerms: ['numbered', 'ordered', 'list'],
icon: IconListNumbers, icon: IconListNumbers,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run(); editor.chain().focus().deleteRange(range).toggleOrderedList().run();
}, },
}, },
{ {
title: "Quote", title: 'Quote',
description: "Create block quote.", description: 'Create block quote.',
searchTerms: ["blockquote", "quotes"], searchTerms: ['blockquote', 'quotes'],
icon: IconBlockquote, icon: IconBlockquote,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleBlockquote().run(), editor.chain().focus().deleteRange(range).toggleBlockquote().run(),
}, },
{ {
title: "Code", title: 'Code',
description: "Capture a code snippet.", description: 'Capture a code snippet.',
searchTerms: ["codeblock"], searchTerms: ['codeblock'],
icon: IconCode, icon: IconCode,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
}, },
{ {
title: "Image", title: 'Image',
description: "Upload any image from your device.", description: 'Upload any image from your device.',
searchTerms: ["photo", "picture", "media"], searchTerms: ['photo', 'picture', 'media'],
icon: IconPhoto, icon: IconPhoto,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@ -141,9 +142,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (!pageId) return; if (!pageId) return;
// upload image // upload image
const input = document.createElement("input"); const input = document.createElement('input');
input.type = "file"; input.type = 'file';
input.accept = "image/*"; input.accept = 'image/*';
input.multiple = true; input.multiple = true;
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
@ -157,9 +158,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
}, },
{ {
title: "Video", title: 'Video',
description: "Upload any video from your device.", description: 'Upload any video from your device.',
searchTerms: ["video", "mp4", "media"], searchTerms: ['video', 'mp4', 'media'],
icon: IconMovie, icon: IconMovie,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@ -168,9 +169,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (!pageId) return; if (!pageId) return;
// upload video // upload video
const input = document.createElement("input"); const input = document.createElement('input');
input.type = "file"; input.type = 'file';
input.accept = "video/*"; input.accept = 'video/*';
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; const file = input.files[0];
@ -182,9 +183,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
}, },
{ {
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', 'pdf', 'csv', 'zip'],
icon: IconPaperclip, icon: IconPaperclip,
command: ({ editor, range }) => { command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
@ -193,16 +194,16 @@ const CommandGroups: SlashMenuGroupedItemsType = {
if (!pageId) return; if (!pageId) return;
// upload file // upload file
const input = document.createElement("input"); const input = document.createElement('input');
input.type = "file"; input.type = 'file';
input.accept = ""; input.accept = '';
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; const file = input.files[0];
const pos = editor.view.state.selection.from; const pos = editor.view.state.selection.from;
if (file.type.includes("image/*")) { if (file.type.includes('image/*')) {
uploadImageAction(file, editor.view, pos, pageId); uploadImageAction(file, editor.view, pos, pageId);
} else if (file.type.includes("video/*")) { } else if (file.type.includes('video/*')) {
uploadVideoAction(file, editor.view, pos, pageId); uploadVideoAction(file, editor.view, pos, pageId);
} else { } else {
uploadAttachmentAction(file, editor.view, pos, pageId); uploadAttachmentAction(file, editor.view, pos, pageId);
@ -213,9 +214,9 @@ const CommandGroups: SlashMenuGroupedItemsType = {
}, },
}, },
{ {
title: "Table", title: 'Table',
description: "Insert a table.", description: 'Insert a table.',
searchTerms: ["table", "rows", "columns"], searchTerms: ['table', 'rows', 'columns'],
icon: IconTable, icon: IconTable,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor editor
@ -226,43 +227,43 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(), .run(),
}, },
{ {
title: "Toggle block", title: 'Toggle block',
description: "Insert collapsible block.", description: 'Insert collapsible block.',
searchTerms: ["collapsible", "block", "toggle", "details", "expand"], searchTerms: ['collapsible', 'block', 'toggle', 'details', 'expand'],
icon: IconCaretRightFilled, icon: IconCaretRightFilled,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleDetails().run(), editor.chain().focus().deleteRange(range).toggleDetails().run(),
}, },
{ {
title: "Callout", title: 'Callout',
description: "Insert callout notice.", description: 'Insert callout notice.',
searchTerms: [ searchTerms: [
"callout", 'callout',
"notice", 'notice',
"panel", 'panel',
"info", 'info',
"warning", 'warning',
"success", 'success',
"error", 'error',
"danger", 'danger',
], ],
icon: IconInfoCircle, icon: IconInfoCircle,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCallout().run(), editor.chain().focus().deleteRange(range).toggleCallout().run(),
}, },
{ {
title: "Math inline", title: 'Math inline',
description: "Insert inline math equation.", description: 'Insert inline math equation.',
searchTerms: [ searchTerms: [
"math", 'math',
"inline", 'inline',
"mathinline", 'mathinline',
"inlinemath", 'inlinemath',
"inline math", 'inline math',
"equation", 'equation',
"katex", 'katex',
"latex", 'latex',
"tex", 'tex',
], ],
icon: IconMathFunction, icon: IconMathFunction,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
@ -275,39 +276,47 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run(), .run(),
}, },
{ {
title: "Math block", title: 'Math block',
description: "Insert math equation", description: 'Insert math equation',
searchTerms: [ searchTerms: [
"math", 'math',
"block", 'block',
"mathblock", 'mathblock',
"block math", 'block math',
"equation", 'equation',
"katex", 'katex',
"latex", 'latex',
"tex", 'tex',
], ],
icon: IconMath, icon: IconMath,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setMathBlock().run(), editor.chain().focus().deleteRange(range).setMathBlock().run(),
}, },
{ {
title: "Mermaid diagram", title: 'Mermaid diagram',
description: "Insert mermaid diagram", description: 'Insert mermaid diagram',
searchTerms: ["mermaid", "diagrams", "chart", "uml"], searchTerms: ['mermaid', 'diagrams', 'chart', 'uml'],
icon: IconMermaid, icon: IconMermaid,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor editor
.chain() .chain()
.focus() .focus()
.deleteRange(range) .deleteRange(range)
.setCodeBlock({ language: "mermaid" }) .setCodeBlock({ language: 'mermaid' })
.run(), .run(),
}, },
{ {
title: "Excalidraw diagram", title: 'Draw.io (diagrams.net) ',
description: "Draw and sketch excalidraw diagrams", description: 'Insert and design Drawio diagrams',
searchTerms: ["diagrams", "draw", "sketch"], searchTerms: ['drawio', 'diagrams', 'charts', 'uml'],
icon: IconDrawio,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
title: 'Excalidraw diagram',
description: 'Draw and sketch excalidraw diagrams',
searchTerms: ['diagrams', 'draw', 'sketch'],
icon: IconExcalidraw, icon: IconExcalidraw,
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setExcalidraw().run(), editor.chain().focus().deleteRange(range).setExcalidraw().run(),

View File

@ -33,6 +33,7 @@ import {
Selection, Selection,
Attachment, Attachment,
CustomCodeBlock, CustomCodeBlock,
Drawio,
Excalidraw, Excalidraw,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
@ -50,6 +51,7 @@ import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx"; import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
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 plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
@ -154,6 +156,9 @@ export const mainExtensions = [
Attachment.configure({ Attachment.configure({
view: AttachmentView, view: AttachmentView,
}), }),
Drawio.configure({
view: DrawioView,
}),
Excalidraw.configure({ Excalidraw.configure({
view: ExcalidrawView, view: ExcalidrawView,
}), }),

View File

@ -38,6 +38,7 @@ import {
} from "@/features/editor/components/common/file-upload-handler.tsx"; } from "@/features/editor/components/common/file-upload-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@ -173,6 +174,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
<VideoMenu editor={editor} /> <VideoMenu editor={editor} />
<CalloutMenu editor={editor} /> <CalloutMenu editor={editor} />
<ExcalidrawMenu editor={editor} /> <ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} /> <LinkMenu editor={editor} appendTo={menuContainerRef} />
</div> </div>
)} )}

View File

@ -4,7 +4,7 @@
height: auto; height: auto;
} }
.node-image, .node-video, .node-excalidraw{ .node-image, .node-video, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode { &.ProseMirror-selectednode {
outline: none; outline: none;
} }

View File

@ -52,3 +52,12 @@ export async function svgStringToFile(
const blob = new Blob([svgString], { type: "image/svg+xml" }); const blob = new Blob([svgString], { type: "image/svg+xml" });
return new File([blob], fileName, { type: "image/svg+xml" }); return new File([blob], fileName, { type: "image/svg+xml" });
} }
export function decodeBase64ToSvgString(base64Data: string): string {
const base64Prefix = 'data:image/svg+xml;base64,';
if (base64Data.startsWith(base64Prefix)) {
base64Data = base64Data.replace(base64Prefix, '');
}
return atob(base64Data);
}

View File

@ -28,6 +28,7 @@ import {
TiptapVideo, TiptapVideo,
TrailingNode, TrailingNode,
Attachment, Attachment,
Drawio,
Excalidraw, Excalidraw,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, JSONContent } from '@tiptap/core'; import { generateText, JSONContent } from '@tiptap/core';
@ -69,6 +70,7 @@ export const tiptapExtensions = [
Callout, Callout,
Attachment, Attachment,
CustomCodeBlock, CustomCodeBlock,
Drawio,
Excalidraw, Excalidraw,
] as any; ] as any;

View File

@ -11,5 +11,7 @@ export * from "./lib/media-utils";
export * from "./lib/link"; export * from "./lib/link";
export * from "./lib/selection"; export * from "./lib/selection";
export * from "./lib/attachment"; export * from "./lib/attachment";
export * from "./lib/custom-code-block"; export * from "./lib/custom-code-block"
export * from "./lib/drawio";
export * from "./lib/excalidraw"; export * from "./lib/excalidraw";

View File

@ -0,0 +1,124 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
export interface DrawioOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export interface DrawioAttributes {
src?: string;
title?: string;
size?: number;
width?: string;
align?: string;
attachmentId?: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
drawio: {
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
};
}
}
export const Drawio = Node.create<DrawioOptions>({
name: "drawio",
inline: false,
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addAttributes() {
return {
src: {
default: '',
parseHTML: (element) => element.getAttribute('data-src'),
renderHTML: (attributes) => ({
'data-src': attributes.src,
}),
},
title: {
default: undefined,
parseHTML: (element) => element.getAttribute('data-title'),
renderHTML: (attributes: DrawioAttributes) => ({
'data-title': attributes.title,
}),
},
width: {
default: '100%',
parseHTML: (element) => element.getAttribute('data-width'),
renderHTML: (attributes: DrawioAttributes) => ({
'data-width': attributes.width,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute('data-size'),
renderHTML: (attributes: DrawioAttributes) => ({
'data-size': attributes.size,
}),
},
align: {
default: 'center',
parseHTML: (element) => element.getAttribute('data-align'),
renderHTML: (attributes: DrawioAttributes) => ({
'data-align': attributes.align,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute('data-attachment-id'),
renderHTML: (attributes: DrawioAttributes) => ({
'data-attachment-id': attributes.attachmentId,
}),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(
{ 'data-type': this.name },
this.options.HTMLAttributes,
HTMLAttributes
),
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
];
},
addCommands() {
return {
setDrawio:
(attrs: DrawioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "drawio",
attrs: attrs,
});
},
};
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
});

12
pnpm-lock.yaml generated
View File

@ -261,6 +261,9 @@ importers:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-drawio:
specifier: ^0.2.0
version: 0.2.0(react@18.3.1)
react-error-boundary: react-error-boundary:
specifier: ^4.0.13 specifier: ^4.0.13
version: 4.0.13(react@18.3.1) version: 4.0.13(react@18.3.1)
@ -7408,6 +7411,11 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 react: ^18.3.1
react-drawio@0.2.0:
resolution: {integrity: sha512-LQRk8miMq7ats+ram6M9DjR77ur1PaweWMf26mWQha9nvBMC98KcT1OyjISvOMhU+v1JCdUqMkTuGMNMD9nMew==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-email@2.1.4: react-email@2.1.4:
resolution: {integrity: sha512-YKZ4jhkalWcNyaw4qyI//+QeTeUxe/ptqI+wSc4wVIoHzqffAWoV5x/jBpFex3FQ636xVIDFrvGq39rUVL7zSQ==} resolution: {integrity: sha512-YKZ4jhkalWcNyaw4qyI//+QeTeUxe/ptqI+wSc4wVIoHzqffAWoV5x/jBpFex3FQ636xVIDFrvGq39rUVL7zSQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@ -16737,6 +16745,10 @@ snapshots:
react: 18.3.1 react: 18.3.1
scheduler: 0.23.2 scheduler: 0.23.2
react-drawio@0.2.0(react@18.3.1):
dependencies:
react: 18.3.1
react-email@2.1.4(@swc/helpers@0.5.11)(eslint@9.5.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.9)(typescript@5.5.2)): react-email@2.1.4(@swc/helpers@0.5.11)(eslint@9.5.0)(ts-node@10.9.2(@swc/core@1.5.25(@swc/helpers@0.5.11))(@types/node@20.14.9)(typescript@5.5.2)):
dependencies: dependencies:
'@babel/core': 7.24.5 '@babel/core': 7.24.5