From 87b99f864606ee23ebad9a50bc53c7b1228e5b55 Mon Sep 17 00:00:00 2001 From: Philip Okugbe Date: Sun, 1 Sep 2024 12:26:20 +0100 Subject: [PATCH] feat: draw.io (diagrams.net) integration (#215) * draw.io init * updates --- apps/client/package.json | 1 + .../src/components/icons/icon-drawio.tsx | 20 ++ .../editor/components/drawio/drawio-menu.tsx | 82 +++++++ .../editor/components/drawio/drawio-view.tsx | 173 ++++++++++++++ .../components/excalidraw/excalidraw-view.tsx | 16 +- .../components/slash-menu/menu-items.ts | 213 +++++++++--------- .../features/editor/extensions/extensions.ts | 5 + .../src/features/editor/page-editor.tsx | 2 + .../src/features/editor/styles/media.css | 2 +- apps/client/src/lib/utils.ts | 9 + .../src/collaboration/collaboration.util.ts | 2 + packages/editor-ext/src/index.ts | 4 +- packages/editor-ext/src/lib/drawio.ts | 124 ++++++++++ pnpm-lock.yaml | 12 + 14 files changed, 555 insertions(+), 110 deletions(-) create mode 100644 apps/client/src/components/icons/icon-drawio.tsx create mode 100644 apps/client/src/features/editor/components/drawio/drawio-menu.tsx create mode 100644 apps/client/src/features/editor/components/drawio/drawio-view.tsx create mode 100644 packages/editor-ext/src/lib/drawio.ts diff --git a/apps/client/package.json b/apps/client/package.json index f38fe74a..49b72b4a 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -38,6 +38,7 @@ "react-arborist": "^3.4.0", "react-clear-modal": "^2.0.9", "react-dom": "^18.3.1", + "react-drawio": "^0.2.0", "react-error-boundary": "^4.0.13", "react-helmet-async": "^2.0.5", "react-moveable": "^0.56.0", diff --git a/apps/client/src/components/icons/icon-drawio.tsx b/apps/client/src/components/icons/icon-drawio.tsx new file mode 100644 index 00000000..32d3a3f4 --- /dev/null +++ b/apps/client/src/components/icons/icon-drawio.tsx @@ -0,0 +1,20 @@ +import { rem } from '@mantine/core'; + +interface Props { + size?: number | string; +} + +function IconDrawio({ size }: Props) { + return ( + + + + ); +} + +export default IconDrawio; diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx new file mode 100644 index 00000000..76771b10 --- /dev/null +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -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 ( + +
+ {editor.getAttributes('drawio')?.width && ( + + )} +
+
+ ); +} + +export default DrawioMenu; diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx new file mode 100644 index 00000000..a89869a2 --- /dev/null +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -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(null); + const [initialXML, setInitialXML] = useState(''); + 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 ( + + + + + +
+ { + // 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(); + }} + /> +
+
+
+
+ + {src ? ( +
+ e.detail === 2 && handleOpen()} + radius="md" + fit="contain" + w={width} + src={getFileUrl(src)} + alt={title} + className={clsx( + selected ? 'ProseMirror-selectednode' : '', + 'alignCenter' + )} + /> + + {selected && ( + + + + )} +
+ ) : ( + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }} + withBorder + className={clsx(selected ? 'ProseMirror-selectednode' : '')} + > +
+ + + + + + Double-click to edit drawio diagram + +
+
+ )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 960642d1..bc6aea89 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -36,15 +36,16 @@ export default function ExcalidrawView(props: NodeViewProps) { } try { - let data = null; if (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) { console.error(err); } finally { @@ -69,7 +70,10 @@ export default function ExcalidrawView(props: NodeViewProps) { const serializer = new XMLSerializer(); 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 excalidrawSvgFile = await svgStringToFile(svgString, fileName); diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index cfdfa1bf..bd86fcb6 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -17,122 +17,123 @@ import { IconPhoto, IconTable, IconTypography, -} from "@tabler/icons-react"; +} from '@tabler/icons-react'; import { CommandProps, SlashMenuGroupedItemsType, -} from "@/features/editor/components/slash-menu/types"; -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 "@/features/editor/components/attachment/upload-attachment-action.tsx"; -import IconExcalidraw from "@/components/icons/icon-excalidraw"; -import IconMermaid from "@/components/icons/icon-mermaid"; +} from '@/features/editor/components/slash-menu/types'; +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 '@/features/editor/components/attachment/upload-attachment-action.tsx'; +import IconExcalidraw from '@/components/icons/icon-excalidraw'; +import IconMermaid from '@/components/icons/icon-mermaid'; +import IconDrawio from '@/components/icons/icon-drawio'; const CommandGroups: SlashMenuGroupedItemsType = { basic: [ { - title: "Text", - description: "Just start typing with plain text.", - searchTerms: ["p", "paragraph"], + title: 'Text', + description: 'Just start typing with plain text.', + searchTerms: ['p', 'paragraph'], icon: IconTypography, command: ({ editor, range }: CommandProps) => { editor .chain() .focus() .deleteRange(range) - .toggleNode("paragraph", "paragraph") + .toggleNode('paragraph', 'paragraph') .run(); }, }, { - title: "To-do list", - description: "Track tasks with a to-do list.", - searchTerms: ["todo", "task", "list", "check", "checkbox"], + title: 'To-do list', + description: 'Track tasks with a to-do list.', + searchTerms: ['todo', 'task', 'list', 'check', 'checkbox'], icon: IconCheckbox, command: ({ editor, range }: CommandProps) => { editor.chain().focus().deleteRange(range).toggleTaskList().run(); }, }, { - title: "Heading 1", - description: "Big section heading.", - searchTerms: ["title", "big", "large"], + title: 'Heading 1', + description: 'Big section heading.', + searchTerms: ['title', 'big', 'large'], icon: IconH1, command: ({ editor, range }: CommandProps) => { editor .chain() .focus() .deleteRange(range) - .setNode("heading", { level: 1 }) + .setNode('heading', { level: 1 }) .run(); }, }, { - title: "Heading 2", - description: "Medium section heading.", - searchTerms: ["subtitle", "medium"], + title: 'Heading 2', + description: 'Medium section heading.', + searchTerms: ['subtitle', 'medium'], icon: IconH2, command: ({ editor, range }: CommandProps) => { editor .chain() .focus() .deleteRange(range) - .setNode("heading", { level: 2 }) + .setNode('heading', { level: 2 }) .run(); }, }, { - title: "Heading 3", - description: "Small section heading.", - searchTerms: ["subtitle", "small"], + title: 'Heading 3', + description: 'Small section heading.', + searchTerms: ['subtitle', 'small'], icon: IconH3, command: ({ editor, range }: CommandProps) => { editor .chain() .focus() .deleteRange(range) - .setNode("heading", { level: 3 }) + .setNode('heading', { level: 3 }) .run(); }, }, { - title: "Bullet list", - description: "Create a simple bullet list.", - searchTerms: ["unordered", "point", "list"], + title: 'Bullet list', + description: 'Create a simple bullet list.', + searchTerms: ['unordered', 'point', 'list'], icon: IconList, command: ({ editor, range }: CommandProps) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { - title: "Numbered list", - description: "Create a list with numbering.", - searchTerms: ["numbered", "ordered", "list"], + title: 'Numbered list', + description: 'Create a list with numbering.', + searchTerms: ['numbered', 'ordered', 'list'], icon: IconListNumbers, command: ({ editor, range }: CommandProps) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { - title: "Quote", - description: "Create block quote.", - searchTerms: ["blockquote", "quotes"], + title: 'Quote', + description: 'Create block quote.', + searchTerms: ['blockquote', 'quotes'], icon: IconBlockquote, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(), }, { - title: "Code", - description: "Capture a code snippet.", - searchTerms: ["codeblock"], + title: 'Code', + description: 'Capture a code snippet.', + searchTerms: ['codeblock'], icon: IconCode, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, { - title: "Image", - description: "Upload any image from your device.", - searchTerms: ["photo", "picture", "media"], + title: 'Image', + description: 'Upload any image from your device.', + searchTerms: ['photo', 'picture', 'media'], icon: IconPhoto, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); @@ -141,9 +142,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (!pageId) return; // upload image - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; input.multiple = true; input.onchange = async () => { if (input.files?.length) { @@ -157,9 +158,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { }, }, { - title: "Video", - description: "Upload any video from your device.", - searchTerms: ["video", "mp4", "media"], + title: 'Video', + description: 'Upload any video from your device.', + searchTerms: ['video', 'mp4', 'media'], icon: IconMovie, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); @@ -168,9 +169,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (!pageId) return; // upload video - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*"; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'video/*'; input.onchange = async () => { if (input.files?.length) { const file = input.files[0]; @@ -182,9 +183,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { }, }, { - title: "File attachment", - description: "Upload any file from your device.", - searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"], + title: 'File attachment', + description: 'Upload any file from your device.', + searchTerms: ['file', 'attachment', 'upload', 'pdf', 'csv', 'zip'], icon: IconPaperclip, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).run(); @@ -193,16 +194,16 @@ const CommandGroups: SlashMenuGroupedItemsType = { if (!pageId) return; // upload file - const input = document.createElement("input"); - input.type = "file"; - input.accept = ""; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = ''; input.onchange = async () => { if (input.files?.length) { const file = input.files[0]; const pos = editor.view.state.selection.from; - if (file.type.includes("image/*")) { + if (file.type.includes('image/*')) { uploadImageAction(file, editor.view, pos, pageId); - } else if (file.type.includes("video/*")) { + } else if (file.type.includes('video/*')) { uploadVideoAction(file, editor.view, pos, pageId); } else { uploadAttachmentAction(file, editor.view, pos, pageId); @@ -213,9 +214,9 @@ const CommandGroups: SlashMenuGroupedItemsType = { }, }, { - title: "Table", - description: "Insert a table.", - searchTerms: ["table", "rows", "columns"], + title: 'Table', + description: 'Insert a table.', + searchTerms: ['table', 'rows', 'columns'], icon: IconTable, command: ({ editor, range }: CommandProps) => editor @@ -226,43 +227,43 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(), }, { - title: "Toggle block", - description: "Insert collapsible block.", - searchTerms: ["collapsible", "block", "toggle", "details", "expand"], + title: 'Toggle block', + description: 'Insert collapsible block.', + searchTerms: ['collapsible', 'block', 'toggle', 'details', 'expand'], icon: IconCaretRightFilled, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleDetails().run(), }, { - title: "Callout", - description: "Insert callout notice.", + title: 'Callout', + description: 'Insert callout notice.', searchTerms: [ - "callout", - "notice", - "panel", - "info", - "warning", - "success", - "error", - "danger", + 'callout', + 'notice', + 'panel', + 'info', + 'warning', + 'success', + 'error', + 'danger', ], icon: IconInfoCircle, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCallout().run(), }, { - title: "Math inline", - description: "Insert inline math equation.", + title: 'Math inline', + description: 'Insert inline math equation.', searchTerms: [ - "math", - "inline", - "mathinline", - "inlinemath", - "inline math", - "equation", - "katex", - "latex", - "tex", + 'math', + 'inline', + 'mathinline', + 'inlinemath', + 'inline math', + 'equation', + 'katex', + 'latex', + 'tex', ], icon: IconMathFunction, command: ({ editor, range }: CommandProps) => @@ -275,39 +276,47 @@ const CommandGroups: SlashMenuGroupedItemsType = { .run(), }, { - title: "Math block", - description: "Insert math equation", + title: 'Math block', + description: 'Insert math equation', searchTerms: [ - "math", - "block", - "mathblock", - "block math", - "equation", - "katex", - "latex", - "tex", + 'math', + 'block', + 'mathblock', + 'block math', + 'equation', + 'katex', + 'latex', + 'tex', ], icon: IconMath, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setMathBlock().run(), }, { - title: "Mermaid diagram", - description: "Insert mermaid diagram", - searchTerms: ["mermaid", "diagrams", "chart", "uml"], + title: 'Mermaid diagram', + description: 'Insert mermaid diagram', + searchTerms: ['mermaid', 'diagrams', 'chart', 'uml'], icon: IconMermaid, command: ({ editor, range }: CommandProps) => editor .chain() .focus() .deleteRange(range) - .setCodeBlock({ language: "mermaid" }) + .setCodeBlock({ language: 'mermaid' }) .run(), }, { - title: "Excalidraw diagram", - description: "Draw and sketch excalidraw diagrams", - searchTerms: ["diagrams", "draw", "sketch"], + title: 'Draw.io (diagrams.net) ', + description: 'Insert and design Drawio diagrams', + 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, command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setExcalidraw().run(), diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 02dc323c..1123c307 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -33,6 +33,7 @@ import { Selection, Attachment, CustomCodeBlock, + Drawio, Excalidraw, } from "@docmost/editor-ext"; import { @@ -50,6 +51,7 @@ import { common, createLowlight } from "lowlight"; import VideoView from "@/features/editor/components/video/video-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 DrawioView from "../components/drawio/drawio-view"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import plaintext from "highlight.js/lib/languages/plaintext"; @@ -154,6 +156,9 @@ export const mainExtensions = [ Attachment.configure({ view: AttachmentView, }), + Drawio.configure({ + view: DrawioView, + }), Excalidraw.configure({ view: ExcalidrawView, }), diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index a7cee0bc..5030468d 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -38,6 +38,7 @@ import { } from "@/features/editor/components/common/file-upload-handler.tsx"; import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; +import DrawioMenu from "./components/drawio/drawio-menu"; interface PageEditorProps { pageId: string; @@ -173,6 +174,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) { + )} diff --git a/apps/client/src/features/editor/styles/media.css b/apps/client/src/features/editor/styles/media.css index ec7bb84c..714822e8 100644 --- a/apps/client/src/features/editor/styles/media.css +++ b/apps/client/src/features/editor/styles/media.css @@ -4,7 +4,7 @@ height: auto; } - .node-image, .node-video, .node-excalidraw{ + .node-image, .node-video, .node-excalidraw, .node-drawio { &.ProseMirror-selectednode { outline: none; } diff --git a/apps/client/src/lib/utils.ts b/apps/client/src/lib/utils.ts index b918d39c..e9357014 100644 --- a/apps/client/src/lib/utils.ts +++ b/apps/client/src/lib/utils.ts @@ -52,3 +52,12 @@ export async function svgStringToFile( const blob = new Blob([svgString], { 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); +} diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 08dd8da6..bb956d91 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -28,6 +28,7 @@ import { TiptapVideo, TrailingNode, Attachment, + Drawio, Excalidraw, } from '@docmost/editor-ext'; import { generateText, JSONContent } from '@tiptap/core'; @@ -69,6 +70,7 @@ export const tiptapExtensions = [ Callout, Attachment, CustomCodeBlock, + Drawio, Excalidraw, ] as any; diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 62c5f534..80e2035f 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -11,5 +11,7 @@ export * from "./lib/media-utils"; export * from "./lib/link"; export * from "./lib/selection"; export * from "./lib/attachment"; -export * from "./lib/custom-code-block"; +export * from "./lib/custom-code-block" +export * from "./lib/drawio"; export * from "./lib/excalidraw"; + diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts new file mode 100644 index 00000000..319853b2 --- /dev/null +++ b/packages/editor-ext/src/lib/drawio.ts @@ -0,0 +1,124 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface DrawioOptions { + HTMLAttributes: Record; + view: any; +} +export interface DrawioAttributes { + src?: string; + title?: string; + size?: number; + width?: string; + align?: string; + attachmentId?: string; +} + +declare module "@tiptap/core" { + interface Commands { + drawio: { + setDrawio: (attributes?: DrawioAttributes) => ReturnType; + }; + } +} + +export const Drawio = Node.create({ + 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); + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee577343..3c054db4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,9 @@ importers: react-dom: specifier: ^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: specifier: ^4.0.13 version: 4.0.13(react@18.3.1) @@ -7408,6 +7411,11 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-YKZ4jhkalWcNyaw4qyI//+QeTeUxe/ptqI+wSc4wVIoHzqffAWoV5x/jBpFex3FQ636xVIDFrvGq39rUVL7zSQ==} engines: {node: '>=18.0.0'} @@ -16737,6 +16745,10 @@ snapshots: react: 18.3.1 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)): dependencies: '@babel/core': 7.24.5