mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Merge branch 'main' into feature-flag
This commit is contained in:
@@ -692,5 +692,16 @@
|
||||
"Failed to update trash retention": "Failed to update trash retention",
|
||||
"Removed page restriction": "Removed page restriction",
|
||||
"Added page permission": "Added page permission",
|
||||
"Removed page permission": "Removed page permission"
|
||||
"Removed page permission": "Removed page permission",
|
||||
"Verifying your email": "Verifying your email",
|
||||
"Please wait...": "Please wait...",
|
||||
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
|
||||
"Check your email": "Check your email",
|
||||
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
|
||||
"We sent a verification link to your email.": "We sent a verification link to your email.",
|
||||
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
|
||||
"Resend verification email": "Resend verification email",
|
||||
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
|
||||
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
|
||||
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
|
||||
import VerifyEmail from "@/ee/pages/verify-email.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -63,6 +64,7 @@ export default function App() {
|
||||
<>
|
||||
<Route path={"/create"} element={<CreateWorkspace />} />
|
||||
<Route path={"/select"} element={<CloudLogin />} />
|
||||
<Route path={"/verify-email"} element={<VerifyEmail />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
|
||||
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function findWorkspacesByEmail(email: string): Promise<void> {
|
||||
await api.post("/workspace/find-by-email", { email });
|
||||
}
|
||||
|
||||
export async function verifyEmail(data: { token: string }): Promise<void> {
|
||||
await api.post("/workspace/verify-email", data);
|
||||
}
|
||||
|
||||
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
|
||||
await api.post("/workspace/resend-verification", data);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,21 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
|
||||
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
|
||||
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
hostname: z.string().min(1, { message: "subdomain is required" }),
|
||||
});
|
||||
|
||||
const findWorkspaceSchema = z.object({
|
||||
email: z.string().email({ message: "Please enter a valid email" }),
|
||||
});
|
||||
|
||||
export function CloudLoginForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
|
||||
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
|
||||
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
|
||||
|
||||
const form = useForm<any>({
|
||||
@@ -37,6 +44,13 @@ export function CloudLoginForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const findForm = useForm<any>({
|
||||
validate: zod4Resolver(findWorkspaceSchema),
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: { hostname: string }) {
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -54,6 +68,19 @@ export function CloudLoginForm() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function onFindSubmit(data: { email: string }) {
|
||||
setIsFindLoading(true);
|
||||
|
||||
try {
|
||||
await findWorkspacesByEmail(data.email);
|
||||
setFindEmailSent(true);
|
||||
} catch {
|
||||
findForm.setFieldError("email", "An error occurred. Please try again.");
|
||||
}
|
||||
|
||||
setIsFindLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container size={420} className={classes.container}>
|
||||
@@ -83,6 +110,38 @@ export function CloudLoginForm() {
|
||||
{t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Divider my="lg" label="or" labelPosition="center" />
|
||||
|
||||
{findEmailSent ? (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
{t("We've sent you an email with your associated workspaces.")}
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
|
||||
<Text fw={600} mb="xs">
|
||||
{t("Find your workspaces")}
|
||||
</Text>
|
||||
<TextInput
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
description={t(
|
||||
"We'll send a list of your workspaces to this email.",
|
||||
)}
|
||||
withErrorStyles={false}
|
||||
{...findForm.getInputProps("email")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
mt="md"
|
||||
variant="light"
|
||||
loading={isFindLoading}
|
||||
>
|
||||
{t("Send")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { Container, Title, Text, Button, Box } from "@mantine/core";
|
||||
import classes from "../../features/auth/components/auth.module.css";
|
||||
import {
|
||||
verifyEmail,
|
||||
resendVerificationEmail,
|
||||
} from "@/ee/cloud/service/cloud-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import APP_ROUTE from "@/lib/app-route.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get("token");
|
||||
const rawEmail = searchParams.get("email");
|
||||
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
|
||||
const sig = searchParams.get("sig");
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resent, setResent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
handleVerify(token);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
async function handleVerify(verifyToken: string) {
|
||||
try {
|
||||
await verifyEmail({ token: verifyToken });
|
||||
navigate(APP_ROUTE.HOME);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: t("Verification failed. The link may have expired."),
|
||||
color: "red",
|
||||
});
|
||||
navigate(APP_ROUTE.AUTH.LOGIN);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
if (!email || !sig) return;
|
||||
setIsResending(true);
|
||||
|
||||
try {
|
||||
await resendVerificationEmail({ email, sig });
|
||||
setResent(true);
|
||||
} catch {
|
||||
notifications.show({
|
||||
message: t("Failed to resend verification email. Please try again."),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
setIsResending(false);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return (
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Verifying your email")}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed">
|
||||
{t("Please wait...")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} className={classes.container}>
|
||||
<Box p="xl" className={classes.containerBox}>
|
||||
<Title order={2} ta="center" fw={500} mb="md">
|
||||
{t("Check your email")}
|
||||
</Title>
|
||||
<Text ta="center" c="dimmed" mb="md">
|
||||
{email
|
||||
? t("We sent a verification link to {{email}}.", { email })
|
||||
: t("We sent a verification link to your email.")}
|
||||
</Text>
|
||||
<Text ta="center" size="sm" c="dimmed" mb="lg">
|
||||
{t("Click the link to verify your email and access your workspace.")}
|
||||
</Text>
|
||||
{email && sig && !resent && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
onClick={handleResend}
|
||||
loading={isResending}
|
||||
>
|
||||
{t("Resend verification email")}
|
||||
</Button>
|
||||
)}
|
||||
{resent && (
|
||||
<Text ta="center" size="sm" c="dimmed">
|
||||
{t("Verification email sent. Please check your inbox.")}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
workspaceName: z.string().trim().max(50).optional(),
|
||||
name: z.string().min(1).max(50),
|
||||
name: z.string().min(1, { message: "Name is required" }).max(50),
|
||||
email: z
|
||||
.email()
|
||||
.min(1, { message: "email is required" }),
|
||||
password: z.string().min(8),
|
||||
.email({ message: "Invalid email address" })
|
||||
.min(1, { message: "Email is required" }),
|
||||
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
|
||||
});
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||
import { RESET } from "jotai/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
|
||||
import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
|
||||
|
||||
export default function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,9 +52,18 @@ export default function useAuth() {
|
||||
}
|
||||
} catch (err) {
|
||||
setIsLoading(false);
|
||||
console.log(err);
|
||||
|
||||
const message = err.response?.data?.message;
|
||||
if (isCloud() && message?.includes("verify your email")) {
|
||||
const sig = err.response?.data?.emailSignature;
|
||||
navigate(
|
||||
`${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
message: err.response?.data.message,
|
||||
message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
@@ -92,6 +101,17 @@ export default function useAuth() {
|
||||
try {
|
||||
if (isCloud()) {
|
||||
const res = await createWorkspace(data);
|
||||
|
||||
if (res?.requiresEmailVerification) {
|
||||
const hostname = res?.workspace?.hostname;
|
||||
if (hostname) {
|
||||
window.location.href =
|
||||
getHostnameUrl(hostname) +
|
||||
`/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = res?.workspace?.hostname;
|
||||
const exchangeToken = res?.exchangeToken;
|
||||
if (hostname && exchangeToken) {
|
||||
|
||||
@@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
|
||||
export async function getCollabToken(): Promise<ICollabToken> {
|
||||
const req = await api.post<ICollabToken>("/auth/collab-token");
|
||||
return req.data;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,12 @@ function CommentActions({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
loading={isLoading}
|
||||
onClick={onSave}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -27,6 +27,9 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
|
||||
function CommentListWithTabs() {
|
||||
const { t } = useTranslation();
|
||||
@@ -345,6 +348,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
const [content, setContent] = useState("");
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(null, content);
|
||||
@@ -363,19 +367,30 @@ const PageCommentInput = ({ onSave, isLoading }) => {
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={t("Add a comment...")}
|
||||
/>
|
||||
<Group wrap="nowrap" align="flex-start" gap="xs">
|
||||
<CustomAvatar
|
||||
size="sm"
|
||||
avatarUrl={currentUser?.user?.avatarUrl}
|
||||
name={currentUser?.user?.name}
|
||||
style={{ flexShrink: 0, marginTop: 10 }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CommentEditor
|
||||
ref={commentEditorRef}
|
||||
onUpdate={setContent}
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={t("Add a comment...")}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
{focused && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
loading={isLoading}
|
||||
style={{ position: "absolute", right: 8, bottom: 30 }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} 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 { useDisclosure } from "@mantine/hooks";
|
||||
import { getDrawioUrl } from "@/lib/config.ts";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
EventExit,
|
||||
EventExport,
|
||||
EventSave,
|
||||
} from "react-drawio";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
@@ -21,6 +22,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||
import clsx from "clsx";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
export default function DrawioView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -30,42 +32,108 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
const saveData = async (svgXml: string, updateSrc = true) => {
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
//@ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
isSavingRef.current = true;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||
try {
|
||||
const svgString = decodeBase64ToSvgString(svgXml);
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
//@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({
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
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]);
|
||||
|
||||
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 (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<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>
|
||||
@@ -74,6 +142,7 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
ref={drawioRef}
|
||||
xml={initialXML}
|
||||
baseUrl={getDrawioUrl()}
|
||||
autosave
|
||||
urlParameters={{
|
||||
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||
spin: true,
|
||||
@@ -85,13 +154,19 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
if (data.parentEvent !== "save") {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
saveData(data.xml, true).then(() => close()).catch(() => {});
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
handleClose();
|
||||
}}
|
||||
onAutoSave={() => {
|
||||
isDirtyRef.current = true;
|
||||
}}
|
||||
onExport={(data: EventExport) => {
|
||||
saveData(data.data, false).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
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 {
|
||||
EditorMenuProps,
|
||||
@@ -10,9 +10,11 @@ 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 {
|
||||
@@ -52,6 +54,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const lastFingerprintRef = useRef("");
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -160,57 +166,109 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
isDirtyRef.current = false;
|
||||
isInitialLoadRef.current = true;
|
||||
open();
|
||||
}
|
||||
}, [editorState?.src, open]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!excalidrawAPI) {
|
||||
const saveData = useCallback(async () => {
|
||||
if (!excalidrawAPI || isSavingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
isSavingRef.current = true;
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
try {
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||
);
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
const attachmentId = editorState?.attachmentId;
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||
// @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;
|
||||
}
|
||||
}, [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", {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
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]);
|
||||
|
||||
close();
|
||||
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isDirtyRef.current && !isSavingRef.current) {
|
||||
saveData().catch(() => {});
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [opened, saveData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -317,7 +375,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
onRequestClose={handleClose}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
@@ -332,10 +390,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSave} size={"compact-sm"}>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
|
||||
{t("Save & Exit")}
|
||||
</Button>
|
||||
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||
<Button onClick={handleClose} color="red" size={"compact-sm"}>
|
||||
{t("Exit")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -343,6 +401,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
<Suspense fallback={null}>
|
||||
<ExcalidrawComponent
|
||||
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={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} 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 { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
import { modals } from "@mantine/modals";
|
||||
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
@@ -42,59 +50,122 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const isDirtyRef = useRef(false);
|
||||
const isSavingRef = useRef(false);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const lastFingerprintRef = useRef("");
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
isDirtyRef.current = false;
|
||||
isInitialLoadRef.current = true;
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!excalidrawAPI) {
|
||||
const saveData = useCallback(async (updateSrc = true) => {
|
||||
if (!excalidrawAPI || isSavingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
isSavingRef.current = true;
|
||||
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
try {
|
||||
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
const svg = await exportToSvg({
|
||||
elements: excalidrawAPI?.getSceneElements(),
|
||||
appState: {
|
||||
exportEmbedScene: true,
|
||||
exportWithDarkMode: false,
|
||||
},
|
||||
files: excalidrawAPI?.getFiles(),
|
||||
});
|
||||
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||
);
|
||||
const serializer = new XMLSerializer();
|
||||
let svgString = serializer.serializeToString(svg);
|
||||
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
svgString = svgString.replace(
|
||||
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
const fileName = "diagram.excalidraw.svg";
|
||||
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||
} else {
|
||||
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||
// @ts-ignore
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
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({
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
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]);
|
||||
|
||||
close();
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (isDirtyRef.current && !isSavingRef.current) {
|
||||
saveData(false).catch(() => {});
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [opened, saveData]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
@@ -105,7 +176,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
onRequestClose={handleClose}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
@@ -120,10 +191,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
bg="var(--mantine-color-body)"
|
||||
p="xs"
|
||||
>
|
||||
<Button onClick={handleSave} size={"compact-sm"}>
|
||||
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
|
||||
{t("Save & Exit")}
|
||||
</Button>
|
||||
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||
<Button onClick={handleClose} color="red" size={"compact-sm"}>
|
||||
{t("Exit")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -131,6 +202,18 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
<Suspense fallback={null}>
|
||||
<ExcalidrawComponent
|
||||
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={{
|
||||
...excalidrawData,
|
||||
scrollToContent: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildSharedPageUrl,
|
||||
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
const isPageMention = entityType === "page";
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const location = useLocation();
|
||||
const isShareRoute = location.pathname.startsWith("/share");
|
||||
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
|
||||
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
|
||||
const location = useLocation();
|
||||
const isShareRoute = location.pathname.startsWith("/share");
|
||||
const { data: sharedPage } = useSharePageQuery({
|
||||
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||
});
|
||||
|
||||
const currentPageSlugId = extractPageSlugId(pageSlug);
|
||||
const isSamePage = currentPageSlugId === slugId;
|
||||
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const sharePageTitle = sharedPage?.page?.title || label;
|
||||
|
||||
const shareSlugUrl = buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: label,
|
||||
pageTitle: sharePageTitle,
|
||||
anchorId,
|
||||
});
|
||||
|
||||
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entityType === "page" && isError && (
|
||||
<Text component="span" c="dimmed" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{entityType === "page" && !isError && (
|
||||
{isPageMention && isShareRoute && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
isShareRoute
|
||||
? shareSlugUrl
|
||||
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
|
||||
}
|
||||
to={shareSlugUrl}
|
||||
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}>
|
||||
{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}
|
||||
underline="never"
|
||||
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
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery({
|
||||
pageId: currentPageId,
|
||||
});
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery(
|
||||
shareId ? null : { pageId: currentPageId },
|
||||
);
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
// If we're in a shared context, use the shared subpages
|
||||
|
||||
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
|
||||
|
||||
export async function createWorkspace(
|
||||
data: ISetupWorkspace,
|
||||
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> {
|
||||
): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
|
||||
const req = await api.post("/workspace/create", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const APP_ROUTE = {
|
||||
SELECT_WORKSPACE: "/select",
|
||||
MFA_CHALLENGE: "/login/mfa",
|
||||
MFA_SETUP_REQUIRED: "/login/mfa/setup",
|
||||
VERIFY_EMAIL: "/verify-email",
|
||||
},
|
||||
SETTINGS: {
|
||||
ACCOUNT: {
|
||||
|
||||
Reference in New Issue
Block a user