import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { lazy, Suspense, 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, Button, Group, Text, Tooltip, useComputedColorScheme, } from "@mantine/core"; import { modals } from "@mantine/modals"; 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 { 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 isDirtyRef = useRef(false); const isSavingRef = useRef(false); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const isInitialLoadRef = useRef(true); const lastFingerprintRef = useRef(""); const editorState = useEditorState({ editor, selector: (ctx) => { if (!ctx.editor) { return null; } const excalidrawAttr = ctx.editor.getAttributes("excalidraw"); return { isExcalidraw: ctx.editor.isActive("excalidraw"), 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; const predicate = (node: PMNode) => node.type.name === "excalidraw"; 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 }) .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; setIsLoading(true); 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 { setIsLoading(false); isDirtyRef.current = false; isInitialLoadRef.current = true; open(); } }, [editorState?.src, open]); const saveData = useCallback(async () => { if (!excalidrawAPI || isSavingRef.current) { return; } isSavingRef.current = true; setIsSaving(true); try { 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, }); isDirtyRef.current = false; } finally { isSavingRef.current = false; setIsSaving(false); } }, [editor, excalidrawAPI, editorState?.attachmentId]); const handleSaveAndExit = useCallback(async () => { try { await saveData(); close(); } catch { // save failed, modal stays open } }, [saveData, close]); 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]); useEffect(() => { if (!opened) return; const interval = setInterval(() => { if (isDirtyRef.current && !isSavingRef.current) { saveData().catch(() => {}); } }, 60_000); return () => clearInterval(interval); }, [opened, saveData]); return ( <>
setExcalidrawAPI(api)} onChange={(elements, _appState, files) => { const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`; if (isInitialLoadRef.current) { lastFingerprintRef.current = fingerprint; isInitialLoadRef.current = false; return; } if (fingerprint !== lastFingerprintRef.current) { lastFingerprintRef.current = fingerprint; isDirtyRef.current = true; } }} initialData={{ ...excalidrawData, scrollToContent: true, }} theme={computedColorScheme} />
); } export default ExcalidrawMenu;