init frontend

This commit is contained in:
Yuzhong Zhang
2025-07-05 23:22:48 +08:00
parent 94953a5eac
commit 602f4629ff
771 changed files with 194268 additions and 1 deletions
@@ -0,0 +1,19 @@
import React from "react";
import { Footer } from "../../packages/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
export const AppFooter = React.memo(() => {
return (
<Footer>
<div
style={{
display: "flex",
gap: ".5rem",
alignItems: "center",
}}
>
<EncryptedIcon />
</div>
</Footer>
);
});
@@ -0,0 +1,149 @@
import React from "react";
import { MainMenu } from "../../packages/excalidraw/index";
import { LanguageList } from "./LanguageList";
import { useAtom, useSetAtom } from "jotai";
import { userAtom, saveAsDialogAtom } from "../app-jotai";
import {
GithubIcon,
saveAs,
extraToolsIcon,
} from "../../packages/excalidraw/components/icons";
import DropdownMenuItemLink from "../../packages/excalidraw/components/dropdownMenu/DropdownMenuItemLink";
import { useI18n } from "../../packages/excalidraw/i18n";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
onStorageSettingsClick: () => void;
}> = React.memo((props) => {
const [user, setUser] = useAtom(userAtom);
const { t } = useI18n();
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
const handleLogin = () => {
window.location.href = "/auth/github/login";
};
const handleLogout = () => {
localStorage.removeItem("token");
setUser(null);
window.location.reload(); // Reload to clear all state
};
return (
<MainMenu>
<MainMenu.DefaultItems.LoadScene />
<MainMenu.DefaultItems.SaveToActiveFile />
<MainMenu.Item
onSelect={() => setSaveAsDialog({ isOpen: true })}
icon={saveAs}
>
Save as New Canvas...
</MainMenu.Item>
<MainMenu.DefaultItems.Export />
{props.isCollabEnabled && (
<MainMenu.DefaultItems.LiveCollaborationTrigger
isCollaborating={props.isCollaborating}
onSelect={() => props.onCollabDialogOpen()}
/>
)}
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.Item
onSelect={props.onStorageSettingsClick}
icon={extraToolsIcon}
>
Data Source Settings...
</MainMenu.Item>
<MainMenu.Separator />
{user ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
padding: "0 0.5rem",
width: "100%",
fontSize: "14px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
overflow: "hidden",
flexShrink: 1,
}}
>
<img
src={user.avatarUrl}
alt={user.login}
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
flexShrink: 0,
}}
/>
<span
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{user.name || user.login}
</span>
</div>
<button
onClick={handleLogout}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: "0.25rem 0.5rem",
borderRadius: "4px",
textAlign: "center",
color: "inherit",
flexShrink: 0,
marginRight: "1rem",
font: "var(--ui-font)",
fontSize: "14px",
}}
onMouseOver={(e) =>
(e.currentTarget.style.background = "var(--button-gray-1)")
}
onMouseOut={(e) =>
(e.currentTarget.style.background = "transparent")
}
>
Logout
</button>
</div>
) : (
<MainMenu.Item onSelect={handleLogin} icon={GithubIcon}>
Login with GitHub
</MainMenu.Item>
)}
<MainMenu.Separator />
<DropdownMenuItemLink
icon={GithubIcon}
href="https://github.com/excalidraw/excalidraw"
aria-label="GitHub"
>
GitHub
</DropdownMenuItemLink>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
});
@@ -0,0 +1,75 @@
import React from "react";
import { GithubIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
import { useAtom } from "jotai";
import { userAtom } from "../app-jotai";
export const AppWelcomeScreen: React.FC<{
onCollabDialogOpen: () => any;
isCollabEnabled: boolean;
}> = React.memo((props) => {
const { t } = useI18n();
const [user] = useAtom(userAtom);
let headingContent;
if (isExcalidrawPlusSignedUser) {
headingContent = t("welcomeScreen.app.center_heading_plus")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
href={`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
}
return (
<WelcomeScreen>
<WelcomeScreen.Hints.MenuHint>
{t("welcomeScreen.app.menuHint")}
</WelcomeScreen.Hints.MenuHint>
<WelcomeScreen.Hints.ToolbarHint />
<WelcomeScreen.Hints.HelpHint />
<WelcomeScreen.Center>
<WelcomeScreen.Center.Logo />
<WelcomeScreen.Center.Heading>
{headingContent}
</WelcomeScreen.Center.Heading>
<WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp />
{props.isCollabEnabled && (
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
onSelect={() => props.onCollabDialogOpen()}
/>
)}
{!user && (
<WelcomeScreen.Center.MenuItem
onSelect={() => {
window.location.href = "/auth/github/login";
}}
icon={GithubIcon}
>
Login with GitHub
</WelcomeScreen.Center.MenuItem>
)}
</WelcomeScreen.Center.Menu>
</WelcomeScreen.Center>
</WelcomeScreen>
);
});
@@ -0,0 +1,51 @@
import React, { useState, useCallback } from "react";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { useSetAtom } from "jotai";
import { createCanvasDialogAtom } from "../app-jotai";
interface CreateCanvasDialogProps {
onCanvasCreate: (name: string) => void;
}
export const CreateCanvasDialog: React.FC<CreateCanvasDialogProps> = ({
onCanvasCreate,
}) => {
const [name, setName] = useState("Untitled Canvas");
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
const handleCreate = useCallback(() => {
if (name.trim()) {
onCanvasCreate(name.trim());
setCreateCanvasDialog({ isOpen: false });
}
}, [name, onCanvasCreate, setCreateCanvasDialog]);
const handleClose = useCallback(() => {
setCreateCanvasDialog({ isOpen: false });
}, [setCreateCanvasDialog]);
return (
<Dialog onCloseRequest={handleClose} title={"Create New Canvas"}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<TextField
label="Canvas Name"
value={name}
placeholder="Enter a name for your new canvas"
onChange={setName}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
>
<FilledButton
color="primary"
label={"Create"}
onClick={handleCreate}
/>
</div>
</div>
</Dialog>
);
};
@@ -0,0 +1,21 @@
import { shield } from "../../packages/excalidraw/components/icons";
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { useI18n } from "../../packages/excalidraw/i18n";
export const EncryptedIcon = () => {
const { t } = useI18n();
return (
<a
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
aria-label={t("encrypted.link")}
>
<Tooltip label={t("encrypted.tooltip")} long={true}>
{shield}
</Tooltip>
</a>
);
};
@@ -0,0 +1,45 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../packages/excalidraw/constants";
import { Theme } from "../../packages/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ theme, dir }: { theme: Theme; dir: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="rtl-mirror"
style={{
marginTop: "calc(var(--space-factor) * -1)",
[dir === "rtl" ? "marginLeft" : "marginRight"]:
"calc(var(--space-factor) * -1)",
}}
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
/>
</a>
</svg>
),
);
@@ -0,0 +1,26 @@
import { useSetAtom } from "jotai";
import React from "react";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();
const setLangCode = useSetAtom(appLangCodeAtom);
return (
<select
className="dropdown-select dropdown-select__language"
onChange={({ target }) => setLangCode(target.value)}
value={langCode}
aria-label={t("buttons.selectLanguage")}
style={style}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.label}
</option>
))}
</select>
);
};
@@ -0,0 +1,193 @@
.my-creations-tab {
padding: 1rem;
height: 100%;
overflow-y: auto;
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--text-primary-lighter);
padding: 2rem;
svg {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
color: var(--icon-fill-color-secondary);
}
p {
margin: 0.25rem 0;
}
}
&__card {
display: flex;
flex-direction: column;
border: 1px solid var(--button-border-color);
border-radius: var(--border-radius-lg);
cursor: pointer;
overflow: hidden;
transition: all 0.15s ease-in-out;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--button-hover-border-color);
}
&--active,
&--active:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
}
&__card-thumbnail,
&__card-thumbnail--placeholder {
width: 100%;
height: 120px;
background-color: var(--canvas-background-color);
border-bottom: 1px solid var(--button-border-color);
}
&__card-thumbnail {
object-fit: contain;
}
&__card-thumbnail--placeholder {
display: flex;
align-items: center;
justify-content: center;
font: var(--ui-font);
font-style: italic;
font-size: 1rem;
color: var(--color-muted-background);
user-select: none;
}
&__card-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
gap: 0.5rem;
}
&__card-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
overflow: hidden;
flex-grow: 1;
}
&__card-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__card-date {
font-size: 0.75rem;
color: var(--text-primary-lighter);
}
&__card-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
&__card-rename,
&__card-delete {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-primary-lighter);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--border-radius-md);
transition: all 0.15s ease-in-out;
svg {
width: 1rem;
height: 1rem;
}
&:hover {
background-color: var(--button-hover-bg);
}
}
}
.my-creations-tab__item-thumbnail,
.my-creations-tab__item-thumbnail--placeholder {
width: 60px;
height: 60px;
object-fit: contain;
border-radius: var(--border-radius-md);
border: 1px solid var(--button-border-color);
background-color: var(--canvas-background-color);
flex-shrink: 0;
}
.my-creations-tab__item-details {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
gap: 0.25rem;
}
.my-creations-tab__item-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.my-creations-tab__item-rename,
.my-creations-tab__item-delete {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-primary-lighter);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--border-radius-md);
transition: all 0.15s ease-in-out;
svg {
width: 1rem;
height: 1rem;
}
&:hover {
background-color: var(--button-hover-bg);
}
}
.my-creations-tab__item-date {
font-size: 0.75rem;
}
.my-creations-tab__item-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
@@ -0,0 +1,123 @@
import React from "react";
import { useAtom, useSetAtom } from "jotai";
import {
userAtom,
createCanvasDialogAtom,
renameCanvasDialogAtom,
} from "../app-jotai";
import { CanvasMetadata } from "../data/storage";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import {
FreedrawIcon,
LoadIcon,
TrashIcon,
} from "../../packages/excalidraw/components/icons";
import "./MyCreationsTab.scss";
import clsx from "clsx";
import { timeAgo } from "../utils/time";
interface MyCreationsTabProps {
canvases: readonly CanvasMetadata[];
onCanvasSelect: (id: string) => void;
onCanvasDelete: (id: string) => void;
currentCanvasId: string | null;
}
export const MyCreationsTab: React.FC<MyCreationsTabProps> = ({
canvases,
onCanvasSelect,
onCanvasDelete,
currentCanvasId,
}) => {
const [user] = useAtom(userAtom);
const setCreateCanvasDialog = useSetAtom(createCanvasDialogAtom);
const setRenameCanvasDialog = useSetAtom(renameCanvasDialogAtom);
const sortedCanvases = [...canvases].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);
return (
<div className="my-creations-tab">
<div style={{ marginBottom: "1rem" }}>
<FilledButton
label="Create New Canvas"
onClick={() => setCreateCanvasDialog({ isOpen: true })}
fullWidth
>
Create New Canvas
</FilledButton>
</div>
<div className="my-creations-tab__grid">
{canvases.length === 0 ? (
<div className="my-creations-tab__empty">
{LoadIcon}
<p>You have no saved canvases yet.</p>
<p>
Create a new canvas to get started. It will be saved{" "}
{user ? "to your account" : "in your browser"}.
</p>
</div>
) : (
sortedCanvases.map((canvas) => (
<div
key={canvas.id}
className={clsx("my-creations-tab__card", {
"my-creations-tab__card--active": canvas.id === currentCanvasId,
})}
onClick={() => onCanvasSelect(canvas.id)}
>
{canvas.thumbnail ? (
<img
src={canvas.thumbnail}
alt={canvas.name}
className="my-creations-tab__card-thumbnail"
/>
) : (
<div className="my-creations-tab__card-thumbnail--placeholder">
</div>
)}
<div className="my-creations-tab__card-info">
<div className="my-creations-tab__card-details">
<span className="my-creations-tab__card-name">
{canvas.name}
</span>
<span className="my-creations-tab__card-date">
{timeAgo(canvas.updatedAt)}
</span>
</div>
<div className="my-creations-tab__card-actions">
<button
className="my-creations-tab__card-rename"
title="Rename canvas"
onClick={(e) => {
e.stopPropagation();
setRenameCanvasDialog({
isOpen: true,
canvasId: canvas.id,
currentName: canvas.name,
});
}}
>
{FreedrawIcon}
</button>
<button
className="my-creations-tab__card-delete"
title="Delete canvas"
onClick={(e) => {
e.stopPropagation();
onCanvasDelete(canvas.id);
}}
>
{TrashIcon}
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
);
};
@@ -0,0 +1,61 @@
import React, { useState, useCallback, useEffect } from "react";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { useAtom } from "jotai";
import { renameCanvasDialogAtom } from "../app-jotai";
interface RenameCanvasDialogProps {
onCanvasRename: (id: string, newName: string) => void;
}
export const RenameCanvasDialog: React.FC<RenameCanvasDialogProps> = ({
onCanvasRename,
}) => {
const [dialogState, setDialogState] = useAtom(renameCanvasDialogAtom);
const [name, setName] = useState("");
useEffect(() => {
if (dialogState.isOpen && dialogState.currentName) {
setName(dialogState.currentName);
}
}, [dialogState.isOpen, dialogState.currentName]);
const handleRename = useCallback(() => {
if (name.trim() && dialogState.canvasId) {
onCanvasRename(dialogState.canvasId, name.trim());
setDialogState({ isOpen: false, canvasId: null, currentName: null });
}
}, [name, dialogState.canvasId, onCanvasRename, setDialogState]);
const handleClose = useCallback(() => {
setDialogState({ isOpen: false, canvasId: null, currentName: null });
}, [setDialogState]);
if (!dialogState.isOpen) {
return null;
}
return (
<Dialog onCloseRequest={handleClose} title={"Rename Canvas"}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<TextField
label="New Name"
value={name}
placeholder="Enter a new name for the canvas"
onChange={setName}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
/>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
>
<FilledButton
color="primary"
label={"Rename"}
onClick={handleRename}
/>
</div>
</div>
</Dialog>
);
};
@@ -0,0 +1,53 @@
import React, { useState, useCallback } from "react";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { useSetAtom } from "jotai";
import { saveAsDialogAtom } from "../app-jotai";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
interface SaveAsDialogProps {
onCanvasSaveAs: (name: string) => void;
}
export const SaveAsDialog: React.FC<SaveAsDialogProps> = ({
onCanvasSaveAs,
}) => {
const appState = useUIAppState();
const [name, setName] = useState(appState.name || "Untitled Canvas");
const setSaveAsDialog = useSetAtom(saveAsDialogAtom);
const handleSaveAs = useCallback(() => {
if (name.trim()) {
onCanvasSaveAs(name.trim());
setSaveAsDialog({ isOpen: false });
}
}, [name, onCanvasSaveAs, setSaveAsDialog]);
const handleClose = useCallback(() => {
setSaveAsDialog({ isOpen: false });
}, [setSaveAsDialog]);
return (
<Dialog onCloseRequest={handleClose} title={"Save as New Canvas"}>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<TextField
label="Canvas Name"
value={name}
placeholder="Enter a name for the new canvas"
onChange={setName}
onKeyDown={(e) => e.key === "Enter" && handleSaveAs()}
/>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
>
<FilledButton
color="primary"
label={"Save As"}
onClick={handleSaveAs}
/>
</div>
</div>
</Dialog>
);
};
@@ -0,0 +1,35 @@
import React from "react";
import { Card } from "../../packages/excalidraw/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
import { useI18n } from "../../packages/excalidraw/i18n";
import { ExportImageIcon } from "../../packages/excalidraw/components/icons";
export const SaveAsImageUI: React.FC<{
onSuccess: () => void;
}> = ({ onSuccess }) => {
const { t } = useI18n();
return (
<Card color="primary">
<div className="Card-icon">
{React.cloneElement(ExportImageIcon as React.ReactElement, {
style: {
width: "2.8rem",
height: "2.8rem",
},
})}
</div>
<h2>{t("buttons.exportImage")}</h2>
<div className="Card-details">
Save your canvas to a file in PNG, SVG or WebP format.
</div>
<ToolButton
className="Card-button"
type="button"
title={t("buttons.exportImage")}
aria-label={t("buttons.exportImage")}
showAriaLabel={true}
onClick={onSuccess}
/>
</Card>
);
};
@@ -0,0 +1,151 @@
import React, { useState } from "react";
import { Dialog } from "../../packages/excalidraw/components/Dialog";
import { Island } from "../../packages/excalidraw/components/Island";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { useAtom } from "jotai";
import { storageConfigAtom } from "../app-jotai";
export type StorageType = "default" | "kv" | "s3" | "indexed-db";
const StorageSettingsDialog = ({ onClose }: { onClose: () => void }) => {
const [config, setConfig] = useAtom(storageConfigAtom);
const [storageType, setStorageType] = useState<StorageType>(config.type);
// Local state for form inputs
const [kvUrl, setKvUrl] = useState(config.kvUrl || "");
const [kvApiToken, setKvApiToken] = useState(config.kvApiToken || "");
const [s3AccessKeyId, setS3AccessKeyId] = useState(
config.s3AccessKeyId || "",
);
const [s3SecretAccessKey, setS3SecretAccessKey] = useState(
config.s3SecretAccessKey || "",
);
const [s3Region, setS3Region] = useState(config.s3Region || "");
const [s3BucketName, setS3BucketName] = useState(config.s3BucketName || "");
const handleSave = () => {
setConfig({
type: storageType,
kvUrl,
kvApiToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
s3BucketName,
});
onClose();
};
const renderForm = () => {
switch (storageType) {
case "kv":
return (
<>
<TextField
label="KV URL"
value={kvUrl}
placeholder="Your Cloudflare KV URL"
onChange={setKvUrl}
/>
<TextField
label="API Token"
value={kvApiToken}
placeholder="Your Cloudflare API Token"
onChange={setKvApiToken}
/>
</>
);
case "s3":
return (
<>
<TextField
label="Access Key ID"
value={s3AccessKeyId}
placeholder="Your AWS Access Key ID"
onChange={setS3AccessKeyId}
/>
<TextField
label="Secret Access Key"
value={s3SecretAccessKey}
placeholder="Your AWS Secret Access Key"
onChange={setS3SecretAccessKey}
/>
<TextField
label="Region"
value={s3Region}
placeholder="e.g., us-east-1"
onChange={setS3Region}
/>
<TextField
label="Bucket Name"
value={s3BucketName}
placeholder="Your S3 Bucket Name"
onChange={setS3BucketName}
/>
</>
);
case "indexed-db":
return (
<p>
Your canvases are stored securely in your browser's local database.
They are not synced online.
</p>
);
case "default":
default:
return (
<p>
Your data is stored on the default backend of this Excalidraw
instance. This requires you to be logged in.
</p>
);
}
};
return (
<Dialog
onCloseRequest={onClose}
title={"Data Source Settings"}
className="storage-settings-dialog"
>
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<p>
Security Warning: Sensitive keys are stored only in your browser's
session storage and are cleared when you close the tab.
</p>
<select
value={storageType}
onChange={(e) => setStorageType(e.target.value as StorageType)}
style={{
padding: "0.5rem",
borderRadius: "var(--border-radius-lg)",
border: "1px solid var(--color-border)",
}}
>
<option value="indexed-db">Browser (IndexedDB)</option>
<option value="default">Default Backend (Online)</option>
<option value="kv">Cloudflare KV (Online)</option>
<option value="s3">Amazon S3 (Online)</option>
</select>
<Island style={{ padding: "1rem" }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
>
{renderForm()}
</div>
</Island>
<div
style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}
>
<FilledButton color="primary" label={"Save"} onClick={handleSave} />
</div>
</div>
</Dialog>
);
};
export default StorageSettingsDialog;
@@ -0,0 +1,144 @@
import React from "react";
import * as Sentry from "@sentry/browser";
import { t } from "../../packages/excalidraw/i18n";
import Trans from "../../packages/excalidraw/components/Trans";
interface TopErrorBoundaryState {
hasError: boolean;
sentryEventId: string;
localStorage: string;
}
export class TopErrorBoundary extends React.Component<
any,
TopErrorBoundaryState
> {
state: TopErrorBoundaryState = {
hasError: false,
sentryEventId: "",
localStorage: "",
};
render() {
return this.state.hasError ? this.errorSplash() : this.props.children;
}
componentDidCatch(error: Error, errorInfo: any) {
const _localStorage: any = {};
for (const [key, value] of Object.entries({ ...localStorage })) {
try {
_localStorage[key] = JSON.parse(value);
} catch (error: any) {
_localStorage[key] = value;
}
}
Sentry.withScope((scope) => {
scope.setExtras(errorInfo);
const eventId = Sentry.captureException(error);
this.setState((state) => ({
hasError: true,
sentryEventId: eventId,
localStorage: JSON.stringify(_localStorage),
}));
});
}
private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
if (event.target !== document.activeElement) {
event.preventDefault();
(event.target as HTMLTextAreaElement).select();
}
}
private async createGithubIssue() {
let body = "";
try {
const templateStrFn = (
await import(
/* webpackChunkName: "bug-issue-template" */ "../bug-issue-template"
)
).default;
body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
} catch (error: any) {
console.error(error);
}
window.open(
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
);
}
private errorSplash() {
return (
<div className="ErrorSplash excalidraw">
<div className="ErrorSplash-messageContainer">
<div className="ErrorSplash-paragraph bigger align-center">
<Trans
i18nKey="errorSplash.headingMain"
button={(el) => (
<button onClick={() => window.location.reload()}>{el}</button>
)}
/>
</div>
<div className="ErrorSplash-paragraph align-center">
<Trans
i18nKey="errorSplash.clearCanvasMessage"
button={(el) => (
<button
onClick={() => {
try {
localStorage.clear();
window.location.reload();
} catch (error: any) {
console.error(error);
}
}}
>
{el}
</button>
)}
/>
<br />
<div className="smaller">
<span role="img" aria-label="warning">
</span>
{t("errorSplash.clearCanvasCaveat")}
<span role="img" aria-hidden="true">
</span>
</div>
</div>
<div>
<div className="ErrorSplash-paragraph">
{t("errorSplash.trackedToSentry", {
eventId: this.state.sentryEventId,
})}
</div>
<div className="ErrorSplash-paragraph">
<Trans
i18nKey="errorSplash.openIssueMessage"
button={(el) => (
<button onClick={() => this.createGithubIssue()}>{el}</button>
)}
/>
</div>
<div className="ErrorSplash-paragraph">
<div className="ErrorSplash-details">
<label>{t("errorSplash.sceneContent")}</label>
<textarea
rows={5}
onPointerDown={this.selectTextArea}
readOnly={true}
value={this.state.localStorage}
/>
</div>
</div>
</div>
</div>
</div>
);
}
}