mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-05 06:32:56 +00:00
init frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user