import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Node as PMNode } from "@tiptap/pm/model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, LoadingOverlay, Modal, Text, 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, EventExport, EventSave, } from "react-drawio"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { IAttachment } from "@/features/attachments/types/attachment.types"; import { modals } from "@mantine/modals"; import classes from "../common/toolbar-menu.module.css"; export function DrawioMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); const [opened, { open, close }] = useDisclosure(false); const [initialXML, setInitialXML] = useState(""); const drawioRef = useRef(null); const computedColorScheme = useComputedColorScheme(); const isDirtyRef = useRef(false); const isSavingRef = useRef(false); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const editorState = useEditorState({ editor, selector: (ctx) => { if (!ctx.editor) { return null; } const drawioAttr = ctx.editor.getAttributes("drawio"); return { isDrawio: ctx.editor.isActive("drawio"), 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; const predicate = (node: PMNode) => node.type.name === "drawio"; const parent = findParentNode(predicate)(selection); if (parent) { const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement; const domRect = dom.getBoundingClientRect(); return { getBoundingClientRect: () => domRect, getClientRects: () => [domRect], }; } const domRect = posToDOMRect(editor.view, selection.from, selection.to); return { getBoundingClientRect: () => domRect, getClientRects: () => [domRect], }; }, [editor]); const 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 saveData = useCallback(async (svgXml: string) => { if (isSavingRef.current) return; isSavingRef.current = true; setIsSaving(true); try { const svgString = decodeBase64ToSvgString(svgXml); 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, }); isDirtyRef.current = false; } finally { isSavingRef.current = false; setIsSaving(false); } }, [editor, editorState?.attachmentId]); const handleClose = useCallback(() => { if (!isDirtyRef.current) { close(); return; } modals.openConfirmModal({ title: t("Unsaved changes"), children: ( {t("You have unsaved changes that will be lost.")} ), centered: true, labels: { confirm: t("Discard"), cancel: t("Cancel") }, confirmProps: { color: "red" }, onConfirm: () => { isDirtyRef.current = false; close(); }, }); }, [close, t]); const handleOpen = useCallback(async () => { if (!editorState?.src) return; setIsLoading(true); 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 { setIsLoading(false); isDirtyRef.current = false; open(); } }, [editorState?.src, open]); useEffect(() => { if (!opened) return; const interval = setInterval(() => { if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) { drawioRef.current.exportDiagram({ format: "xmlsvg" }); } }, 60_000); return () => clearInterval(interval); }, [opened]); useEffect(() => { if (!opened) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); handleClose(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [opened, handleClose]); return ( <>
{ if (data.parentEvent !== "save") { return; } saveData(data.xml).then(() => close()).catch(() => {}); }} onClose={(data: EventExit) => { if (data.parentEvent) { return; } handleClose(); }} onAutoSave={() => { isDirtyRef.current = true; }} onExport={(data: EventExport) => { saveData(data.data).catch(() => {}); }} />
); } export default DrawioMenu;