From 8c380db8c3a2ad06d839ebf739f23387752127b8 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:48:14 +0000 Subject: [PATCH] refactor excalidraw and drawio menu --- .../components/common/node-resize-handles.ts | 35 ++ .../components/common/node-resize.module.css | 64 ++++ .../toolbar-menu.module.css} | 0 .../editor/components/drawio/drawio-menu.tsx | 296 +++++++++++++-- .../editor/components/drawio/drawio-view.tsx | 106 ++---- .../components/excalidraw/excalidraw-menu.tsx | 349 +++++++++++++++--- .../components/excalidraw/excalidraw-view.tsx | 106 ++---- .../editor/components/image/image-menu.tsx | 5 +- .../components/image/image-resize-handles.ts | 38 +- .../features/editor/extensions/extensions.ts | 24 ++ apps/server/src/ee | 2 +- packages/editor-ext/src/lib/drawio.ts | 214 ++++++++++- packages/editor-ext/src/lib/excalidraw.ts | 215 ++++++++++- packages/editor-ext/src/lib/image/image.ts | 14 +- 14 files changed, 1162 insertions(+), 306 deletions(-) create mode 100644 apps/client/src/features/editor/components/common/node-resize-handles.ts create mode 100644 apps/client/src/features/editor/components/common/node-resize.module.css rename apps/client/src/features/editor/components/{image/image-menu.module.css => common/toolbar-menu.module.css} (100%) diff --git a/apps/client/src/features/editor/components/common/node-resize-handles.ts b/apps/client/src/features/editor/components/common/node-resize-handles.ts new file mode 100644 index 00000000..0785845d --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize-handles.ts @@ -0,0 +1,35 @@ +import type { ResizableNodeViewDirection } from "@tiptap/core"; +import classes from "./node-resize.module.css"; + +export function createResizeHandle( + direction: ResizableNodeViewDirection, +): HTMLElement { + const handle = document.createElement("div"); + handle.dataset.resizeHandle = direction; + handle.style.position = "absolute"; + handle.className = classes.handle; + + if (direction === "left") { + handle.style.left = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } else if (direction === "right") { + handle.style.right = "-8px"; + handle.style.top = "0"; + handle.style.bottom = "0"; + } + + const bar = document.createElement("div"); + bar.className = classes.handleBar; + handle.appendChild(bar); + + return handle; +} + +export function buildResizeClasses(nodeClass: string) { + return { + container: `${classes.container} ${nodeClass}`, + wrapper: classes.wrapper, + resizing: classes.resizing, + }; +} diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css new file mode 100644 index 00000000..24414171 --- /dev/null +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -0,0 +1,64 @@ +.container { + display: flex; +} + +.wrapper { + position: relative; + border-radius: 8px; + overflow: visible; + max-width: 100%; +} + +.wrapper img { + height: auto !important; +} + +.resizing { + user-select: none; +} + +.handle { + position: absolute; + top: 0; + bottom: 0; + width: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: ew-resize; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 2; +} + +.handle[data-resize-handle="left"] { + left: -8px; +} + +.handle[data-resize-handle="right"] { + right: -8px; +} + +.wrapper:hover .handle { + opacity: 1; +} + +.resizing .handle { + opacity: 1; +} + +.handleBar { + width: 4px; + height: 48px; + border-radius: 4px; + transition: background-color 0.15s ease; + background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5)); +} + +.handle:hover .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} + +.resizing .handleBar { + background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); +} diff --git a/apps/client/src/features/editor/components/image/image-menu.module.css b/apps/client/src/features/editor/components/common/toolbar-menu.module.css similarity index 100% rename from apps/client/src/features/editor/components/image/image-menu.module.css rename to apps/client/src/features/editor/components/common/toolbar-menu.module.css diff --git a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx index 937b8e7d..ee8f28a1 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-menu.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-menu.tsx @@ -1,24 +1,41 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback } from "react"; +import { useCallback, useRef, useState } from "react"; 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"; +import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import clsx from "clsx"; +import { + IconLayoutAlignCenter, + IconLayoutAlignLeft, + IconLayoutAlignRight, + IconDownload, + IconEdit, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { + DrawIoEmbed, + DrawIoEmbedRef, + EventExit, + EventSave, +} from "react-drawio"; +import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; +import classes from "../common/toolbar-menu.module.css"; export function DrawioMenu({ editor }: EditorMenuProps) { - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; - }, - [editor], - ); + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const [initialXML, setInitialXML] = useState(""); + const drawioRef = useRef(null); + const computedColorScheme = useComputedColorScheme(); const editorState = useEditorState({ editor, @@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) { const drawioAttr = ctx.editor.getAttributes("drawio"); return { isDrawio: ctx.editor.isActive("drawio"), - width: drawioAttr?.width ? parseInt(drawioAttr.width) : null, + isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }), + isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }), + isAlignRight: ctx.editor.isActive("drawio", { align: "right" }), + src: drawioAttr?.src || null, + attachmentId: drawioAttr?.attachmentId || null, }; }, }); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return editor.isActive("drawio") && editor.getAttributes("drawio")?.src; + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { if (!editor) return; const { selection } = editor.state; @@ -57,38 +89,224 @@ export function DrawioMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor.commands.updateAttributes("drawio", { width: `${value}%` }); + const alignLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("left") + .run(); + }, [editor]); + + const alignCenter = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("center") + .run(); + }, [editor]); + + const alignRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setDrawioAlign("right") + .run(); + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.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 = () => { + const base64data = (reader.result || "") as string; + setInitialXML(base64data); + }; + } catch (err) { + console.error(err); + } finally { + open(); + } + }, [editorState?.src, open]); + + const handleSave = useCallback( + async (data: EventSave) => { + const svgString = decodeBase64ToSvgString(data.xml); + const fileName = "diagram.drawio.svg"; + const drawioSVGFile = await svgStringToFile(svgString, fileName); + + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); + } else { + attachment = await uploadFile(drawioSVGFile, pageId); + } + + editor.commands.updateAttributes("drawio", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + close(); }, - [editor], + [editor, editorState?.attachmentId, close], ); return ( - -
+ - {editorState?.width && ( - - )} -
-
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ { + if (data.parentEvent !== "save") { + return; + } + handleSave(data); + }} + onClose={(data: EventExit) => { + if (data.parentEvent) { + return; + } + close(); + }} + /> +
+
+
+
+ ); } diff --git a/apps/client/src/features/editor/components/drawio/drawio-view.tsx b/apps/client/src/features/editor/components/drawio/drawio-view.tsx index b51e8936..0b1580ec 100644 --- a/apps/client/src/features/editor/components/drawio/drawio-view.tsx +++ b/apps/client/src/features/editor/components/drawio/drawio-view.tsx @@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { ActionIcon, Card, - Image, Modal, Text, useComputedColorScheme, @@ -10,7 +9,7 @@ import { import { useRef, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { useDisclosure } from "@mantine/hooks"; -import { getDrawioUrl, getFileUrl } from "@/lib/config.ts"; +import { getDrawioUrl } from "@/lib/config.ts"; import { DrawIoEmbed, DrawIoEmbedRef, @@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next"; export default function DrawioView(props: NodeViewProps) { const { t } = useTranslation(); const { node, updateAttributes, editor, selected } = props; - const { src, title, width, attachmentId } = node.attrs; + const { attachmentId } = node.attrs; const drawioRef = useRef(null); const [initialXML, setInitialXML] = useState(""); const [opened, { open, close }] = useDisclosure(false); @@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) { 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 = () => { - const base64data = (reader.result || "") as string; - setInitialXML(base64data); - }; - } - } catch (err) { - console.error(err); - } finally { - open(); - } + open(); }; const handleSave = async (data: EventSave) => { const svgString = decodeBase64ToSvgString(data.xml); - const fileName = "diagram.drawio.svg"; const drawioSVGFile = await svgStringToFile(svgString, fileName); @@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) { const pageId = editor.storage?.pageId; let attachment: IAttachment = null; - if (attachmentId) { attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); } else { @@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) { 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; } @@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) { - {src ? ( -
- e.detail === 2 && handleOpen()} - radius="md" - fit="contain" - w={width} - src={getFileUrl(src)} - alt={title} - className={clsx( - selected ? "ProseMirror-selectednode" : "", - "alignCenter", - )} - /> + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + withBorder + className={clsx(selected ? "ProseMirror-selectednode" : "")} + > +
+ + + - {selected && editor.isEditable && ( - - - - )} + + {t("Double-click to edit Draw.io diagram")} +
- ) : ( - e.detail === 2 && handleOpen()} - p="xs" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - withBorder - className={clsx(selected ? "ProseMirror-selectednode" : "")} - > -
- - - - - - {t("Double-click to edit Draw.io diagram")} - -
-
- )} +
); } diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx index 06e79515..e6a0d46a 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-menu.tsx @@ -1,26 +1,57 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import { useCallback } from "react"; +import { lazy, Suspense, useCallback, useState } from "react"; 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"; +import { + ActionIcon, + Button, + Group, + Tooltip, + useComputedColorScheme, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import clsx from "clsx"; +import { + IconLayoutAlignCenter, + IconLayoutAlignLeft, + IconLayoutAlignRight, + IconDownload, + IconEdit, + IconTrash, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import { uploadFile } from "@/features/page/services/page-service.ts"; +import { svgStringToFile } from "@/lib"; +import "@excalidraw/excalidraw/index.css"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import { IAttachment } from "@/features/attachments/types/attachment.types"; +import ReactClearModal from "react-clear-modal"; +import { useHandleLibrary } from "@excalidraw/excalidraw"; +import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; +import classes from "../common/toolbar-menu.module.css"; + +const ExcalidrawComponent = lazy(() => + import("@excalidraw/excalidraw").then((module) => ({ + default: module.Excalidraw, + })), +); export function ExcalidrawMenu({ editor }: EditorMenuProps) { - const shouldShow = useCallback( - ({ state }: ShouldShowProps) => { - if (!state) { - return false; - } - - return ( - editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src - ); - }, - [editor], - ); + const { t } = useTranslation(); + const [opened, { open, close }] = useDisclosure(false); + const [excalidrawAPI, setExcalidrawAPI] = + useState(null); + useHandleLibrary({ + excalidrawAPI, + adapter: localStorageLibraryAdapter, + }); + const [excalidrawData, setExcalidrawData] = useState(null); + const computedColorScheme = useComputedColorScheme(); const editorState = useEditorState({ editor, @@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { const excalidrawAttr = ctx.editor.getAttributes("excalidraw"); return { isExcalidraw: ctx.editor.isActive("excalidraw"), - width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null, + isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }), + isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }), + isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }), + src: excalidrawAttr?.src || null, + attachmentId: excalidrawAttr?.attachmentId || null, }; }, }); + const shouldShow = useCallback( + ({ state }: ShouldShowProps) => { + if (!state) { + return false; + } + + return ( + editor.isActive("excalidraw") && + editor.getAttributes("excalidraw")?.src + ); + }, + [editor], + ); + const getReferencedVirtualElement = useCallback(() => { if (!editor) return; const { selection } = editor.state; @@ -59,38 +108,254 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) { }; }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor.commands.updateAttributes("excalidraw", { width: `${value}%` }); - }, - [editor], - ); + const alignLeft = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("left") + .run(); + }, [editor]); + + const alignCenter = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("center") + .run(); + }, [editor]); + + const alignRight = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setExcalidrawAlign("right") + .run(); + }, [editor]); + + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + + const handleOpen = useCallback(async () => { + if (!editorState?.src) return; + + try { + const url = getFileUrl(editorState.src); + const request = await fetch(url, { + credentials: "include", + cache: "no-store", + }); + + const { loadFromBlob } = await import("@excalidraw/excalidraw"); + const data = await loadFromBlob(await request.blob(), null, null); + setExcalidrawData(data); + } catch (err) { + console.error(err); + } finally { + open(); + } + }, [editorState?.src, open]); + + const handleSave = useCallback(async () => { + if (!excalidrawAPI) { + return; + } + + const { exportToSvg } = await import("@excalidraw/excalidraw"); + + const svg = await exportToSvg({ + elements: excalidrawAPI?.getSceneElements(), + appState: { + exportEmbedScene: true, + exportWithDarkMode: false, + }, + files: excalidrawAPI?.getFiles(), + }); + + 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", + ); + + const fileName = "diagram.excalidraw.svg"; + const excalidrawSvgFile = await svgStringToFile(svgString, fileName); + + // @ts-ignore + const pageId = editor.storage?.pageId; + const attachmentId = editorState?.attachmentId; + + let attachment: IAttachment = null; + if (attachmentId) { + attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); + } else { + attachment = await uploadFile(excalidrawSvgFile, pageId); + } + + editor.commands.updateAttributes("excalidraw", { + src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, + title: attachment.fileName, + size: attachment.fileSize, + attachmentId: attachment.id, + }); + + close(); + }, [editor, excalidrawAPI, editorState?.attachmentId, close]); return ( - -
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + - {editorState?.width && ( - - )} -
-
+ + + + +
+ + setExcalidrawAPI(api)} + initialData={{ + ...excalidrawData, + scrollToContent: true, + }} + theme={computedColorScheme} + /> + +
+ + ); } 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 86c9665e..51ff5b06 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -4,28 +4,24 @@ import { Button, Card, Group, - Image, Text, useComputedColorScheme, } from "@mantine/core"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { svgStringToFile } from "@/lib"; import { useDisclosure } from "@mantine/hooks"; -import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; import { IconEdit } from "@tabler/icons-react"; -import { lazy } from "react"; -import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { useHandleLibrary } from "@excalidraw/excalidraw"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; -const Excalidraw = lazy(() => +const ExcalidrawComponent = lazy(() => import("@excalidraw/excalidraw").then((module) => ({ default: module.Excalidraw, })), @@ -34,7 +30,7 @@ const Excalidraw = lazy(() => export default function ExcalidrawView(props: NodeViewProps) { const { t } = useTranslation(); const { node, updateAttributes, editor, selected } = props; - const { src, title, width, attachmentId } = node.attrs; + const { attachmentId } = node.attrs; const [excalidrawAPI, setExcalidrawAPI] = useState(null); @@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) { if (!editor.isEditable) { return; } - - try { - if (src) { - const url = getFileUrl(src); - const request = await fetch(url, { - credentials: "include", - cache: "no-store", - }); - - const { loadFromBlob } = await import("@excalidraw/excalidraw"); - - const data = await loadFromBlob(await request.blob(), null, null); - setExcalidrawData(data); - } - } catch (err) { - console.error(err); - } finally { - open(); - } + open(); }; const handleSave = async () => { @@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
- setExcalidrawAPI(api)} initialData={{ ...excalidrawData, @@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
- {src ? ( -
- e.detail === 2 && handleOpen()} - radius="md" - fit="contain" - w={width} - src={getFileUrl(src)} - alt={title} - className={clsx( - selected ? "ProseMirror-selectednode" : "", - "alignCenter", - )} - /> + e.detail === 2 && handleOpen()} + p="xs" + style={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + }} + withBorder + className={clsx(selected ? "ProseMirror-selectednode" : "")} + > +
+ + + - {selected && editor.isEditable && ( - - - - )} + + {t("Double-click to edit Excalidraw diagram")} +
- ) : ( - e.detail === 2 && handleOpen()} - p="xs" - style={{ - display: "flex", - justifyContent: "center", - alignItems: "center", - }} - withBorder - className={clsx(selected ? "ProseMirror-selectednode" : "")} - > -
- - - - - - {t("Double-click to edit Excalidraw diagram")} - -
-
- )} +
); } diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index e59792d5..30f6979e 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -19,7 +19,7 @@ import { import { useTranslation } from "react-i18next"; import { getFileUrl } from "@/lib/config.ts"; import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; -import classes from "./image-menu.module.css"; +import classes from "../common/toolbar-menu.module.css"; export function ImageMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); @@ -122,7 +122,8 @@ export function ImageMenu({ editor }: EditorMenuProps) { // @ts-ignore const pageId = editor.storage?.pageId; if (pageId) { - uploadImageAction(file, editor, pageId); + const pos = editor.state.selection.from; + uploadImageAction(file, editor, pos, pageId); } // Reset so the same file can be selected again e.target.value = ""; diff --git a/apps/client/src/features/editor/components/image/image-resize-handles.ts b/apps/client/src/features/editor/components/image/image-resize-handles.ts index 467c5f03..ec941497 100644 --- a/apps/client/src/features/editor/components/image/image-resize-handles.ts +++ b/apps/client/src/features/editor/components/image/image-resize-handles.ts @@ -1,33 +1,7 @@ -import type { ResizableNodeViewDirection } from "@tiptap/core"; -import classes from "./image-resize.module.css"; +import { + createResizeHandle, + buildResizeClasses, +} from "../common/node-resize-handles"; -export function createImageHandle( - direction: ResizableNodeViewDirection, -): HTMLElement { - const handle = document.createElement("div"); - handle.dataset.resizeHandle = direction; - handle.style.position = "absolute"; - handle.className = classes.handle; - - if (direction === "left") { - handle.style.left = "-8px"; - handle.style.top = "0"; - handle.style.bottom = "0"; - } else if (direction === "right") { - handle.style.right = "-8px"; - handle.style.top = "0"; - handle.style.bottom = "0"; - } - - const bar = document.createElement("div"); - bar.className = classes.handleBar; - handle.appendChild(bar); - - return handle; -} - -export const imageResizeClasses = { - container: `${classes.container} node-image`, - wrapper: classes.wrapper, - resizing: classes.resizing, -}; +export const createImageHandle = createResizeHandle; +export const imageResizeClasses = buildResizeClasses("node-image"); diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 93a7e9a1..7b33e603 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -56,6 +56,10 @@ import { createImageHandle, imageResizeClasses, } from "@/features/editor/components/image/image-resize-handles.ts"; +import { + createResizeHandle, + buildResizeClasses, +} from "@/features/editor/components/common/node-resize-handles.ts"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; @@ -236,9 +240,29 @@ export const mainExtensions = [ }), Drawio.configure({ view: DrawioView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-drawio"), + }, }), Excalidraw.configure({ view: ExcalidrawView, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createResizeHandle, + className: buildResizeClasses("node-excalidraw"), + }, }), Embed.configure({ view: EmbedView, diff --git a/apps/server/src/ee b/apps/server/src/ee index 71b4323d..028e3172 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 71b4323d1b6ea3fbec061b0d31be33235d4ddbcd +Subproject commit 028e31724e023d230426191eb6d6ef22af350a22 diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index 3cc041a2..298a1648 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -1,15 +1,35 @@ -import { Node, mergeAttributes } from "@tiptap/core"; +import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +export type DrawioResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; + export interface DrawioOptions { HTMLAttributes: Record; view: any; + resize: DrawioResizeOptions | false; } + export interface DrawioAttributes { src?: string; title?: string; size?: number; - width?: string; + width?: number | string; + height?: number; + aspectRatio?: number; align?: string; attachmentId?: string; } @@ -18,6 +38,8 @@ declare module "@tiptap/core" { interface Commands { drawio: { setDrawio: (attributes?: DrawioAttributes) => ReturnType; + setDrawioAlign: (align: "left" | "center" | "right") => ReturnType; + setDrawioSize: (width: number, height: number) => ReturnType; }; } } @@ -35,6 +57,7 @@ export const Drawio = Node.create({ return { HTMLAttributes: {}, view: null, + resize: false, }; }, @@ -55,12 +78,30 @@ export const Drawio = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("data-width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: DrawioAttributes) => ({ "data-width": attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: DrawioAttributes) => ({ + "data-height": attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -68,6 +109,13 @@ export const Drawio = Node.create({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: DrawioAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -99,7 +147,7 @@ export const Drawio = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "img", @@ -122,13 +170,163 @@ export const Drawio = Node.create({ attrs: attrs, }); }, + + setDrawioAlign: + (align) => + ({ commands }) => + commands.updateAttributes("drawio", { align }), + + setDrawioSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("drawio", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("img"); + el.src = node.attrs.src; + el.alt = node.attrs.title || ""; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index 28b064e4..9f498dbb 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -1,15 +1,35 @@ -import { Node, mergeAttributes } from "@tiptap/core"; +import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; +export type ExcalidrawResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; + export interface ExcalidrawOptions { HTMLAttributes: Record; view: any; + resize: ExcalidrawResizeOptions | false; } + export interface ExcalidrawAttributes { src?: string; title?: string; size?: number; - width?: string; + width?: number | string; + height?: number; + aspectRatio?: number; align?: string; attachmentId?: string; } @@ -18,6 +38,8 @@ declare module "@tiptap/core" { interface Commands { excalidraw: { setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType; + setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType; + setExcalidrawSize: (width: number, height: number) => ReturnType; }; } } @@ -35,8 +57,10 @@ export const Excalidraw = Node.create({ return { HTMLAttributes: {}, view: null, + resize: false, }; }, + addAttributes() { return { src: { @@ -54,12 +78,30 @@ export const Excalidraw = Node.create({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("data-width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: ExcalidrawAttributes) => ({ "data-width": attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("data-height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: ExcalidrawAttributes) => ({ + "data-height": attributes.height, + }), + }, size: { default: null, parseHTML: (element) => element.getAttribute("data-size"), @@ -67,6 +109,13 @@ export const Excalidraw = Node.create({ "data-size": attributes.size, }), }, + aspectRatio: { + default: null, + parseHTML: (element) => element.getAttribute("data-aspect-ratio"), + renderHTML: (attributes: ExcalidrawAttributes) => ({ + "data-aspect-ratio": attributes.aspectRatio, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -98,7 +147,7 @@ export const Excalidraw = Node.create({ mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, - HTMLAttributes + HTMLAttributes, ), [ "img", @@ -121,13 +170,163 @@ export const Excalidraw = Node.create({ attrs: attrs, }); }, + + setExcalidrawAlign: + (align) => + ({ commands }) => + commands.updateAttributes("excalidraw", { align }), + + setExcalidrawSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("excalidraw", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + + if (!node.attrs.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView(props); + + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + const el = document.createElement("img"); + el.src = node.attrs.src; + el.alt = node.attrs.title || ""; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +} diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 51d9006d..3876068c 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -212,20 +212,14 @@ export const TiptapImage = Image.extend({ className, } = resize; - return ({ node, getPos, HTMLAttributes, editor }) => { + return (props) => { + const { node, getPos, HTMLAttributes, editor } = props; + // If no src yet (placeholder/uploading), use React view for loading UI if (!HTMLAttributes.src) { editor.isInitialized = true; const reactView = ReactNodeViewRenderer(this.options.view); - const view = reactView({ - node, - getPos, - HTMLAttributes, - editor, - extension: this, - decorations: [] as any, - innerDecorations: {} as any, - }); + const view = reactView(props); // When the node gets a src, return false from update to force rebuild const originalUpdate = view.update?.bind(view);