Compare commits

..

4 Commits

Author SHA1 Message Date
Philipinho c36833ad5b 30 seconds 2026-03-12 02:27:09 +00:00
Philipinho 131511a94e feat(editor): add auto-save and unsaved changes protection for diagrams 2026-03-12 02:25:20 +00:00
Philip Okugbe 7b69727a30 fix shared page mention view for non-logged in users (#2008) 2026-03-11 19:25:27 +00:00
Philip Okugbe 66c26af34b noop audit module (#1994) 2026-03-05 09:29:39 +00:00
6 changed files with 490 additions and 148 deletions
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; 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 { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -9,6 +9,7 @@ import {
import { import {
ActionIcon, ActionIcon,
Modal, Modal,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
@@ -29,10 +30,12 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -41,6 +44,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -131,33 +136,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const handleOpen = useCallback(async () => { const saveData = useCallback(async (svgXml: string) => {
if (!editorState?.src) return; if (isSavingRef.current) return;
isSavingRef.current = true;
try { try {
const url = getFileUrl(editorState.src); const svgString = decodeBase64ToSvgString(svgXml);
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 fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -179,10 +164,85 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close(); close();
}, return;
[editor, editorState?.attachmentId, close], }
);
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 ( return (
<> <>
@@ -276,7 +336,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -285,6 +345,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -296,13 +357,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}} }}
/> />
</div> </div>
@@ -6,7 +6,7 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts"; import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,6 +14,7 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -21,6 +22,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) { export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,42 +32,108 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
open(); open();
}; };
const handleSave = async (data: EventSave) => { const saveData = async (svgXml: string, updateSrc = true) => {
const svgString = decodeBase64ToSvgString(data.xml); if (isSavingRef.current) return;
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore isSavingRef.current = true;
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; try {
if (attachmentId) { const svgString = decodeBase64ToSvgString(svgXml);
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); const fileName = "diagram.drawio.svg";
} else { const drawioSVGFile = await svgStringToFile(svgString, fileName);
attachment = await uploadFile(drawioSVGFile, pageId);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {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]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_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 ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -74,6 +142,7 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -85,13 +154,19 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml, true).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
}} }}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -10,9 +10,11 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Group, Group,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -52,6 +54,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}); });
const [excalidrawData, setExcalidrawData] = useState<any>(null); const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -160,57 +166,109 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
} }
}, [editorState?.src, open]); }, [editorState?.src, open]);
const handleSave = useCallback(async () => { const saveData = useCallback(async () => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); const attachmentId = editorState?.attachmentId;
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId); 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;
}
}, [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;
} }
editor.commands.updateAttributes("excalidraw", { modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {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]);
close(); useEffect(() => {
}, [editor, excalidrawAPI, editorState?.attachmentId, close]); if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<> <>
@@ -317,7 +375,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -332,10 +390,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -343,6 +401,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => 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={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -7,7 +7,14 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { lazy, Suspense, useState } from "react"; import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
@@ -42,59 +50,122 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
}; };
const handleSave = async () => { const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else { let attachment: IAttachment = null;
attachment = await uploadFile(excalidrawSvgFile, pageId); if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {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]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -105,7 +176,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -120,10 +191,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -131,6 +202,18 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => 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={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null }); } = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const location = useLocation(); const { data: sharedPage } = useSharePageQuery({
const isShareRoute = location.pathname.startsWith("/share"); pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
} }
}; };
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: sharePageTitle,
anchorId, anchorId,
}); });
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{entityType === "page" && isError && ( {isPageMention && isShareRoute && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={ to={shareSlugUrl}
isShareRoute onClick={handleClick}
? shareSlugUrl underline="never"
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId) className={classes.pageMentionLink}
} >
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
onClick={handleClick} onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({ const { data, isLoading, error } = useGetSidebarPagesQuery(
pageId: currentPageId, shareId ? null : { pageId: currentPageId },
}); );
const subpages = useMemo(() => { const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages // If we're in a shared context, use the shared subpages