feat(editor): add auto-save and unsaved changes protection for diagrams (#2011)

* feat(editor): add auto-save and unsaved changes protection for diagrams
* 30 seconds
This commit is contained in:
Philip Okugbe
2026-03-13 17:58:29 +00:00
committed by GitHub
parent 7b69727a30
commit 1fdee33206
4 changed files with 424 additions and 129 deletions
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
@@ -9,6 +9,7 @@ import {
import {
ActionIcon,
Modal,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
@@ -29,10 +30,12 @@ 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) {
@@ -41,6 +44,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({
editor,
@@ -131,33 +136,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return;
isSavingRef.current = 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 {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const svgString = decodeBase64ToSvgString(svgXml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -179,10 +164,85 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
},
[editor, editorState?.attachmentId, close],
);
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
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;
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 {
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 (
<>
@@ -276,7 +336,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div>
</BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
@@ -285,6 +345,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
autosave
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
@@ -296,13 +357,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") {
return;
}
handleSave(data);
saveData(data.xml).then(() => close()).catch(() => {});
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
close();
handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}}
/>
</div>