Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847fd3da32 | ||
|
|
3e334a67ed | ||
|
|
1d71f84515 | ||
|
|
550a388b2b | ||
|
|
6b523563d8 | ||
|
|
65bc500598 | ||
|
|
7949aa1f1c | ||
|
|
15bfa626b4 | ||
|
|
068895db0e | ||
|
|
b7babe554b | ||
|
|
6a385d6663 | ||
|
|
2382fad4f6 | ||
|
|
480572f893 | ||
|
|
68b1fdb20e | ||
|
|
a38e82f999 | ||
|
|
a07f6e9e3a | ||
|
|
7e471b55eb | ||
|
|
160440b860 | ||
|
|
f207bd0a1c | ||
|
|
99601baffc | ||
|
|
af1a3d5b76 | ||
|
|
36e56267c9 | ||
|
|
b09b5cb5f4 | ||
|
|
dd8529743a | ||
|
|
f639d44a95 | ||
|
|
f5ab3e4e12 | ||
|
|
361a9449bb | ||
|
|
2e719ff671 | ||
|
|
79d9dc2f8f | ||
|
|
9013c84524 | ||
|
|
47f87f4ecb | ||
|
|
73bf50e8a8 | ||
|
|
48c3465b19 | ||
|
|
adc4c9f484 | ||
|
|
def1df2c68 |
+2
-1
@@ -25,4 +25,5 @@ packages/excalidraw/types
|
|||||||
coverage
|
coverage
|
||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
examples/**/bundle.*
|
examples/**/bundle.*
|
||||||
|
meta*.json
|
||||||
@@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
"use client";
|
"use client";
|
||||||
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
height: 141.9765625,
|
height: 141.9765625,
|
||||||
},]));
|
},]));
|
||||||
return (
|
return (
|
||||||
<div style={{height:"500px", width:"500px"}}
|
<div style={{height:"500px", width:"500px"}}>
|
||||||
<Excalidraw />
|
<Excalidraw />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+257
-43
@@ -30,7 +30,6 @@ import {
|
|||||||
} from "../packages/excalidraw/index";
|
} from "../packages/excalidraw/index";
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
LibraryItems,
|
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
ExcalidrawInitialDataState,
|
ExcalidrawInitialDataState,
|
||||||
@@ -48,6 +47,7 @@ import {
|
|||||||
} from "../packages/excalidraw/utils";
|
} from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
FIREBASE_STORAGE_PREFIXES,
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
|
isExcalidrawPlusSignedUser,
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
@@ -64,7 +64,6 @@ import {
|
|||||||
loadScene,
|
loadScene,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
@@ -82,7 +81,11 @@ import { updateStaleImageStatuses } from "./data/FileManager";
|
|||||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||||
import { loadFilesFromFirebase } from "./data/firebase";
|
import { loadFilesFromFirebase } from "./data/firebase";
|
||||||
import { LocalData } from "./data/LocalData";
|
import {
|
||||||
|
LibraryIndexedDBAdapter,
|
||||||
|
LibraryLocalStorageMigrationAdapter,
|
||||||
|
LocalData,
|
||||||
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { reconcileElements } from "./collab/reconciliation";
|
import { reconcileElements } from "./collab/reconciliation";
|
||||||
@@ -104,6 +107,20 @@ import { openConfirmModal } from "../packages/excalidraw/components/OverwriteCon
|
|||||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||||
import Trans from "../packages/excalidraw/components/Trans";
|
import Trans from "../packages/excalidraw/components/Trans";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
|
||||||
|
import {
|
||||||
|
GithubIcon,
|
||||||
|
XBrandIcon,
|
||||||
|
DiscordIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
usersIcon,
|
||||||
|
exportToPlus,
|
||||||
|
share,
|
||||||
|
} from "../packages/excalidraw/components/icons";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -310,10 +327,13 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
return isCollaborationLink(window.location.href);
|
return isCollaborationLink(window.location.href);
|
||||||
});
|
});
|
||||||
|
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
adapter: LibraryIndexedDBAdapter,
|
||||||
|
// TODO maybe remove this in several months (shipped: 24-03-11)
|
||||||
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -443,8 +463,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
});
|
});
|
||||||
excalidrawAPI.updateLibrary({
|
LibraryIndexedDBAdapter.load().then((data) => {
|
||||||
libraryItems: getLibraryItemsFromStorage(),
|
if (data) {
|
||||||
|
excalidrawAPI.updateLibrary({
|
||||||
|
libraryItems: data.libraryItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
collabAPI?.setUsername(username || "");
|
collabAPI?.setUsername(username || "");
|
||||||
}
|
}
|
||||||
@@ -656,15 +680,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryChange = async (items: LibraryItems) => {
|
|
||||||
if (!items.length) {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serializedItems = JSON.stringify(items);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
@@ -691,6 +706,45 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExcalidrawPlusCommand = {
|
||||||
|
label: "Excalidraw+",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: ["plus", "cloud", "server"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ExcalidrawPlusAppCommand = {
|
||||||
|
label: "Sign up",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: [
|
||||||
|
"excalidraw",
|
||||||
|
"plus",
|
||||||
|
"cloud",
|
||||||
|
"server",
|
||||||
|
"signin",
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
@@ -709,27 +763,30 @@ const ExcalidrawWrapper = () => {
|
|||||||
toggleTheme: true,
|
toggleTheme: true,
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
renderCustomUI: (elements, appState, files) => {
|
renderCustomUI: excalidrawAPI
|
||||||
return (
|
? (elements, appState, files) => {
|
||||||
<ExportToExcalidrawPlus
|
return (
|
||||||
elements={elements}
|
<ExportToExcalidrawPlus
|
||||||
appState={appState}
|
elements={elements}
|
||||||
files={files}
|
appState={appState}
|
||||||
onError={(error) => {
|
files={files}
|
||||||
excalidrawAPI?.updateScene({
|
name={excalidrawAPI.getName()}
|
||||||
appState: {
|
onError={(error) => {
|
||||||
errorMessage: error.message,
|
excalidrawAPI?.updateScene({
|
||||||
},
|
appState: {
|
||||||
});
|
errorMessage: error.message,
|
||||||
}}
|
},
|
||||||
onSuccess={() => {
|
});
|
||||||
excalidrawAPI?.updateScene({
|
}}
|
||||||
appState: { openDialog: null },
|
onSuccess={() => {
|
||||||
});
|
excalidrawAPI.updateScene({
|
||||||
}}
|
appState: { openDialog: null },
|
||||||
/>
|
});
|
||||||
);
|
}}
|
||||||
},
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -737,7 +794,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
onLibraryChange={onLibraryChange}
|
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
@@ -745,12 +801,15 @@ const ExcalidrawWrapper = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LiveCollaborationTrigger
|
<div className="top-right-ui">
|
||||||
isCollaborating={isCollaborating}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
onSelect={() =>
|
<LiveCollaborationTrigger
|
||||||
setShareDialogState({ isOpen: true, type: "share" })
|
isCollaborating={isCollaborating}
|
||||||
}
|
onSelect={() =>
|
||||||
/>
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -775,6 +834,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.getSceneElements(),
|
excalidrawAPI.getSceneElements(),
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -879,6 +939,160 @@ const ExcalidrawWrapper = () => {
|
|||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
customCommandPaletteItems={[
|
||||||
|
{
|
||||||
|
label: t("labels.liveCollaboration"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
keywords: [
|
||||||
|
"team",
|
||||||
|
"multiplayer",
|
||||||
|
"share",
|
||||||
|
"public",
|
||||||
|
"session",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
icon: usersIcon,
|
||||||
|
perform: () => {
|
||||||
|
setShareDialogState({
|
||||||
|
isOpen: true,
|
||||||
|
type: "collaborationOnly",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("roomDialog.button_stopSession"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: () => !!collabAPI?.isCollaborating(),
|
||||||
|
keywords: [
|
||||||
|
"stop",
|
||||||
|
"session",
|
||||||
|
"end",
|
||||||
|
"leave",
|
||||||
|
"close",
|
||||||
|
"exit",
|
||||||
|
"collaboration",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
if (collabAPI) {
|
||||||
|
collabAPI.stopCollaboration();
|
||||||
|
if (!collabAPI.isCollaborating()) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.share"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: true,
|
||||||
|
icon: share,
|
||||||
|
keywords: [
|
||||||
|
"link",
|
||||||
|
"shareable",
|
||||||
|
"readonly",
|
||||||
|
"export",
|
||||||
|
"publish",
|
||||||
|
"snapshot",
|
||||||
|
"url",
|
||||||
|
"collaborate",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
perform: async () => {
|
||||||
|
setShareDialogState({ isOpen: true, type: "share" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
icon: GithubIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: [
|
||||||
|
"issues",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"features",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://github.com/excalidraw/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.followUs"),
|
||||||
|
icon: XBrandIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["twitter", "contact", "social", "community"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://x.com/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.discordChat"),
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: DiscordIcon,
|
||||||
|
keywords: [
|
||||||
|
"chat",
|
||||||
|
"talk",
|
||||||
|
"contact",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"feedback",
|
||||||
|
"suggestions",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://discord.gg/UexuTaE",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(isExcalidrawPlusSignedUser
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...ExcalidrawPlusAppCommand,
|
||||||
|
label: "Sign in / Go to Excalidraw+",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
|
||||||
|
|
||||||
|
{
|
||||||
|
label: t("overwriteConfirm.action.excalidrawPlus.button"),
|
||||||
|
category: DEFAULT_CATEGORIES.export,
|
||||||
|
icon: exportToPlus,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["plus", "export", "save", "backup"],
|
||||||
|
perform: () => {
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
exportToExcalidrawPlus(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CommandPalette.defaultItems.toggleTheme,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
IDB_LIBRARY: "excalidraw-library",
|
||||||
|
|
||||||
|
// do not use apart from migrations
|
||||||
|
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const COOKIES = {
|
export const COOKIES = {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import { appJotaiStore } from "../app-jotai";
|
|||||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
@@ -88,6 +89,8 @@ export const isOfflineAtom = atom(false);
|
|||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
/** errors related to saving */
|
||||||
|
dialogNotifiedErrors: Record<string, boolean>;
|
||||||
username: string;
|
username: string;
|
||||||
activeRoomLink: string | null;
|
activeRoomLink: string | null;
|
||||||
}
|
}
|
||||||
@@ -107,7 +110,7 @@ export interface CollabAPI {
|
|||||||
setUsername: CollabInstance["setUsername"];
|
setUsername: CollabInstance["setUsername"];
|
||||||
getUsername: CollabInstance["getUsername"];
|
getUsername: CollabInstance["getUsername"];
|
||||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||||
setErrorMessage: CollabInstance["setErrorMessage"];
|
setCollabError: CollabInstance["setErrorDialog"];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollabProps {
|
interface CollabProps {
|
||||||
@@ -129,6 +132,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
activeRoomLink: null,
|
activeRoomLink: null,
|
||||||
};
|
};
|
||||||
@@ -197,7 +201,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
setUsername: this.setUsername,
|
setUsername: this.setUsername,
|
||||||
getUsername: this.getUsername,
|
getUsername: this.getUsername,
|
||||||
getActiveRoomLink: this.getActiveRoomLink,
|
getActiveRoomLink: this.getActiveRoomLink,
|
||||||
setErrorMessage: this.setErrorMessage,
|
setCollabError: this.setErrorDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
@@ -276,18 +280,35 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.excalidrawAPI.getAppState(),
|
this.excalidrawAPI.getAppState(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.resetErrorIndicator();
|
||||||
|
|
||||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this.reconcileElements(savedData.reconciledElements),
|
this.reconcileElements(savedData.reconciledElements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.setState({
|
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||||
// firestore doesn't return a specific error code when size exceeded
|
? t("errors.collabSaveFailed_sizeExceeded")
|
||||||
errorMessage: /is longer than.*?bytes/.test(error.message)
|
: t("errors.collabSaveFailed");
|
||||||
? t("errors.collabSaveFailed_sizeExceeded")
|
|
||||||
: t("errors.collabSaveFailed"),
|
if (
|
||||||
});
|
!this.state.dialogNotifiedErrors[errorMessage] ||
|
||||||
|
!this.isCollaborating()
|
||||||
|
) {
|
||||||
|
this.setErrorDialog(errorMessage);
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {
|
||||||
|
...this.state.dialogNotifiedErrors,
|
||||||
|
[errorMessage]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCollaborating()) {
|
||||||
|
this.setErrorIndicator(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -296,6 +317,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
|
this.resetErrorIndicator(true);
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
@@ -464,7 +486,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setErrorDialog(error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,8 +945,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||||
|
|
||||||
setErrorMessage = (errorMessage: string | null) => {
|
setErrorIndicator = (errorMessage: string | null) => {
|
||||||
this.setState({ errorMessage });
|
appJotaiStore.set(collabErrorIndicatorAtom, {
|
||||||
|
message: errorMessage,
|
||||||
|
nonce: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
|
||||||
|
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
|
||||||
|
if (resetDialogNotifiedErrors) {
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setErrorDialog = (errorMessage: string | null) => {
|
||||||
|
this.setState({
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -933,7 +973,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{errorMessage != null && (
|
{errorMessage != null && (
|
||||||
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
|
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.collab-errors-button {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
margin-inline-end: 1rem;
|
||||||
|
|
||||||
|
color: var(--color-danger);
|
||||||
|
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collab-errors-button-shake {
|
||||||
|
animation: strong-shake 0.15s 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes strong-shake {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(0eg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
|
||||||
|
import { warning } from "../../packages/excalidraw/components/icons";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import "./CollabError.scss";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
type ErrorIndicator = {
|
||||||
|
message: string | null;
|
||||||
|
/** used to rerun the useEffect responsible for animation */
|
||||||
|
nonce: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
|
||||||
|
message: null,
|
||||||
|
nonce: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsAnimating(true);
|
||||||
|
clearAnimationRef.current = setTimeout(() => {
|
||||||
|
setIsAnimating(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(clearAnimationRef.current);
|
||||||
|
};
|
||||||
|
}, [collabError.message, collabError.nonce]);
|
||||||
|
|
||||||
|
if (!collabError.message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={collabError.message} long={true}>
|
||||||
|
<div
|
||||||
|
className={clsx("collab-errors-button", {
|
||||||
|
"collab-errors-button-shake": isAnimating,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CollabError.displayName = "CollabError";
|
||||||
|
|
||||||
|
export default CollabError;
|
||||||
@@ -65,19 +65,18 @@ export const RoomModal = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
onSelect={() => props.onCollabDialogOpen()}
|
onSelect={() => props.onCollabDialogOpen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MainMenu.DefaultItems.CommandPalette />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
.ref(`/migrations/scenes/${id}`)
|
.ref(`/migrations/scenes/${id}`)
|
||||||
.put(blob, {
|
.put(blob, {
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
data: JSON.stringify({ version: 2, name: appState.name }),
|
data: JSON.stringify({ version: 2, name }),
|
||||||
created: Date.now().toString(),
|
created: Date.now().toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: Partial<AppState>;
|
appState: Partial<AppState>;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
|
name: string;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card color="primary">
|
<Card color="primary">
|
||||||
@@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||||
await exportToExcalidrawPlus(elements, appState, files);
|
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,18 @@
|
|||||||
* (localStorage, indexedDB).
|
* (localStorage, indexedDB).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
import {
|
||||||
|
createStore,
|
||||||
|
entries,
|
||||||
|
del,
|
||||||
|
getMany,
|
||||||
|
set,
|
||||||
|
setMany,
|
||||||
|
get,
|
||||||
|
} from "idb-keyval";
|
||||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||||
|
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||||
|
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@@ -22,6 +32,7 @@ import {
|
|||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||||
import { debounce } from "../../packages/excalidraw/utils";
|
import { debounce } from "../../packages/excalidraw/utils";
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
@@ -183,3 +194,52 @@ export class LocalData {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export class LibraryIndexedDBAdapter {
|
||||||
|
/** IndexedDB database and store name */
|
||||||
|
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
|
||||||
|
/** library data store key */
|
||||||
|
private static key = "libraryData";
|
||||||
|
|
||||||
|
private static store = createStore(
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-db`,
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-store`,
|
||||||
|
);
|
||||||
|
|
||||||
|
static async load() {
|
||||||
|
const IDBData = await get<LibraryPersistedData>(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
|
||||||
|
return IDBData || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static save(data: LibraryPersistedData): MaybePromise<void> {
|
||||||
|
return set(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
data,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LS Adapter used only for migrating LS library data
|
||||||
|
* to indexedDB */
|
||||||
|
export class LibraryLocalStorageMigrationAdapter {
|
||||||
|
static load() {
|
||||||
|
const LSData = localStorage.getItem(
|
||||||
|
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
|
||||||
|
);
|
||||||
|
if (LSData != null) {
|
||||||
|
const libraryItems: ImportedDataState["libraryItems"] =
|
||||||
|
JSON.parse(LSData);
|
||||||
|
if (libraryItems) {
|
||||||
|
return { libraryItems };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
static clear() {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "../../packages/excalidraw/appState";
|
} from "../../packages/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
|
|||||||
try {
|
try {
|
||||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
|
|
||||||
const appStateSize = appState?.length || 0;
|
const appStateSize = appState?.length || 0;
|
||||||
const collabSize = collab?.length || 0;
|
const collabSize = collab?.length || 0;
|
||||||
const librarySize = library?.length || 0;
|
|
||||||
|
|
||||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
return appStateSize + collabSize + getElementsStorageSize();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryItemsFromStorage = () => {
|
|
||||||
try {
|
|
||||||
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
return libraryItems || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!------------------------------------------------------------------------->
|
<!------------------------------------------------------------------------->
|
||||||
<% if ("%PROD%" === "true") { %>
|
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||||
//
|
//
|
||||||
@@ -122,7 +122,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
{
|
{
|
||||||
const _WebSocket = window.WebSocket;
|
const _WebSocket = window.WebSocket;
|
||||||
@@ -196,7 +197,8 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="index.tsx"></script>
|
<script type="module" src="index.tsx"></script>
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %>
|
||||||
<!-- 100% privacy friendly analytics -->
|
<!-- 100% privacy friendly analytics -->
|
||||||
<script>
|
<script>
|
||||||
// need to load this script dynamically bcs. of iframe embed tracking
|
// need to load this script dynamically bcs. of iframe embed tracking
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-right-ui {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"vite-plugin-html": "3.2.2"
|
||||||
|
},
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
@@ -22,6 +22,7 @@ import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
|||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
@@ -69,20 +70,20 @@ const ActiveRoomDialog = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
collabAPI.setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,6 +276,14 @@ export const ShareDialog = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
|
|
||||||
|
const { openDialog } = useUIAppState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openDialog) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}, [openDialog, setShareDialogState]);
|
||||||
|
|
||||||
if (!shareDialogState.isOpen) {
|
if (!shareDialogState.isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -285,6 +294,6 @@ export const ShareDialog = (props: {
|
|||||||
collabAPI={props.collabAPI}
|
collabAPI={props.collabAPI}
|
||||||
onExportToBackend={props.onExportToBackend}
|
onExportToBackend={props.onExportToBackend}
|
||||||
type={shareDialogState.type}
|
type={shareDialogState.type}
|
||||||
></ShareDialogInner>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr";
|
|||||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
|
||||||
// To load .env.local variables
|
// To load .env.local variables
|
||||||
const envVars = loadEnv("", `../`);
|
const envVars = loadEnv("", `../`);
|
||||||
@@ -189,6 +190,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
minify: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
publicDir: "../public",
|
publicDir: "../public",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,24 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||||
|
|
||||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
|
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
||||||
|
|
||||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||||
|
|
||||||
#### Bundler
|
#### Bundler
|
||||||
@@ -57,10 +67,12 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
|
||||||
|
|
||||||
## 0.17.1 (2023-11-28)
|
## 0.17.3 (2024-02-09)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||||
|
|
||||||
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
|
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -69,6 +81,10 @@ define: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||||
|
|
||||||
|
- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339)
|
||||||
|
|
||||||
## Excalidraw Library
|
## Excalidraw Library
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -58,5 +58,5 @@ export const actionAddToLibrary = register({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
label: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const alignActionsPredicate = (
|
const alignActionsPredicate = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: UIAppState,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
@@ -59,6 +59,8 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
|
label: "labels.alignTop",
|
||||||
|
icon: AlignTopIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -90,6 +92,8 @@ export const actionAlignTop = register({
|
|||||||
|
|
||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
|
label: "labels.alignBottom",
|
||||||
|
icon: AlignBottomIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -121,6 +125,8 @@ export const actionAlignBottom = register({
|
|||||||
|
|
||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
|
label: "labels.alignLeft",
|
||||||
|
icon: AlignLeftIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -152,6 +158,8 @@ export const actionAlignLeft = register({
|
|||||||
|
|
||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
|
label: "labels.alignRight",
|
||||||
|
icon: AlignRightIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -183,6 +191,8 @@ export const actionAlignRight = register({
|
|||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
|
label: "labels.centerVertically",
|
||||||
|
icon: CenterVerticallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -210,6 +220,8 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
|
label: "labels.centerHorizontally",
|
||||||
|
icon: CenterHorizontallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { register } from "./register";
|
|||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
contextItemLabel: "labels.unbindText",
|
label: "labels.unbindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -58,7 +58,11 @@ export const actionUnbindText = register({
|
|||||||
element.id,
|
element.id,
|
||||||
);
|
);
|
||||||
resetOriginalContainerCache(element.id);
|
resetOriginalContainerCache(element.id);
|
||||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
@@ -87,7 +91,7 @@ export const actionUnbindText = register({
|
|||||||
|
|
||||||
export const actionBindText = register({
|
export const actionBindText = register({
|
||||||
name: "bindText",
|
name: "bindText",
|
||||||
contextItemLabel: "labels.bindText",
|
label: "labels.bindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -144,7 +148,11 @@ export const actionBindText = register({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const originalContainerHeight = container.height;
|
const originalContainerHeight = container.height;
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
// overwritting the cache with original container height so
|
// overwritting the cache with original container height so
|
||||||
// it can be restored when unbind
|
// it can be restored when unbind
|
||||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||||
@@ -195,7 +203,7 @@ const pushContainerBelowText = (
|
|||||||
|
|
||||||
export const actionWrapTextInContainer = register({
|
export const actionWrapTextInContainer = register({
|
||||||
name: "wrapTextInContainer",
|
name: "wrapTextInContainer",
|
||||||
contextItemLabel: "labels.createContainerFromText",
|
label: "labels.createContainerFromText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -285,7 +293,11 @@ export const actionWrapTextInContainer = register({
|
|||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
updatedElements = pushContainerBelowText(
|
updatedElements = pushContainerBelowText(
|
||||||
[...updatedElements, container],
|
[...updatedElements, container],
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
import {
|
||||||
|
handIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SunIcon,
|
||||||
|
TrashIcon,
|
||||||
|
zoomAreaIcon,
|
||||||
|
ZoomInIcon,
|
||||||
|
ZoomOutIcon,
|
||||||
|
ZoomResetIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
@@ -25,6 +34,8 @@ import { setCursor } from "../cursor";
|
|||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
|
label: "labels.canvasBackground",
|
||||||
|
paletteName: "Change canvas background color",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +70,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
|
label: "labels.clearCanvas",
|
||||||
|
paletteName: "Clear canvas",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +109,9 @@ export const actionClearCanvas = register({
|
|||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
|
label: "buttons.zoomIn",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
|
icon: ZoomInIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -133,6 +149,8 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
|
label: "buttons.zoomOut",
|
||||||
|
icon: ZoomOutIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@@ -171,6 +189,8 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
|
label: "buttons.resetZoom",
|
||||||
|
icon: ZoomResetIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@@ -340,6 +360,8 @@ export const zoomToFit = ({
|
|||||||
// size, it won't be zoomed in.
|
// size, it won't be zoomed in.
|
||||||
export const actionZoomToFitSelectionInViewport = register({
|
export const actionZoomToFitSelectionInViewport = register({
|
||||||
name: "zoomToFitSelectionInViewport",
|
name: "zoomToFitSelectionInViewport",
|
||||||
|
label: "labels.zoomToFitViewport",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -363,6 +385,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
|
|
||||||
export const actionZoomToFitSelection = register({
|
export const actionZoomToFitSelection = register({
|
||||||
name: "zoomToFitSelection",
|
name: "zoomToFitSelection",
|
||||||
|
label: "helpDialog.zoomToSelection",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@@ -385,6 +409,8 @@ export const actionZoomToFitSelection = register({
|
|||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
|
label: "helpDialog.zoomToFit",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
@@ -405,6 +431,11 @@ export const actionZoomToFit = register({
|
|||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
|
label: (_, appState) => {
|
||||||
|
return appState.theme === "dark" ? "buttons.lightMode" : "buttons.darkMode";
|
||||||
|
},
|
||||||
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
|
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
@@ -425,6 +456,7 @@ export const actionToggleTheme = register({
|
|||||||
|
|
||||||
export const actionToggleEraserTool = register({
|
export const actionToggleEraserTool = register({
|
||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
@@ -459,7 +491,11 @@ export const actionToggleEraserTool = register({
|
|||||||
|
|
||||||
export const actionToggleHandTool = register({
|
export const actionToggleHandTool = register({
|
||||||
name: "toggleHandTool",
|
name: "toggleHandTool",
|
||||||
|
label: "toolBar.hand",
|
||||||
|
paletteName: "Toggle hand tool",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: handIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
label: "labels.copy",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
const elementsToCopy = app.scene.getSelectedElements({
|
const elementsToCopy = app.scene.getSelectedElements({
|
||||||
@@ -40,13 +43,13 @@ export const actionCopy = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copy",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPaste = register({
|
export const actionPaste = register({
|
||||||
name: "paste",
|
name: "paste",
|
||||||
|
label: "labels.paste",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, data, app) => {
|
perform: async (elements, appState, data, app) => {
|
||||||
let types;
|
let types;
|
||||||
@@ -97,24 +100,26 @@ export const actionPaste = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.paste",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
|
label: "labels.cut",
|
||||||
|
icon: cutIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
actionCopy.perform(elements, appState, event, app);
|
actionCopy.perform(elements, appState, event, app);
|
||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
name: "copyAsSvg",
|
name: "copyAsSvg",
|
||||||
|
label: "labels.copyAsSvg",
|
||||||
|
icon: svgIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
@@ -138,6 +143,7 @@ export const actionCopyAsSvg = register({
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -157,11 +163,13 @@ export const actionCopyAsSvg = register({
|
|||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsSvg",
|
keywords: ["svg", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsPng = register({
|
export const actionCopyAsPng = register({
|
||||||
name: "copyAsPng",
|
name: "copyAsPng",
|
||||||
|
label: "labels.copyAsPng",
|
||||||
|
icon: pngIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
@@ -184,6 +192,7 @@ export const actionCopyAsPng = register({
|
|||||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -215,12 +224,13 @@ export const actionCopyAsPng = register({
|
|||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardBlob && elements.length > 0;
|
return probablySupportsClipboardBlob && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsPng",
|
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
|
keywords: ["png", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const copyText = register({
|
export const copyText = register({
|
||||||
name: "copyText",
|
name: "copyText",
|
||||||
|
label: "labels.copyText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@@ -236,7 +246,11 @@ export const copyText = register({
|
|||||||
return acc;
|
return acc;
|
||||||
}, [])
|
}, [])
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
copyTextToSystemClipboard(text);
|
try {
|
||||||
|
copyTextToSystemClipboard(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -252,5 +266,5 @@ export const copyText = register({
|
|||||||
.some(isTextElement)
|
.some(isTextElement)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyText",
|
keywords: ["text", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,8 +72,10 @@ const handleGroupEditingState = (
|
|||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
|
label: "labels.delete",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
@@ -81,7 +83,8 @@ export const actionDeleteSelected = register({
|
|||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -167,7 +170,6 @@ export const actionDeleteSelected = register({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
|
||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
|
label: "labels.distributeHorizontally",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -79,6 +80,7 @@ export const distributeHorizontally = register({
|
|||||||
|
|
||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
|
label: "labels.distributeVertically",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ import {
|
|||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
label: "labels.duplicateSelection",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
@@ -56,7 +62,6 @@ export const actionDuplicateSelection = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -10,11 +12,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
|
|
||||||
export const actionToggleElementLock = register({
|
export const actionToggleElementLock = register({
|
||||||
name: "toggleElementLock",
|
name: "toggleElementLock",
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selected = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||||
|
return selected[0].locked
|
||||||
|
? "labels.elementLock.unlock"
|
||||||
|
: "labels.elementLock.lock";
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldLock(selected)
|
||||||
|
? "labels.elementLock.lockAll"
|
||||||
|
: "labels.elementLock.unlockAll";
|
||||||
|
},
|
||||||
|
icon: (appState, elements) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
|
||||||
|
},
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return !selectedElements.some(
|
return (
|
||||||
(element) => element.locked && element.frameId,
|
selectedElements.length > 0 &&
|
||||||
|
!selectedElements.some((element) => element.locked && element.frameId)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@@ -47,21 +69,6 @@ export const actionToggleElementLock = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState, app) => {
|
|
||||||
const selected = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: false,
|
|
||||||
});
|
|
||||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
|
||||||
return selected[0].locked
|
|
||||||
? "labels.elementLock.unlock"
|
|
||||||
: "labels.elementLock.lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldLock(selected)
|
|
||||||
? "labels.elementLock.lockAll"
|
|
||||||
: "labels.elementLock.unlockAll";
|
|
||||||
},
|
|
||||||
keyTest: (event, appState, elements, app) => {
|
keyTest: (event, appState, elements, app) => {
|
||||||
return (
|
return (
|
||||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||||
@@ -77,10 +84,16 @@ export const actionToggleElementLock = register({
|
|||||||
|
|
||||||
export const actionUnlockAllElements = register({
|
export const actionUnlockAllElements = register({
|
||||||
name: "unlockAllElements",
|
name: "unlockAllElements",
|
||||||
|
paletteName: "Unlock all elements",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
predicate: (elements) => {
|
icon: UnlockedIcon,
|
||||||
return elements.some((element) => element.locked);
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length === 0 &&
|
||||||
|
elements.some((element) => element.locked)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const lockedElements = elements.filter((el) => el.locked);
|
const lockedElements = elements.filter((el) => el.locked);
|
||||||
@@ -101,5 +114,5 @@ export const actionUnlockAllElements = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.elementLock.unlockAll",
|
label: "labels.elementLock.unlockAll",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { questionCircle, saveAs } from "../components/icons";
|
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@@ -22,18 +22,16 @@ import "../components/ToolIcon.scss";
|
|||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
|
label: "labels.fileTitle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={app.getName()}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
isNameEditable={
|
|
||||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
|
||||||
}
|
|
||||||
ignoreFocus={data?.ignoreFocus ?? false}
|
ignoreFocus={data?.ignoreFocus ?? false}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -41,6 +39,7 @@ export const actionChangeProjectName = register({
|
|||||||
|
|
||||||
export const actionChangeExportScale = register({
|
export const actionChangeExportScale = register({
|
||||||
name: "changeExportScale",
|
name: "changeExportScale",
|
||||||
|
label: "imageExportDialog.scale",
|
||||||
trackEvent: { category: "export", action: "scale" },
|
trackEvent: { category: "export", action: "scale" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -90,6 +89,7 @@ export const actionChangeExportScale = register({
|
|||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
|
label: "imageExportDialog.label.withBackground",
|
||||||
trackEvent: { category: "export", action: "toggleBackground" },
|
trackEvent: { category: "export", action: "toggleBackground" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -109,6 +109,7 @@ export const actionChangeExportBackground = register({
|
|||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
|
label: "imageExportDialog.tooltip.embedScene",
|
||||||
trackEvent: { category: "export", action: "embedScene" },
|
trackEvent: { category: "export", action: "embedScene" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -131,6 +132,8 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
|
label: "buttons.save",
|
||||||
|
icon: ExportIcon,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -144,8 +147,13 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
? await resaveAsImageWithScene(
|
||||||
: await saveAsJSON(elements, appState, app.files);
|
elements,
|
||||||
|
appState,
|
||||||
|
app.files,
|
||||||
|
app.getName(),
|
||||||
|
)
|
||||||
|
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -179,6 +187,8 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
|
label: "exportDialog.disk_title",
|
||||||
|
icon: ExportIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
@@ -190,6 +200,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
},
|
},
|
||||||
app.files,
|
app.files,
|
||||||
|
app.getName(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@@ -227,6 +238,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
|
label: "buttons.load",
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@@ -264,6 +276,7 @@ export const actionLoadScene = register({
|
|||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
|
label: "imageExportDialog.label.darkMode",
|
||||||
trackEvent: { category: "export", action: "toggleTheme" },
|
trackEvent: { category: "export", action: "toggleTheme" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { updateActiveTool } from "../utils";
|
import { arrayToMap, updateActiveTool } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -19,6 +19,7 @@ import { resetCursor } from "../cursor";
|
|||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
@@ -26,10 +27,12 @@ export const actionFinalize = register({
|
|||||||
_,
|
_,
|
||||||
{ interactiveCanvas, focusContainer, scene },
|
{ interactiveCanvas, focusContainer, scene },
|
||||||
) => {
|
) => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
if (isBindingElement(element)) {
|
if (isBindingElement(element)) {
|
||||||
@@ -37,6 +40,7 @@ export const actionFinalize = register({
|
|||||||
element,
|
element,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -125,12 +129,14 @@ export const actionFinalize = register({
|
|||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
multiPointElement,
|
multiPointElement,
|
||||||
-1,
|
-1,
|
||||||
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(
|
||||||
multiPointElement,
|
multiPointElement,
|
||||||
appState,
|
appState,
|
||||||
Scene.getScene(multiPointElement)!,
|
Scene.getScene(multiPointElement)!,
|
||||||
{ x, y },
|
{ x, y },
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +192,7 @@ export const actionFinalize = register({
|
|||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
? new LinearElementEditor(multiPointElement, scene)
|
? new LinearElementEditor(multiPointElement)
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getNonDeletedElements } from "../element";
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedElementsMap,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
@@ -18,9 +17,12 @@ import {
|
|||||||
unbindLinearElements,
|
unbindLinearElements,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
|
label: "labels.flipHorizontal",
|
||||||
|
icon: flipHorizontal,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -39,11 +41,12 @@ export const actionFlipHorizontal = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||||
contextItemLabel: "labels.flipHorizontal",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionFlipVertical = register({
|
export const actionFlipVertical = register({
|
||||||
name: "flipVertical",
|
name: "flipVertical",
|
||||||
|
label: "labels.flipVertical",
|
||||||
|
icon: flipVertical,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -63,12 +66,11 @@ export const actionFlipVertical = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||||
contextItemLabel: "labels.flipVertical",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flipSelectedElements = (
|
const flipSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
) => {
|
) => {
|
||||||
@@ -83,6 +85,7 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = flipElements(
|
const updatedElements = flipElements(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
@@ -97,7 +100,8 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
elements: readonly ExcalidrawElement[],
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
@@ -113,9 +117,9 @@ const flipElements = (
|
|||||||
flipDirection === "horizontal" ? minY : maxY,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
);
|
);
|
||||||
|
|
||||||
(isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
? bindOrUnbindSelectedElements
|
? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
|
||||||
: unbindLinearElements)(selectedElements);
|
: unbindLinearElements(selectedElements, elementsMap);
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { ExcalidrawElement } from "../element/types";
|
|||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
|
import { frameToolIcon } from "../components/icons";
|
||||||
|
|
||||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
const isSingleFrameSelected = (
|
||||||
|
appState: UIAppState,
|
||||||
|
app: AppClassProperties,
|
||||||
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +23,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
|||||||
|
|
||||||
export const actionSelectAllElementsInFrame = register({
|
export const actionSelectAllElementsInFrame = register({
|
||||||
name: "selectAllElementsInFrame",
|
name: "selectAllElementsInFrame",
|
||||||
|
label: "labels.selectAllElementsInFrame",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@@ -49,13 +54,13 @@ export const actionSelectAllElementsInFrame = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionRemoveAllElementsFromFrame = register({
|
export const actionRemoveAllElementsFromFrame = register({
|
||||||
name: "removeAllElementsFromFrame",
|
name: "removeAllElementsFromFrame",
|
||||||
|
label: "labels.removeAllElementsFromFrame",
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@@ -80,13 +85,13 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionupdateFrameRendering = register({
|
export const actionupdateFrameRendering = register({
|
||||||
name: "updateFrameRendering",
|
name: "updateFrameRendering",
|
||||||
|
label: "labels.updateFrameRendering",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
@@ -102,13 +107,15 @@ export const actionupdateFrameRendering = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.updateFrameRendering",
|
|
||||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSetFrameAsActiveTool = register({
|
export const actionSetFrameAsActiveTool = register({
|
||||||
name: "setFrameAsActiveTool",
|
name: "setFrameAsActiveTool",
|
||||||
|
label: "toolBar.frame",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: frameToolIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
type: "frame",
|
type: "frame",
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const enableActionGroup = (
|
|||||||
|
|
||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
|
label: "labels.group",
|
||||||
|
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@@ -157,7 +159,6 @@ export const actionGroup = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.group",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
enableActionGroup(elements, appState, app),
|
enableActionGroup(elements, appState, app),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -177,9 +178,13 @@ export const actionGroup = register({
|
|||||||
|
|
||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
|
label: "labels.ungroup",
|
||||||
|
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
@@ -226,7 +231,12 @@ export const actionUngroup = register({
|
|||||||
if (frame) {
|
if (frame) {
|
||||||
nextElements = replaceAllElementsInFrame(
|
nextElements = replaceAllElementsInFrame(
|
||||||
nextElements,
|
nextElements,
|
||||||
getElementsInResizingFrame(nextElements, frame, appState),
|
getElementsInResizingFrame(
|
||||||
|
nextElements,
|
||||||
|
frame,
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
frame,
|
frame,
|
||||||
app,
|
app,
|
||||||
);
|
);
|
||||||
@@ -256,7 +266,6 @@ export const actionUngroup = register({
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key === KEYS.G.toUpperCase(),
|
event.key === KEYS.G.toUpperCase(),
|
||||||
contextItemLabel: "labels.ungroup",
|
|
||||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||||
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ type ActionCreator = (history: History) => Action;
|
|||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
|
label: "buttons.undo",
|
||||||
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -84,7 +87,10 @@ export const createUndoAction: ActionCreator = (history) => ({
|
|||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history) => ({
|
export const createRedoAction: ActionCreator = (history) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
|
label: "buttons.redo",
|
||||||
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
@@ -5,6 +6,16 @@ import { register } from "./register";
|
|||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
name: "toggleLinearEditor",
|
name: "toggleLinearEditor",
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selectedElement = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
return appState.editingLinearElement?.elementId === selectedElement?.id
|
||||||
|
? "labels.lineEditor.exit"
|
||||||
|
: "labels.lineEditor.edit";
|
||||||
|
},
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "element",
|
category: "element",
|
||||||
},
|
},
|
||||||
@@ -24,7 +35,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
const editingLinearElement =
|
const editingLinearElement =
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
? null
|
? null
|
||||||
: new LinearElementEditor(selectedElement, app.scene);
|
: new LinearElementEditor(selectedElement);
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -33,13 +44,4 @@ export const actionToggleLinearEditor = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState, app) => {
|
|
||||||
const selectedElement = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
})[0] as ExcalidrawLinearElement;
|
|
||||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
|
||||||
? "labels.lineEditor.exit"
|
|
||||||
: "labels.lineEditor.edit";
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||||
|
import { LinkIcon } from "../components/icons";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { isEmbeddableElement } from "../element/typeChecks";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionLink = register({
|
||||||
|
name: "hyperlink",
|
||||||
|
label: (elements, appState) => getContextMenuLabel(elements, appState),
|
||||||
|
icon: LinkIcon,
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
if (appState.showHyperlinkPopup === "editor") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
showHyperlinkPopup: "editor",
|
||||||
|
openMenu: null,
|
||||||
|
},
|
||||||
|
commitToHistory: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
trackEvent: { category: "hyperlink", action: "click" },
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||||
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return selectedElements.length === 1;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={LinkIcon}
|
||||||
|
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||||
|
title={`${
|
||||||
|
isEmbeddableElement(elements[0])
|
||||||
|
? t("labels.link.labelEmbed")
|
||||||
|
: t("labels.link.label")
|
||||||
|
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
@@ -7,6 +7,7 @@ import { KEYS } from "../keys";
|
|||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
|
label: "buttons.menu",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_, appState) => ({
|
perform: (_, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
@@ -28,6 +29,7 @@ export const actionToggleCanvasMenu = register({
|
|||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
export const actionToggleEditMenu = register({
|
||||||
name: "toggleEditMenu",
|
name: "toggleEditMenu",
|
||||||
|
label: "buttons.edit",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_elements, appState) => ({
|
perform: (_elements, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
@@ -53,6 +55,8 @@ export const actionToggleEditMenu = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
|
icon: HelpIconThin,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||||
import { eyeIcon } from "../components/icons";
|
import {
|
||||||
|
eyeIcon,
|
||||||
|
microphoneIcon,
|
||||||
|
microphoneMutedIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
|
label: "Go to a collaborator",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "collab" },
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||||
@@ -39,14 +45,45 @@ export const actionGoToCollaborator = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data, appState }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||||
data as GoToCollaboratorComponentProps;
|
data as GoToCollaboratorComponentProps;
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
const statusClassNames = clsx({
|
||||||
|
"is-followed": isBeingFollowed,
|
||||||
|
"is-current-user": collaborator.isCurrentUser === true,
|
||||||
|
"is-speaking": collaborator.isSpeaking,
|
||||||
|
"is-in-call": collaborator.isInCall,
|
||||||
|
"is-muted": collaborator.isMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIconJSX = collaborator.isInCall ? (
|
||||||
|
collaborator.isSpeaking ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||||
|
title={t("userList.hint.isSpeaking")}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
) : collaborator.isMuted ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-microphone-muted"
|
||||||
|
title={t("userList.hint.micMuted")}
|
||||||
|
>
|
||||||
|
{microphoneMutedIcon}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return withName ? (
|
return withName ? (
|
||||||
<div
|
<div
|
||||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||||
|
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||||
onClick={() => updateData<Collaborator>(collaborator)}
|
onClick={() => updateData<Collaborator>(collaborator)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -54,32 +91,42 @@ export const actionGoToCollaborator = register({
|
|||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
isBeingFollowed={isBeingFollowed}
|
className={statusClassNames}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
|
||||||
/>
|
/>
|
||||||
<div className="UserList__collaborator-name">
|
<div className="UserList__collaborator-name">
|
||||||
{collaborator.username}
|
{collaborator.username}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||||
className="UserList__collaborator-follow-status-icon"
|
{isBeingFollowed && (
|
||||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
<div
|
||||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
className="UserList__collaborator-status-icon-is-followed"
|
||||||
aria-hidden
|
title={t("userList.hint.followStatus")}
|
||||||
>
|
>
|
||||||
{eyeIcon}
|
{eyeIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusIconJSX}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Avatar
|
<div
|
||||||
color={background}
|
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||||
onClick={() => {
|
>
|
||||||
updateData(collaborator);
|
<Avatar
|
||||||
}}
|
color={background}
|
||||||
name={collaborator.username || ""}
|
onClick={() => {
|
||||||
src={collaborator.avatarUrl}
|
updateData(collaborator);
|
||||||
isBeingFollowed={isBeingFollowed}
|
}}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
name={collaborator.username || ""}
|
||||||
/>
|
src={collaborator.avatarUrl}
|
||||||
|
className={statusClassNames}
|
||||||
|
/>
|
||||||
|
{statusIconJSX && (
|
||||||
|
<div className="UserList__collaborator-status-icon">
|
||||||
|
{statusIconJSX}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
ArrowheadCircleOutlineIcon,
|
ArrowheadCircleOutlineIcon,
|
||||||
ArrowheadDiamondIcon,
|
ArrowheadDiamondIcon,
|
||||||
ArrowheadDiamondOutlineIcon,
|
ArrowheadDiamondOutlineIcon,
|
||||||
|
fontSizeIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
@@ -209,6 +210,7 @@ const changeFontSize = (
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
@@ -237,6 +239,7 @@ const changeFontSize = (
|
|||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
|
label: "labels.stroke",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -287,6 +290,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
|
|
||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
|
label: "labels.changeBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -330,6 +334,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
|
label: "labels.fill",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
trackEvent(
|
trackEvent(
|
||||||
@@ -407,6 +412,7 @@ export const actionChangeFillStyle = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
|
label: "labels.strokeWidth",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -460,6 +466,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
|
|
||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
|
label: "labels.sloppiness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -511,6 +518,7 @@ export const actionChangeSloppiness = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
|
label: "labels.strokeStyle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -561,6 +569,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
|
label: "labels.opacity",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -602,6 +611,7 @@ export const actionChangeOpacity = register({
|
|||||||
|
|
||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
|
label: "labels.fontSize",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
@@ -672,6 +682,8 @@ export const actionChangeFontSize = register({
|
|||||||
|
|
||||||
export const actionDecreaseFontSize = register({
|
export const actionDecreaseFontSize = register({
|
||||||
name: "decreaseFontSize",
|
name: "decreaseFontSize",
|
||||||
|
label: "labels.decreaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
@@ -694,6 +706,8 @@ export const actionDecreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionIncreaseFontSize = register({
|
export const actionIncreaseFontSize = register({
|
||||||
name: "increaseFontSize",
|
name: "increaseFontSize",
|
||||||
|
label: "labels.increaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
@@ -712,6 +726,7 @@ export const actionIncreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
|
label: "labels.fontFamily",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -730,6 +745,7 @@ export const actionChangeFontFamily = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@@ -814,6 +830,7 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
|
label: "Change text alignment",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -829,6 +846,7 @@ export const actionChangeTextAlign = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@@ -902,6 +920,7 @@ export const actionChangeTextAlign = register({
|
|||||||
|
|
||||||
export const actionChangeVerticalAlign = register({
|
export const actionChangeVerticalAlign = register({
|
||||||
name: "changeVerticalAlign",
|
name: "changeVerticalAlign",
|
||||||
|
label: "Change vertical alignment",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@@ -918,6 +937,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@@ -990,6 +1010,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
|
|
||||||
export const actionChangeRoundness = register({
|
export const actionChangeRoundness = register({
|
||||||
name: "changeRoundness",
|
name: "changeRoundness",
|
||||||
|
label: "Change edge roundness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@@ -1128,6 +1149,7 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
|
label: "Change arrowheads",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import { ExcalidrawElement } from "../element/types";
|
|||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
|
label: "labels.selectAll",
|
||||||
|
icon: selectAllIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
@@ -43,12 +47,11 @@ export const actionSelectAll = register({
|
|||||||
// single linear element selected
|
// single linear element selected
|
||||||
Object.keys(selectedElementIds).length === 1 &&
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
isLinearElement(elements[0])
|
isLinearElement(elements[0])
|
||||||
? new LinearElementEditor(elements[0], app.scene)
|
? new LinearElementEditor(elements[0])
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ import {
|
|||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawTextElement } from "../element/types";
|
||||||
|
import { paintIcon } from "../components/icons";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
|
label: "labels.copyStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = [];
|
const elementsCopied = [];
|
||||||
@@ -54,13 +57,14 @@ export const actionCopyStyles = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
|
label: "labels.pasteStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = JSON.parse(copiedStyles);
|
const elementsCopied = JSON.parse(copiedStyles);
|
||||||
@@ -128,7 +132,11 @@ export const actionPasteStyles = register({
|
|||||||
element.id === newElement.containerId,
|
element.id === newElement.containerId,
|
||||||
) || null;
|
) || null;
|
||||||
}
|
}
|
||||||
redrawTextBoundingBox(newElement, container);
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -155,7 +163,6 @@ export const actionPasteStyles = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppState } from "../types";
|
|||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
|
label: "labels.showGrid",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@@ -24,6 +25,5 @@ export const actionToggleGridMode = register({
|
|||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return typeof props.gridModeEnabled === "undefined";
|
return typeof props.gridModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.showGrid",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleObjectsSnapMode = register({
|
export const actionToggleObjectsSnapMode = register({
|
||||||
name: "objectsSnapMode",
|
name: "objectsSnapMode",
|
||||||
viewMode: true,
|
label: "buttons.objectsSnapMode",
|
||||||
|
icon: magnetIcon,
|
||||||
|
viewMode: false,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||||
@@ -22,7 +25,6 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "buttons.objectsSnapMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { abacusIcon } from "../components/icons";
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
|
label: "stats.title",
|
||||||
|
icon: abacusIcon,
|
||||||
|
paletteName: "Toggle stats",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
@@ -15,7 +19,6 @@ export const actionToggleStats = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.showStats,
|
||||||
contextItemLabel: "stats.title",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { eyeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
export const actionToggleViewMode = register({
|
||||||
name: "viewMode",
|
name: "viewMode",
|
||||||
|
label: "labels.viewMode",
|
||||||
|
paletteName: "Toggle view mode",
|
||||||
|
icon: eyeIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@@ -21,7 +25,6 @@ export const actionToggleViewMode = register({
|
|||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.viewModeEnabled === "undefined";
|
return typeof appProps.viewModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.viewMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { coffeeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
export const actionToggleZenMode = register({
|
||||||
name: "zenMode",
|
name: "zenMode",
|
||||||
|
label: "buttons.zenMode",
|
||||||
|
icon: coffeeIcon,
|
||||||
|
paletteName: "Toggle zen mode",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@@ -21,7 +25,6 @@ export const actionToggleZenMode = register({
|
|||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.zenModeEnabled === "undefined";
|
return typeof appProps.zenModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "buttons.zenMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { isDarwin } from "../constants";
|
|||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
|
label: "labels.sendBackward",
|
||||||
|
icon: SendBackwardIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
@@ -27,7 +29,6 @@ export const actionSendBackward = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendBackward",
|
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@@ -47,6 +48,8 @@ export const actionSendBackward = register({
|
|||||||
|
|
||||||
export const actionBringForward = register({
|
export const actionBringForward = register({
|
||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
|
label: "labels.bringForward",
|
||||||
|
icon: BringForwardIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
@@ -55,7 +58,6 @@ export const actionBringForward = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringForward",
|
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@@ -75,6 +77,8 @@ export const actionBringForward = register({
|
|||||||
|
|
||||||
export const actionSendToBack = register({
|
export const actionSendToBack = register({
|
||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
|
label: "labels.sendToBack",
|
||||||
|
icon: SendToBackIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
@@ -83,7 +87,6 @@ export const actionSendToBack = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendToBack",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
@@ -110,6 +113,8 @@ export const actionSendToBack = register({
|
|||||||
|
|
||||||
export const actionBringToFront = register({
|
export const actionBringToFront = register({
|
||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
|
label: "labels.bringToFront",
|
||||||
|
icon: BringToFrontIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
@@ -119,7 +124,6 @@ export const actionBringToFront = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringToFront",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
|||||||
@@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
|||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||||
export { actionLink } from "../element/Hyperlink";
|
export { actionLink } from "./actionLink";
|
||||||
export { actionToggleElementLock } from "./actionElementLock";
|
export { actionToggleElementLock } from "./actionElementLock";
|
||||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
|||||||
@@ -36,9 +36,22 @@ export type ShortcutName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "hyperlink"
|
| "hyperlink"
|
||||||
| "toggleElementLock"
|
| "toggleElementLock"
|
||||||
|
| "resetZoom"
|
||||||
|
| "zoomOut"
|
||||||
|
| "zoomIn"
|
||||||
|
| "zoomToFit"
|
||||||
|
| "zoomToFitSelectionInViewport"
|
||||||
|
| "zoomToFitSelection"
|
||||||
|
| "toggleEraserTool"
|
||||||
|
| "toggleHandTool"
|
||||||
|
| "setFrameAsActiveTool"
|
||||||
|
| "saveFileToDisk"
|
||||||
|
| "saveToActiveFile"
|
||||||
|
| "toggleShortcuts"
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport";
|
| "imageExport"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||||
@@ -46,6 +59,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||||
|
commandPalette: [
|
||||||
|
getShortcutKey("CtrlOrCmd+/"),
|
||||||
|
getShortcutKey("CtrlOrCmd+Shift+P"),
|
||||||
|
],
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||||
@@ -83,10 +100,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||||
|
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
|
||||||
|
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
|
||||||
|
zoomIn: [getShortcutKey("CtrlOrCmd++")],
|
||||||
|
zoomToFitSelection: [getShortcutKey("Shift+3")],
|
||||||
|
zoomToFit: [getShortcutKey("Shift+1")],
|
||||||
|
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
|
||||||
|
toggleEraserTool: [getShortcutKey("E")],
|
||||||
|
toggleHandTool: [getShortcutKey("H")],
|
||||||
|
setFrameAsActiveTool: [getShortcutKey("F")],
|
||||||
|
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
const shortcuts = shortcutMap[name];
|
const shortcuts = shortcutMap[name];
|
||||||
// if multiple shortcuts available, take the first one
|
// if multiple shortcuts available, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0
|
||||||
|
? shortcuts[idx] || shortcuts[0]
|
||||||
|
: "";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import {
|
|||||||
AppState,
|
AppState,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
|
|
||||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
export type ActionSource =
|
||||||
|
| "ui"
|
||||||
|
| "keyboard"
|
||||||
|
| "contextMenu"
|
||||||
|
| "api"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@@ -124,7 +130,8 @@ export type ActionName =
|
|||||||
| "setFrameAsActiveTool"
|
| "setFrameAsActiveTool"
|
||||||
| "setEmbeddableAsActiveTool"
|
| "setEmbeddableAsActiveTool"
|
||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer";
|
| "wrapTextInContainer"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -137,6 +144,20 @@ export type PanelComponentProps = {
|
|||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
|
label:
|
||||||
|
| string
|
||||||
|
| ((
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
app: AppClassProperties,
|
||||||
|
) => string);
|
||||||
|
keywords?: string[];
|
||||||
|
icon?:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((
|
||||||
|
appState: UIAppState,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
) => React.ReactNode);
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
@@ -146,13 +167,6 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?:
|
|
||||||
| string
|
|
||||||
| ((
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
app: AppClassProperties,
|
|
||||||
) => string);
|
|
||||||
predicate?: (
|
predicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
THEME,
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
|
||||||
|
|
||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||||
? devicePixelRatio
|
? devicePixelRatio
|
||||||
@@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
isRotating: false,
|
isRotating: false,
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: null,
|
||||||
contextMenu: null,
|
contextMenu: null,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
openPopup: null,
|
openPopup: null,
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
import {
|
||||||
|
COLOR_CHARCOAL_BLACK,
|
||||||
|
COLOR_VOICE_CALL,
|
||||||
|
COLOR_WHITE,
|
||||||
|
THEME,
|
||||||
|
} from "./constants";
|
||||||
|
import { roundRect } from "./renderer/roundRect";
|
||||||
|
import { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||||
|
import {
|
||||||
|
Collaborator,
|
||||||
|
InteractiveCanvasAppState,
|
||||||
|
SocketId,
|
||||||
|
UserIdleState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
function hashToInteger(id: string) {
|
function hashToInteger(id: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
if (id.length === 0) {
|
if (id.length === 0) {
|
||||||
@@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getClientColor = (
|
export const getClientColor = (
|
||||||
/**
|
socketId: SocketId,
|
||||||
* any uniquely identifying key, such as user id or socket id
|
collaborator: Collaborator | undefined,
|
||||||
*/
|
|
||||||
id: string,
|
|
||||||
) => {
|
) => {
|
||||||
// to get more even distribution in case `id` is not uniformly distributed to
|
// to get more even distribution in case `id` is not uniformly distributed to
|
||||||
// begin with, we hash it
|
// begin with, we hash it
|
||||||
const hash = Math.abs(hashToInteger(id));
|
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
|
||||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||||
// words a hue value of step size 10). There are 37 such values including 0.
|
// words a hue value of step size 10). There are 37 such values including 0.
|
||||||
const hue = (hash % 37) * 10;
|
const hue = (hash % 37) * 10;
|
||||||
@@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
|||||||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const renderRemoteCursors = ({
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
}: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
renderConfig: InteractiveCanvasRenderConfig;
|
||||||
|
appState: InteractiveCanvasAppState;
|
||||||
|
normalizedWidth: number;
|
||||||
|
normalizedHeight: number;
|
||||||
|
}) => {
|
||||||
|
// Paint remote pointers
|
||||||
|
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
|
||||||
|
let { x, y } = pointer;
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(socketId);
|
||||||
|
|
||||||
|
x -= appState.offsetLeft;
|
||||||
|
y -= appState.offsetTop;
|
||||||
|
|
||||||
|
const width = 11;
|
||||||
|
const height = 14;
|
||||||
|
|
||||||
|
const isOutOfBounds =
|
||||||
|
x < 0 ||
|
||||||
|
x > normalizedWidth - width ||
|
||||||
|
y < 0 ||
|
||||||
|
y > normalizedHeight - height;
|
||||||
|
|
||||||
|
x = Math.max(x, 0);
|
||||||
|
x = Math.min(x, normalizedWidth - width);
|
||||||
|
y = Math.max(y, 0);
|
||||||
|
y = Math.min(y, normalizedHeight - height);
|
||||||
|
|
||||||
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.fillStyle = background;
|
||||||
|
|
||||||
|
const userState = renderConfig.remotePointerUserStates.get(socketId);
|
||||||
|
const isInactive =
|
||||||
|
isOutOfBounds ||
|
||||||
|
userState === UserIdleState.IDLE ||
|
||||||
|
userState === UserIdleState.AWAY;
|
||||||
|
|
||||||
|
if (isInactive) {
|
||||||
|
context.globalAlpha = 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderConfig.remotePointerButton.get(socketId) === "down") {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.strokeStyle = "#ffffff88";
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||||
|
const IS_SPEAKING_COLOR =
|
||||||
|
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||||
|
|
||||||
|
const isSpeaking = collaborator?.isSpeaking;
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
// cursor outline for currently speaking user
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.lineWidth = 10;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background (white outline) for arrow
|
||||||
|
context.fillStyle = COLOR_WHITE;
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.lineWidth = 6;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
if (isInactive) {
|
||||||
|
context.moveTo(x - 1, y - 1);
|
||||||
|
context.lineTo(x - 1, y + 15);
|
||||||
|
context.lineTo(x + 5, y + 10);
|
||||||
|
context.lineTo(x + 12, y + 9);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
} else {
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
|
||||||
|
|
||||||
|
if (!isOutOfBounds && username) {
|
||||||
|
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||||
|
|
||||||
|
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
|
||||||
|
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
|
||||||
|
const paddingHorizontal = 5;
|
||||||
|
const paddingVertical = 3;
|
||||||
|
const measure = context.measureText(username);
|
||||||
|
const measureHeight =
|
||||||
|
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||||
|
const finalHeight = Math.max(measureHeight, 12);
|
||||||
|
|
||||||
|
const boxX = offsetX - 1;
|
||||||
|
const boxY = offsetY - 1;
|
||||||
|
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||||
|
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||||
|
if (context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
|
||||||
|
}
|
||||||
|
context.fillStyle = COLOR_CHARCOAL_BLACK;
|
||||||
|
|
||||||
|
context.fillText(
|
||||||
|
username,
|
||||||
|
offsetX + paddingHorizontal + 1,
|
||||||
|
offsetY +
|
||||||
|
paddingVertical +
|
||||||
|
measure.actualBoundingBoxAscent +
|
||||||
|
Math.floor((finalHeight - measureHeight) / 2) +
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// draw three vertical bars signalling someone is speaking
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
const barheight = 8;
|
||||||
|
const margin = 8;
|
||||||
|
const gap = 5;
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap,
|
||||||
|
boxY + (boxHeight / 2 - (barheight * 2) / 2),
|
||||||
|
2,
|
||||||
|
barheight * 2,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap * 2,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
import { deepCopyElement } from "./element/newElement";
|
import { deepCopyElement } from "./element/newElement";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { mutateElement } from "./element/mutateElement";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { isMemberOf, isPromiseLike } from "./utils";
|
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||||
import { t } from "./i18n";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@@ -126,6 +125,7 @@ export const serializeAsClipboardJSON = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
}) => {
|
}) => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
const framesToCopy = new Set(
|
const framesToCopy = new Set(
|
||||||
elements.filter((element) => isFrameLikeElement(element)),
|
elements.filter((element) => isFrameLikeElement(element)),
|
||||||
);
|
);
|
||||||
@@ -152,8 +152,8 @@ export const serializeAsClipboardJSON = ({
|
|||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
getContainingFrame(element) &&
|
getContainingFrame(element, elementsMap) &&
|
||||||
!framesToCopy.has(getContainingFrame(element)!)
|
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
|
||||||
) {
|
) {
|
||||||
const copiedElement = deepCopyElement(element);
|
const copiedElement = deepCopyElement(element);
|
||||||
mutateElement(copiedElement, {
|
mutateElement(copiedElement, {
|
||||||
@@ -434,7 +434,7 @@ export const copyTextToSystemClipboard = async (
|
|||||||
|
|
||||||
// (3) if that fails, use document.execCommand
|
// (3) if that fails, use document.execCommand
|
||||||
if (!copyTextViaExecCommand(text)) {
|
if (!copyTextViaExecCommand(text)) {
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error("Error copying to clipboard.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import {
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@@ -45,6 +46,40 @@ import {
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
|
||||||
|
export const canChangeStrokeColor = (
|
||||||
|
appState: UIAppState,
|
||||||
|
targetElements: ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
let commonSelectedType: ExcalidrawElementType | null =
|
||||||
|
targetElements[0]?.type || null;
|
||||||
|
|
||||||
|
for (const element of targetElements) {
|
||||||
|
if (element.type !== commonSelectedType) {
|
||||||
|
commonSelectedType = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasStrokeColor(appState.activeTool.type) &&
|
||||||
|
appState.activeTool.type !== "image" &&
|
||||||
|
commonSelectedType !== "image" &&
|
||||||
|
commonSelectedType !== "frame" &&
|
||||||
|
commonSelectedType !== "magicframe") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canChangeBackgroundColor = (
|
||||||
|
appState: UIAppState,
|
||||||
|
targetElements: ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
hasBackground(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasBackground(element.type))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -75,35 +110,17 @@ export const SelectedShapeActions = ({
|
|||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
|
||||||
hasBackground(appState.activeTool.type) ||
|
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
|
||||||
|
|
||||||
const showLinkIcon =
|
const showLinkIcon =
|
||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
let commonSelectedType: ExcalidrawElementType | null =
|
|
||||||
targetElements[0]?.type || null;
|
|
||||||
|
|
||||||
for (const element of targetElements) {
|
|
||||||
if (element.type !== commonSelectedType) {
|
|
||||||
commonSelectedType = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<div>
|
<div>
|
||||||
{((hasStrokeColor(appState.activeTool.type) &&
|
{canChangeStrokeColor(appState, targetElements) &&
|
||||||
appState.activeTool.type !== "image" &&
|
|
||||||
commonSelectedType !== "image" &&
|
|
||||||
commonSelectedType !== "frame" &&
|
|
||||||
commonSelectedType !== "magicframe") ||
|
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
|
||||||
renderAction("changeStrokeColor")}
|
renderAction("changeStrokeColor")}
|
||||||
</div>
|
</div>
|
||||||
{showChangeBackgroundIcons && (
|
{canChangeBackgroundColor(appState, targetElements) && (
|
||||||
<div>{renderAction("changeBackgroundColor")}</div>
|
<div>{renderAction("changeBackgroundColor")}</div>
|
||||||
)}
|
)}
|
||||||
{showFillIcons && renderAction("changeFillStyle")}
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
@@ -306,6 +323,25 @@ export const ShapesSwitcher = ({
|
|||||||
title={t("toolBar.extraTools")}
|
title={t("toolBar.extraTools")}
|
||||||
>
|
>
|
||||||
{extraToolsIcon}
|
{extraToolsIcon}
|
||||||
|
{app.props.aiEnabled !== false && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 8,
|
||||||
|
fontFamily: "Cascadia, monospace",
|
||||||
|
position: "absolute",
|
||||||
|
background: "pink",
|
||||||
|
color: "black",
|
||||||
|
bottom: 3,
|
||||||
|
right: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,7 @@ type AvatarProps = {
|
|||||||
color: string;
|
color: string;
|
||||||
name: string;
|
name: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
isBeingFollowed?: boolean;
|
className?: string;
|
||||||
isCurrentUser: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = ({
|
||||||
@@ -18,22 +17,14 @@ export const Avatar = ({
|
|||||||
onClick,
|
onClick,
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
isBeingFollowed,
|
className,
|
||||||
isCurrentUser,
|
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const shortName = getNameInitial(name);
|
const shortName = getNameInitial(name);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const loadImg = !error && src;
|
const loadImg = !error && src;
|
||||||
const style = loadImg ? undefined : { background: color };
|
const style = loadImg ? undefined : { background: color };
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||||
className={clsx("Avatar", {
|
|
||||||
"Avatar--is-followed": isBeingFollowed,
|
|
||||||
"Avatar--is-current-user": isCurrentUser,
|
|
||||||
})}
|
|
||||||
style={style}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{loadImg ? (
|
{loadImg ? (
|
||||||
<img
|
<img
|
||||||
className="Avatar-img"
|
className="Avatar-img"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
@import "../../css/variables.module.scss";
|
||||||
|
|
||||||
|
$verticalBreakpoint: 861px;
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.command-palette-dialog {
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.Modal__content {
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
@media screen and (min-width: $verticalBreakpoint) {
|
||||||
|
max-height: 750px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Island {
|
||||||
|
height: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Dialog__content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.shortcut-wrapper {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-plus {
|
||||||
|
margin: 0px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-key {
|
||||||
|
padding: 0px 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-desc {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--color-gray-50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.commands {
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.command-category {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 0px;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-category-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--color-surface-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-selected {
|
||||||
|
background-color: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-match {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
useApp,
|
||||||
|
useAppProps,
|
||||||
|
useExcalidrawActionManager,
|
||||||
|
useExcalidrawSetAppState,
|
||||||
|
} from "../App";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { Dialog } from "../Dialog";
|
||||||
|
import { TextField } from "../TextField";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { getSelectedElements } from "../../scene";
|
||||||
|
import { Action } from "../../actions/types";
|
||||||
|
import { TranslationKeys, t } from "../../i18n";
|
||||||
|
import {
|
||||||
|
ShortcutName,
|
||||||
|
getShortcutFromShortcutName,
|
||||||
|
} from "../../actions/shortcuts";
|
||||||
|
import { DEFAULT_SIDEBAR, EVENT } from "../../constants";
|
||||||
|
import {
|
||||||
|
LockedIcon,
|
||||||
|
UnlockedIcon,
|
||||||
|
clockIcon,
|
||||||
|
searchIcon,
|
||||||
|
boltIcon,
|
||||||
|
bucketFillIcon,
|
||||||
|
ExportImageIcon,
|
||||||
|
mermaidLogoIcon,
|
||||||
|
brainIconThin,
|
||||||
|
LibraryIcon,
|
||||||
|
} from "../icons";
|
||||||
|
import fuzzy from "fuzzy";
|
||||||
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
|
import { AppProps, AppState, UIAppState } from "../../types";
|
||||||
|
import {
|
||||||
|
capitalizeString,
|
||||||
|
getShortcutKey,
|
||||||
|
isWritableElement,
|
||||||
|
} from "../../utils";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { deburr } from "../../deburr";
|
||||||
|
import { MarkRequired } from "../../utility-types";
|
||||||
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
import { SHAPES } from "../../shapes";
|
||||||
|
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||||
|
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||||
|
import { actionClearCanvas, actionLink } from "../../actions";
|
||||||
|
import { jotaiStore } from "../../jotai";
|
||||||
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
|
import { CommandPaletteItem } from "./types";
|
||||||
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
|
||||||
|
import "./CommandPalette.scss";
|
||||||
|
|
||||||
|
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORIES = {
|
||||||
|
app: "App",
|
||||||
|
export: "Export",
|
||||||
|
tools: "Tools",
|
||||||
|
editor: "Editor",
|
||||||
|
elements: "Elements",
|
||||||
|
links: "Links",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryOrder = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case DEFAULT_CATEGORIES.app:
|
||||||
|
return 1;
|
||||||
|
case DEFAULT_CATEGORIES.export:
|
||||||
|
return 2;
|
||||||
|
case DEFAULT_CATEGORIES.editor:
|
||||||
|
return 3;
|
||||||
|
case DEFAULT_CATEGORIES.tools:
|
||||||
|
return 4;
|
||||||
|
case DEFAULT_CATEGORIES.elements:
|
||||||
|
return 5;
|
||||||
|
case DEFAULT_CATEGORIES.links:
|
||||||
|
return 6;
|
||||||
|
default:
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandShortcutHint = ({
|
||||||
|
shortcut,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
shortcut: string;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const shortcuts = shortcut.replace("++", "+$").split("+");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("shortcut", className)}>
|
||||||
|
{shortcuts.map((item, idx) => {
|
||||||
|
return (
|
||||||
|
<div className="shortcut-wrapper" key={item}>
|
||||||
|
<div className="shortcut-key">{item === "$" ? "+" : item}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="shortcut-desc">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => {
|
||||||
|
return (
|
||||||
|
!event.altKey &&
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
((event.shiftKey && event.key.toLowerCase() === KEYS.P) ||
|
||||||
|
event.key === KEYS.SLASH)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommandPaletteProps = {
|
||||||
|
customCommandPaletteItems?: CommandPaletteItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandPalette = Object.assign(
|
||||||
|
(props: CommandPaletteProps) => {
|
||||||
|
const uiAppState = useUIAppState();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const commandPaletteShortcut = (event: KeyboardEvent) => {
|
||||||
|
if (isCommandPaletteToggleShortcut(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setAppState((appState) => ({
|
||||||
|
openDialog:
|
||||||
|
appState.openDialog?.name === "commandPalette"
|
||||||
|
? null
|
||||||
|
: { name: "commandPalette" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}, [setAppState]);
|
||||||
|
|
||||||
|
if (uiAppState.openDialog?.name !== "commandPalette") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CommandPaletteInner {...props} />;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultItems,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function CommandPaletteInner({
|
||||||
|
customCommandPaletteItems,
|
||||||
|
}: CommandPaletteProps) {
|
||||||
|
const app = useApp();
|
||||||
|
const uiAppState = useUIAppState();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const appProps = useAppProps();
|
||||||
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
|
const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem);
|
||||||
|
const [allCommands, setAllCommands] = useState<
|
||||||
|
MarkRequired<CommandPaletteItem, "haystack" | "order">[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uiAppState || !app.scene || !actionManager) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const getActionLabel = (action: Action) => {
|
||||||
|
let label = "";
|
||||||
|
if (action.label) {
|
||||||
|
if (typeof action.label === "function") {
|
||||||
|
label = t(
|
||||||
|
action.label(
|
||||||
|
app.scene.getNonDeletedElements(),
|
||||||
|
uiAppState as AppState,
|
||||||
|
app,
|
||||||
|
) as unknown as TranslationKeys,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
label = t(action.label as unknown as TranslationKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionIcon = (action: Action) => {
|
||||||
|
if (typeof action.icon === "function") {
|
||||||
|
return action.icon(uiAppState, app.scene.getNonDeletedElements());
|
||||||
|
}
|
||||||
|
return action.icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
let commandsFromActions: CommandPaletteItem[] = [];
|
||||||
|
|
||||||
|
const actionToCommand = (
|
||||||
|
action: Action,
|
||||||
|
category: string,
|
||||||
|
transformer?: (
|
||||||
|
command: CommandPaletteItem,
|
||||||
|
action: Action,
|
||||||
|
) => CommandPaletteItem,
|
||||||
|
): CommandPaletteItem => {
|
||||||
|
const command: CommandPaletteItem = {
|
||||||
|
label: getActionLabel(action),
|
||||||
|
icon: getActionIcon(action),
|
||||||
|
category,
|
||||||
|
shortcut: getShortcutFromShortcutName(action.name as ShortcutName),
|
||||||
|
keywords: action.keywords,
|
||||||
|
predicate: action.predicate,
|
||||||
|
viewMode: action.viewMode,
|
||||||
|
perform: () => {
|
||||||
|
actionManager.executeAction(action, "commandPalette");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return transformer ? transformer(command, action) : command;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (uiAppState && app.scene && actionManager) {
|
||||||
|
const elementsCommands: CommandPaletteItem[] = [
|
||||||
|
actionManager.actions.group,
|
||||||
|
actionManager.actions.ungroup,
|
||||||
|
actionManager.actions.cut,
|
||||||
|
actionManager.actions.copy,
|
||||||
|
actionManager.actions.deleteSelectedElements,
|
||||||
|
actionManager.actions.copyStyles,
|
||||||
|
actionManager.actions.pasteStyles,
|
||||||
|
actionManager.actions.sendBackward,
|
||||||
|
actionManager.actions.sendToBack,
|
||||||
|
actionManager.actions.bringForward,
|
||||||
|
actionManager.actions.bringToFront,
|
||||||
|
actionManager.actions.alignTop,
|
||||||
|
actionManager.actions.alignBottom,
|
||||||
|
actionManager.actions.alignLeft,
|
||||||
|
actionManager.actions.alignRight,
|
||||||
|
actionManager.actions.alignVerticallyCentered,
|
||||||
|
actionManager.actions.alignHorizontallyCentered,
|
||||||
|
actionManager.actions.duplicateSelection,
|
||||||
|
actionManager.actions.flipHorizontal,
|
||||||
|
actionManager.actions.flipVertical,
|
||||||
|
actionManager.actions.zoomToFitSelection,
|
||||||
|
actionManager.actions.zoomToFitSelectionInViewport,
|
||||||
|
actionManager.actions.increaseFontSize,
|
||||||
|
actionManager.actions.decreaseFontSize,
|
||||||
|
actionManager.actions.toggleLinearEditor,
|
||||||
|
actionLink,
|
||||||
|
].map((action: Action) =>
|
||||||
|
actionToCommand(
|
||||||
|
action,
|
||||||
|
DEFAULT_CATEGORIES.elements,
|
||||||
|
(command, action) => ({
|
||||||
|
...command,
|
||||||
|
predicate: action.predicate
|
||||||
|
? action.predicate
|
||||||
|
: (elements, appState, appProps, app) => {
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return selectedElements.length > 0;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const toolCommands: CommandPaletteItem[] = [
|
||||||
|
actionManager.actions.toggleHandTool,
|
||||||
|
actionManager.actions.setFrameAsActiveTool,
|
||||||
|
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
|
||||||
|
|
||||||
|
const editorCommands: CommandPaletteItem[] = [
|
||||||
|
actionManager.actions.undo,
|
||||||
|
actionManager.actions.redo,
|
||||||
|
actionManager.actions.zoomIn,
|
||||||
|
actionManager.actions.zoomOut,
|
||||||
|
actionManager.actions.resetZoom,
|
||||||
|
actionManager.actions.zoomToFit,
|
||||||
|
actionManager.actions.zenMode,
|
||||||
|
actionManager.actions.viewMode,
|
||||||
|
actionManager.actions.objectsSnapMode,
|
||||||
|
actionManager.actions.toggleShortcuts,
|
||||||
|
actionManager.actions.selectAll,
|
||||||
|
actionManager.actions.toggleElementLock,
|
||||||
|
actionManager.actions.unlockAllElements,
|
||||||
|
actionManager.actions.stats,
|
||||||
|
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor));
|
||||||
|
|
||||||
|
const exportCommands: CommandPaletteItem[] = [
|
||||||
|
actionManager.actions.saveToActiveFile,
|
||||||
|
actionManager.actions.saveFileToDisk,
|
||||||
|
actionManager.actions.copyAsPng,
|
||||||
|
actionManager.actions.copyAsSvg,
|
||||||
|
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export));
|
||||||
|
|
||||||
|
commandsFromActions = [
|
||||||
|
...elementsCommands,
|
||||||
|
...editorCommands,
|
||||||
|
{
|
||||||
|
label: getActionLabel(actionClearCanvas),
|
||||||
|
icon: getActionIcon(actionClearCanvas),
|
||||||
|
shortcut: getShortcutFromShortcutName(
|
||||||
|
actionClearCanvas.name as ShortcutName,
|
||||||
|
),
|
||||||
|
category: DEFAULT_CATEGORIES.editor,
|
||||||
|
keywords: ["delete", "destroy"],
|
||||||
|
viewMode: false,
|
||||||
|
perform: () => {
|
||||||
|
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("buttons.exportImage"),
|
||||||
|
category: DEFAULT_CATEGORIES.export,
|
||||||
|
icon: ExportImageIcon,
|
||||||
|
shortcut: getShortcutFromShortcutName("imageExport"),
|
||||||
|
keywords: [
|
||||||
|
"export",
|
||||||
|
"image",
|
||||||
|
"png",
|
||||||
|
"jpeg",
|
||||||
|
"svg",
|
||||||
|
"clipboard",
|
||||||
|
"picture",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
setAppState({ openDialog: { name: "imageExport" } });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...exportCommands,
|
||||||
|
];
|
||||||
|
|
||||||
|
const additionalCommands: CommandPaletteItem[] = [
|
||||||
|
{
|
||||||
|
label: t("toolBar.library"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
icon: LibraryIcon,
|
||||||
|
viewMode: false,
|
||||||
|
perform: () => {
|
||||||
|
if (uiAppState.openSidebar) {
|
||||||
|
setAppState({
|
||||||
|
openSidebar: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAppState({
|
||||||
|
openSidebar: {
|
||||||
|
name: DEFAULT_SIDEBAR.name,
|
||||||
|
tab: DEFAULT_SIDEBAR.defaultTab,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.changeStroke"),
|
||||||
|
keywords: ["color", "outline"],
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
icon: bucketFillIcon,
|
||||||
|
viewMode: false,
|
||||||
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
canChangeStrokeColor(appState, selectedElements)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: () => {
|
||||||
|
setAppState((prevState) => ({
|
||||||
|
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||||
|
openPopup: "elementStroke",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.changeBackground"),
|
||||||
|
keywords: ["color", "fill"],
|
||||||
|
icon: bucketFillIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
viewMode: false,
|
||||||
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
canChangeBackgroundColor(appState, selectedElements)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: () => {
|
||||||
|
setAppState((prevState) => ({
|
||||||
|
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||||
|
openPopup: "elementBackground",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.canvasBackground"),
|
||||||
|
keywords: ["color"],
|
||||||
|
icon: bucketFillIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.editor,
|
||||||
|
viewMode: false,
|
||||||
|
perform: () => {
|
||||||
|
setAppState((prevState) => ({
|
||||||
|
openMenu: prevState.openMenu === "canvas" ? null : "canvas",
|
||||||
|
openPopup: "canvasBackground",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...SHAPES.reduce((acc: CommandPaletteItem[], shape) => {
|
||||||
|
const { value, icon, key, numericKey } = shape;
|
||||||
|
|
||||||
|
if (
|
||||||
|
appProps.UIOptions.tools?.[
|
||||||
|
value as Extract<
|
||||||
|
typeof value,
|
||||||
|
keyof AppProps["UIOptions"]["tools"]
|
||||||
|
>
|
||||||
|
] === false
|
||||||
|
) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const letter =
|
||||||
|
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||||
|
const shortcut = letter || numericKey;
|
||||||
|
|
||||||
|
const command: CommandPaletteItem = {
|
||||||
|
label: t(`toolBar.${value}`),
|
||||||
|
category: DEFAULT_CATEGORIES.tools,
|
||||||
|
shortcut,
|
||||||
|
icon,
|
||||||
|
keywords: ["toolbar"],
|
||||||
|
viewMode: false,
|
||||||
|
perform: ({ event }) => {
|
||||||
|
if (value === "image") {
|
||||||
|
app.setActiveTool({
|
||||||
|
type: value,
|
||||||
|
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.setActiveTool({ type: value });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
acc.push(command);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []),
|
||||||
|
...toolCommands,
|
||||||
|
{
|
||||||
|
label: t("toolBar.lock"),
|
||||||
|
category: DEFAULT_CATEGORIES.tools,
|
||||||
|
icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon,
|
||||||
|
shortcut: KEYS.Q.toLocaleUpperCase(),
|
||||||
|
viewMode: false,
|
||||||
|
perform: () => {
|
||||||
|
app.toggleLock();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${t("labels.textToDiagram")}...`,
|
||||||
|
category: DEFAULT_CATEGORIES.tools,
|
||||||
|
icon: brainIconThin,
|
||||||
|
viewMode: false,
|
||||||
|
predicate: appProps.aiEnabled,
|
||||||
|
perform: () => {
|
||||||
|
setAppState((state) => ({
|
||||||
|
...state,
|
||||||
|
openDialog: {
|
||||||
|
name: "ttd",
|
||||||
|
tab: "text-to-diagram",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${t("toolBar.mermaidToExcalidraw")}...`,
|
||||||
|
category: DEFAULT_CATEGORIES.tools,
|
||||||
|
icon: mermaidLogoIcon,
|
||||||
|
viewMode: false,
|
||||||
|
predicate: appProps.aiEnabled,
|
||||||
|
perform: () => {
|
||||||
|
setAppState((state) => ({
|
||||||
|
...state,
|
||||||
|
openDialog: {
|
||||||
|
name: "ttd",
|
||||||
|
tab: "mermaid",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: `${t("toolBar.magicframe")}...`,
|
||||||
|
// category: DEFAULT_CATEGORIES.tools,
|
||||||
|
// icon: MagicIconThin,
|
||||||
|
// viewMode: false,
|
||||||
|
// predicate: appProps.aiEnabled,
|
||||||
|
// perform: () => {
|
||||||
|
// app.onMagicframeToolSelect();
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allCommands = [
|
||||||
|
...commandsFromActions,
|
||||||
|
...additionalCommands,
|
||||||
|
...(customCommandPaletteItems || []),
|
||||||
|
].map((command) => {
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
icon: command.icon || boltIcon,
|
||||||
|
order: command.order ?? getCategoryOrder(command.category),
|
||||||
|
haystack: `${deburr(command.label)} ${
|
||||||
|
command.keywords?.join(" ") || ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setAllCommands(allCommands);
|
||||||
|
setLastUsed(
|
||||||
|
allCommands.find((command) => command.label === lastUsed?.label) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
app,
|
||||||
|
appProps,
|
||||||
|
uiAppState,
|
||||||
|
actionManager,
|
||||||
|
setAllCommands,
|
||||||
|
lastUsed?.label,
|
||||||
|
setLastUsed,
|
||||||
|
setAppState,
|
||||||
|
customCommandPaletteItems,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [commandSearch, setCommandSearch] = useState("");
|
||||||
|
const [currentCommand, setCurrentCommand] =
|
||||||
|
useState<CommandPaletteItem | null>(null);
|
||||||
|
const [commandsByCategory, setCommandsByCategory] = useState<
|
||||||
|
Record<string, CommandPaletteItem[]>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const closeCommandPalette = (cb?: () => void) => {
|
||||||
|
setAppState(
|
||||||
|
{
|
||||||
|
openDialog: null,
|
||||||
|
},
|
||||||
|
cb,
|
||||||
|
);
|
||||||
|
setCommandSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeCommand = (
|
||||||
|
command: CommandPaletteItem,
|
||||||
|
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent,
|
||||||
|
) => {
|
||||||
|
if (uiAppState.openDialog?.name === "commandPalette") {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
document.body.classList.add("excalidraw-animations-disabled");
|
||||||
|
closeCommandPalette(() => {
|
||||||
|
command.perform({ actionManager, event });
|
||||||
|
setLastUsed(command);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.classList.remove("excalidraw-animations-disabled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCommandAvailable = useStableCallback(
|
||||||
|
(command: CommandPaletteItem) => {
|
||||||
|
if (command.viewMode === false && uiAppState.viewModeEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof command.predicate === "function"
|
||||||
|
? command.predicate(
|
||||||
|
app.scene.getNonDeletedElements(),
|
||||||
|
uiAppState as AppState,
|
||||||
|
appProps,
|
||||||
|
app,
|
||||||
|
)
|
||||||
|
: command.predicate === undefined || command.predicate;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
|
||||||
|
const ignoreAlphanumerics =
|
||||||
|
isWritableElement(event.target) ||
|
||||||
|
isCommandPaletteToggleShortcut(event) ||
|
||||||
|
event.key === KEYS.ESCAPE;
|
||||||
|
|
||||||
|
if (
|
||||||
|
ignoreAlphanumerics &&
|
||||||
|
event.key !== KEYS.ARROW_UP &&
|
||||||
|
event.key !== KEYS.ARROW_DOWN &&
|
||||||
|
event.key !== KEYS.ENTER
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingCommands = Object.values(commandsByCategory).flat();
|
||||||
|
const shouldConsiderLastUsed =
|
||||||
|
lastUsed && !commandSearch && isCommandAvailable(lastUsed);
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_UP) {
|
||||||
|
event.preventDefault();
|
||||||
|
const index = matchingCommands.findIndex(
|
||||||
|
(item) => item.label === currentCommand?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldConsiderLastUsed) {
|
||||||
|
if (index === 0) {
|
||||||
|
setCurrentCommand(lastUsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCommand === lastUsed) {
|
||||||
|
const nextItem = matchingCommands[matchingCommands.length - 1];
|
||||||
|
if (nextItem) {
|
||||||
|
setCurrentCommand(nextItem);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex;
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
nextIndex = matchingCommands.length - 1;
|
||||||
|
} else {
|
||||||
|
nextIndex =
|
||||||
|
index === 0
|
||||||
|
? matchingCommands.length - 1
|
||||||
|
: (index - 1) % matchingCommands.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItem = matchingCommands[nextIndex];
|
||||||
|
if (nextItem) {
|
||||||
|
setCurrentCommand(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ARROW_DOWN) {
|
||||||
|
event.preventDefault();
|
||||||
|
const index = matchingCommands.findIndex(
|
||||||
|
(item) => item.label === currentCommand?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldConsiderLastUsed) {
|
||||||
|
if (!currentCommand || index === matchingCommands.length - 1) {
|
||||||
|
setCurrentCommand(lastUsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCommand === lastUsed) {
|
||||||
|
const nextItem = matchingCommands[0];
|
||||||
|
if (nextItem) {
|
||||||
|
setCurrentCommand(nextItem);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = (index + 1) % matchingCommands.length;
|
||||||
|
const nextItem = matchingCommands[nextIndex];
|
||||||
|
if (nextItem) {
|
||||||
|
setCurrentCommand(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === KEYS.ENTER) {
|
||||||
|
if (currentCommand) {
|
||||||
|
setTimeout(() => {
|
||||||
|
executeCommand(currentCommand, event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoreAlphanumerics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevent regular editor shortcuts
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// if alphanumeric keypress and we're not inside the input, focus it
|
||||||
|
if (/^[a-zA-Z0-9]$/.test(event.key)) {
|
||||||
|
inputRef?.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(EVENT.KEYDOWN, handleKeyDown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allCommands) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => {
|
||||||
|
const nextCommandsByCategory: Record<string, CommandPaletteItem[]> = {};
|
||||||
|
for (const command of commands) {
|
||||||
|
if (nextCommandsByCategory[command.category]) {
|
||||||
|
nextCommandsByCategory[command.category].push(command);
|
||||||
|
} else {
|
||||||
|
nextCommandsByCategory[command.category] = [command];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCommandsByCategory;
|
||||||
|
};
|
||||||
|
|
||||||
|
let matchingCommands = allCommands
|
||||||
|
.filter(isCommandAvailable)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
const showLastUsed =
|
||||||
|
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
||||||
|
|
||||||
|
if (!commandSearch) {
|
||||||
|
setCommandsByCategory(
|
||||||
|
getNextCommandsByCategory(
|
||||||
|
showLastUsed
|
||||||
|
? matchingCommands.filter(
|
||||||
|
(command) => command.label !== lastUsed?.label,
|
||||||
|
)
|
||||||
|
: matchingCommands,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
||||||
|
matchingCommands = fuzzy
|
||||||
|
.filter(_query, matchingCommands, {
|
||||||
|
extract: (command) => command.haystack,
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map((item) => item.original);
|
||||||
|
|
||||||
|
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
||||||
|
setCurrentCommand(matchingCommands[0] ?? null);
|
||||||
|
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={() => closeCommandPalette()}
|
||||||
|
closeOnClickOutside
|
||||||
|
title={false}
|
||||||
|
size={720}
|
||||||
|
autofocus
|
||||||
|
className="command-palette-dialog"
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
value={commandSearch}
|
||||||
|
placeholder={t("commandPalette.search.placeholder")}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCommandSearch(value);
|
||||||
|
}}
|
||||||
|
selectOnRender
|
||||||
|
ref={inputRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!app.device.viewport.isMobile && (
|
||||||
|
<div className="shortcuts-wrapper">
|
||||||
|
<CommandShortcutHint shortcut="↑↓">
|
||||||
|
{t("commandPalette.shortcuts.select")}
|
||||||
|
</CommandShortcutHint>
|
||||||
|
<CommandShortcutHint shortcut="↵">
|
||||||
|
{t("commandPalette.shortcuts.confirm")}
|
||||||
|
</CommandShortcutHint>
|
||||||
|
<CommandShortcutHint shortcut={getShortcutKey("Esc")}>
|
||||||
|
{t("commandPalette.shortcuts.close")}
|
||||||
|
</CommandShortcutHint>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="commands">
|
||||||
|
{lastUsed && !commandSearch && (
|
||||||
|
<div className="command-category">
|
||||||
|
<div className="command-category-title">
|
||||||
|
{t("commandPalette.recents")}
|
||||||
|
<div
|
||||||
|
className="icon"
|
||||||
|
style={{
|
||||||
|
marginLeft: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clockIcon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CommandItem
|
||||||
|
command={lastUsed}
|
||||||
|
isSelected={lastUsed.label === currentCommand?.label}
|
||||||
|
onClick={(event) => executeCommand(lastUsed, event)}
|
||||||
|
disabled={!isCommandAvailable(lastUsed)}
|
||||||
|
onMouseMove={() => setCurrentCommand(lastUsed)}
|
||||||
|
showShortcut={!app.device.viewport.isMobile}
|
||||||
|
appState={uiAppState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(commandsByCategory).length > 0 ? (
|
||||||
|
Object.keys(commandsByCategory).map((category, idx) => {
|
||||||
|
return (
|
||||||
|
<div className="command-category" key={category}>
|
||||||
|
<div className="command-category-title">{category}</div>
|
||||||
|
{commandsByCategory[category].map((command) => (
|
||||||
|
<CommandItem
|
||||||
|
key={command.label}
|
||||||
|
command={command}
|
||||||
|
isSelected={command.label === currentCommand?.label}
|
||||||
|
onClick={(event) => executeCommand(command, event)}
|
||||||
|
onMouseMove={() => setCurrentCommand(command)}
|
||||||
|
showShortcut={!app.device.viewport.isMobile}
|
||||||
|
appState={uiAppState}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : allCommands ? (
|
||||||
|
<div className="no-match">
|
||||||
|
<div className="icon">{searchIcon}</div>{" "}
|
||||||
|
{t("commandPalette.search.noMatch")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandItem = ({
|
||||||
|
command,
|
||||||
|
isSelected,
|
||||||
|
disabled,
|
||||||
|
onMouseMove,
|
||||||
|
onClick,
|
||||||
|
showShortcut,
|
||||||
|
appState,
|
||||||
|
}: {
|
||||||
|
command: CommandPaletteItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onMouseMove: () => void;
|
||||||
|
onClick: (event: React.MouseEvent) => void;
|
||||||
|
showShortcut: boolean;
|
||||||
|
appState: UIAppState;
|
||||||
|
}) => {
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx("command-item", {
|
||||||
|
"item-selected": isSelected,
|
||||||
|
"item-disabled": disabled,
|
||||||
|
})}
|
||||||
|
ref={(ref) => {
|
||||||
|
if (isSelected && !disabled) {
|
||||||
|
ref?.scrollIntoView?.({
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={disabled ? noop : onClick}
|
||||||
|
onMouseMove={disabled ? noop : onMouseMove}
|
||||||
|
title={disabled ? t("commandPalette.itemNotAvailable") : ""}
|
||||||
|
>
|
||||||
|
<div className="name">
|
||||||
|
{command.icon && (
|
||||||
|
<InlineIcon
|
||||||
|
icon={
|
||||||
|
typeof command.icon === "function"
|
||||||
|
? command.icon(appState)
|
||||||
|
: command.icon
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{command.label}
|
||||||
|
</div>
|
||||||
|
{showShortcut && command.shortcut && (
|
||||||
|
<CommandShortcutHint shortcut={command.shortcut} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { actionToggleTheme } from "../../actions";
|
||||||
|
import { CommandPaletteItem } from "./types";
|
||||||
|
|
||||||
|
export const toggleTheme: CommandPaletteItem = {
|
||||||
|
...actionToggleTheme,
|
||||||
|
category: "App",
|
||||||
|
label: "Toggle theme",
|
||||||
|
perform: ({ actionManager }) => {
|
||||||
|
actionManager.executeAction(actionToggleTheme, "commandPalette");
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ActionManager } from "../../actions/manager";
|
||||||
|
import { Action } from "../../actions/types";
|
||||||
|
import { UIAppState } from "../../types";
|
||||||
|
|
||||||
|
export type CommandPaletteItem = {
|
||||||
|
label: string;
|
||||||
|
/** additional keywords to match against
|
||||||
|
* (appended to haystack, not displayed) */
|
||||||
|
keywords?: string[];
|
||||||
|
/**
|
||||||
|
* string we should match against when searching
|
||||||
|
* (deburred name + keywords)
|
||||||
|
*/
|
||||||
|
haystack?: string;
|
||||||
|
icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
|
||||||
|
category: string;
|
||||||
|
order?: number;
|
||||||
|
predicate?: boolean | Action["predicate"];
|
||||||
|
shortcut?: string;
|
||||||
|
/** if false, command will not show while in view mode */
|
||||||
|
viewMode?: boolean;
|
||||||
|
perform: (data: {
|
||||||
|
actionManager: ActionManager;
|
||||||
|
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
@@ -78,17 +78,17 @@ export const ContextMenu = React.memo(
|
|||||||
|
|
||||||
const actionName = item.name;
|
const actionName = item.name;
|
||||||
let label = "";
|
let label = "";
|
||||||
if (item.contextItemLabel) {
|
if (item.label) {
|
||||||
if (typeof item.contextItemLabel === "function") {
|
if (typeof item.label === "function") {
|
||||||
label = t(
|
label = t(
|
||||||
item.contextItemLabel(
|
item.label(
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
actionManager.app,
|
actionManager.app,
|
||||||
) as unknown as TranslationKeys,
|
) as unknown as TranslationKeys,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
label = t(item.label as unknown as TranslationKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@
|
|||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& + .Dialog__content {
|
||||||
|
--offset: 28px;
|
||||||
|
height: calc(100% - var(--offset)) !important;
|
||||||
|
margin-top: var(--offset) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dialog--fullscreen {
|
.Dialog--fullscreen {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
|
||||||
import {
|
import {
|
||||||
useExcalidrawContainer,
|
useExcalidrawContainer,
|
||||||
useDevice,
|
useDevice,
|
||||||
@@ -9,13 +8,14 @@ import {
|
|||||||
} from "./App";
|
} from "./App";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
import { back, CloseIcon } from "./icons";
|
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { queryFocusableElements } from "../utils";
|
import { queryFocusableElements } from "../utils";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||||
import { jotaiScope } from "../jotai";
|
import { jotaiScope } from "../jotai";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { CloseIcon } from "./icons";
|
||||||
|
|
||||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||||
|
|
||||||
@@ -58,10 +58,12 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
|
|
||||||
const focusableElements = queryFocusableElements(islandNode);
|
const focusableElements = queryFocusableElements(islandNode);
|
||||||
|
|
||||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
setTimeout(() => {
|
||||||
// If there's an element other than close, focus it.
|
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||||
(focusableElements[1] || focusableElements[0]).focus();
|
// If there's an element other than close, focus it.
|
||||||
}
|
(focusableElements[1] || focusableElements[0]).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === KEYS.TAB) {
|
if (event.key === KEYS.TAB) {
|
||||||
@@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => {
|
|||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<button
|
{isFullscreen && (
|
||||||
className="Dialog__close"
|
<button
|
||||||
onClick={onClose}
|
className="Dialog__close"
|
||||||
title={t("buttons.close")}
|
onClick={onClose}
|
||||||
aria-label={t("buttons.close")}
|
title={t("buttons.close")}
|
||||||
>
|
aria-label={t("buttons.close")}
|
||||||
{isFullscreen ? back : CloseIcon}
|
>
|
||||||
</button>
|
{CloseIcon}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="Dialog__content">{props.children}</div>
|
<div className="Dialog__content">{props.children}</div>
|
||||||
</Island>
|
</Island>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
background-color: var(--back-color);
|
background-color: var(--back-color);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transition: all 150ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.Spinner {
|
.Spinner {
|
||||||
--spinner-color: var(--color-surface-lowest);
|
--spinner-color: var(--color-surface-lowest);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -203,8 +207,6 @@
|
|||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
transition: all 150ms ease-out;
|
|
||||||
|
|
||||||
&--size-large {
|
&--size-large {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import "./HelpDialog.scss";
|
|||||||
import { ExternalLinkIcon } from "./icons";
|
import { ExternalLinkIcon } from "./icons";
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||||
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||||
|
|
||||||
const Header = () => (
|
const Header = () => (
|
||||||
<div className="HelpDialog__header">
|
<div className="HelpDialog__header">
|
||||||
@@ -278,6 +279,17 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("stats.title")}
|
label={t("stats.title")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("commandPalette.title")}
|
||||||
|
shortcuts={
|
||||||
|
isFirefox
|
||||||
|
? [getShortcutFromShortcutName("commandPalette")]
|
||||||
|
: [
|
||||||
|
getShortcutFromShortcutName("commandPalette"),
|
||||||
|
getShortcutFromShortcutName("commandPalette", 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
/>
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
<ShortcutIsland
|
<ShortcutIsland
|
||||||
className="HelpDialog__island--editor"
|
className="HelpDialog__island--editor"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { Switch } from "./Switch";
|
|||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
|
||||||
import "./ImageExportDialog.scss";
|
import "./ImageExportDialog.scss";
|
||||||
import { useAppProps } from "./App";
|
|
||||||
import { FilledButton } from "./FilledButton";
|
import { FilledButton } from "./FilledButton";
|
||||||
import { cloneJSON } from "../utils";
|
import { cloneJSON } from "../utils";
|
||||||
import { prepareElementsForExport } from "../data";
|
import { prepareElementsForExport } from "../data";
|
||||||
@@ -58,6 +57,7 @@ type ImageExportModalProps = {
|
|||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
onExportImage: AppClassProperties["onExportImage"];
|
onExportImage: AppClassProperties["onExportImage"];
|
||||||
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImageExportModal = ({
|
const ImageExportModal = ({
|
||||||
@@ -66,14 +66,14 @@ const ImageExportModal = ({
|
|||||||
files,
|
files,
|
||||||
actionManager,
|
actionManager,
|
||||||
onExportImage,
|
onExportImage,
|
||||||
|
name,
|
||||||
}: ImageExportModalProps) => {
|
}: ImageExportModalProps) => {
|
||||||
const hasSelection = isSomeElementSelected(
|
const hasSelection = isSomeElementSelected(
|
||||||
elementsSnapshot,
|
elementsSnapshot,
|
||||||
appStateSnapshot,
|
appStateSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
const appProps = useAppProps();
|
const [projectName, setProjectName] = useState(name);
|
||||||
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
|
||||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||||
const [exportWithBackground, setExportWithBackground] = useState(
|
const [exportWithBackground, setExportWithBackground] = useState(
|
||||||
appStateSnapshot.exportBackground,
|
appStateSnapshot.exportBackground,
|
||||||
@@ -124,9 +124,16 @@ const ImageExportModal = ({
|
|||||||
setRenderError(null);
|
setRenderError(null);
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
return canvasToBlob(canvas).then(() => {
|
return canvasToBlob(canvas)
|
||||||
previewNode.replaceChildren(canvas);
|
.then(() => {
|
||||||
});
|
previewNode.replaceChildren(canvas);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
|
throw new Error(t("canvasError.canvasTooBig"));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -158,10 +165,6 @@ const ImageExportModal = ({
|
|||||||
className="TextInput"
|
className="TextInput"
|
||||||
value={projectName}
|
value={projectName}
|
||||||
style={{ width: "30ch" }}
|
style={{ width: "30ch" }}
|
||||||
disabled={
|
|
||||||
typeof appProps.name !== "undefined" ||
|
|
||||||
appStateSnapshot.viewModeEnabled
|
|
||||||
}
|
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setProjectName(event.target.value);
|
setProjectName(event.target.value);
|
||||||
actionManager.executeAction(
|
actionManager.executeAction(
|
||||||
@@ -347,6 +350,7 @@ export const ImageExportDialog = ({
|
|||||||
actionManager,
|
actionManager,
|
||||||
onExportImage,
|
onExportImage,
|
||||||
onCloseRequest,
|
onCloseRequest,
|
||||||
|
name,
|
||||||
}: {
|
}: {
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
@@ -354,6 +358,7 @@ export const ImageExportDialog = ({
|
|||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
onExportImage: AppClassProperties["onExportImage"];
|
onExportImage: AppClassProperties["onExportImage"];
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
|
name: string;
|
||||||
}) => {
|
}) => {
|
||||||
// we need to take a snapshot so that the exported state can't be modified
|
// we need to take a snapshot so that the exported state can't be modified
|
||||||
// while the dialog is open
|
// while the dialog is open
|
||||||
@@ -372,6 +377,7 @@ export const ImageExportDialog = ({
|
|||||||
files={files}
|
files={files}
|
||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportImage={onExportImage}
|
onExportImage={onExportImage}
|
||||||
|
name={name}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
|
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -19,7 +19,14 @@
|
|||||||
|
|
||||||
&__top-right {
|
&__top-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
pointer-events: none !important;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ const LayerUI = ({
|
|||||||
actionManager={actionManager}
|
actionManager={actionManager}
|
||||||
onExportImage={onExportImage}
|
onExportImage={onExportImage}
|
||||||
onCloseRequest={() => setAppState({ openDialog: null })}
|
onCloseRequest={() => setAppState({ openDialog: null })}
|
||||||
|
name={app.getName()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,20 @@
|
|||||||
|
|
||||||
.Island {
|
.Island {
|
||||||
padding: 2.5rem;
|
padding: 2.5rem;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.animations-disabled {
|
||||||
|
.Modal__background {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Modal__content {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +49,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: rgba(#121212, 0.2);
|
background-color: rgba(#121212, 0.2);
|
||||||
|
|
||||||
animation: Modal__background__fade-in 0.125s linear forwards;
|
animation: Modal__background__fade-in 0.1s linear forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Modal__content {
|
.Modal__content {
|
||||||
@@ -47,7 +61,8 @@
|
|||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
animation: Modal__content_fade-in 0.025s ease-out 0s forwards;
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
@@ -56,7 +71,7 @@
|
|||||||
|
|
||||||
border: 1px solid var(--dialog-border-color);
|
border: 1px solid var(--dialog-border-color);
|
||||||
box-shadow: var(--modal-shadow);
|
box-shadow: var(--modal-shadow);
|
||||||
border-radius: 6px;
|
border-radius: 0.75rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
export const Modal: React.FC<{
|
export const Modal: React.FC<{
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -20,6 +21,10 @@ export const Modal: React.FC<{
|
|||||||
className: "excalidraw-modal-container",
|
className: "excalidraw-modal-container",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animationsDisabledRef = useRef(
|
||||||
|
document.body.classList.contains("excalidraw-animations-disabled"),
|
||||||
|
);
|
||||||
|
|
||||||
if (!modalRoot) {
|
if (!modalRoot) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -34,7 +39,9 @@ export const Modal: React.FC<{
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={clsx("Modal", props.className)}
|
className={clsx("Modal", props.className, {
|
||||||
|
"animations-disabled": animationsDisabledRef.current,
|
||||||
|
})}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onKeyDown={handleKeydown}
|
onKeyDown={handleKeydown}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type Props = {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
label: string;
|
label: string;
|
||||||
isNameEditable: boolean;
|
|
||||||
ignoreFocus?: boolean;
|
ignoreFocus?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="ProjectName">
|
<div className="ProjectName">
|
||||||
<label className="ProjectName-label" htmlFor="filename">
|
<label className="ProjectName-label" htmlFor="filename">
|
||||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
{`${props.label}:`}
|
||||||
</label>
|
</label>
|
||||||
{props.isNameEditable ? (
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
className="TextInput"
|
||||||
className="TextInput"
|
onBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyDown={handleKeyDown}
|
id={`${id}-filename`}
|
||||||
id={`${id}-filename`}
|
value={fileName}
|
||||||
value={fileName}
|
onChange={(event) => setFileName(event.target.value)}
|
||||||
onChange={(event) => setFileName(event.target.value)}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
|
||||||
{props.value}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(link);
|
await copyTextToSystemClipboard(link);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should toggle sidebar using props.toggleMenu()", async () => {
|
it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => {
|
||||||
const { container } = await render(
|
const { container } = await render(
|
||||||
<Excalidraw>
|
<Excalidraw>
|
||||||
<Sidebar name="customSidebar">
|
<Sidebar name="customSidebar">
|
||||||
@@ -158,6 +158,20 @@ describe("Sidebar", () => {
|
|||||||
const sidebars = container.querySelectorAll(".sidebar");
|
const sidebars = container.querySelectorAll(".sidebar");
|
||||||
expect(sidebars.length).toBe(1);
|
expect(sidebars.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// closing sidebar using `{ name: null }`
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).not.toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.h.app.toggleSidebar({ name: null })).toBe(false);
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = container.querySelector("#test-sidebar-content");
|
||||||
|
expect(node).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,4 +343,70 @@ describe("Sidebar", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Sidebar.tab", () => {
|
||||||
|
it("should toggle sidebars tabs correctly", async () => {
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw>
|
||||||
|
<Sidebar name="custom" docked>
|
||||||
|
<Sidebar.Tabs>
|
||||||
|
<Sidebar.Tab tab="library">Library</Sidebar.Tab>
|
||||||
|
<Sidebar.Tab tab="comments">Comments</Sidebar.Tab>
|
||||||
|
</Sidebar.Tabs>
|
||||||
|
</Sidebar>
|
||||||
|
</Excalidraw>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await withExcalidrawDimensions(
|
||||||
|
{ width: 1920, height: 1080 },
|
||||||
|
async () => {
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(
|
||||||
|
"[role=tabpanel][data-testid=library]",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// open library sidebar
|
||||||
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "custom", tab: "library" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(
|
||||||
|
"[role=tabpanel][data-testid=library]",
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
// switch to comments tab
|
||||||
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(
|
||||||
|
"[role=tabpanel][data-testid=comments]",
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
// toggle sidebar closed
|
||||||
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(
|
||||||
|
"[role=tabpanel][data-testid=comments]",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// toggle sidebar open
|
||||||
|
expect(
|
||||||
|
window.h.app.toggleSidebar({ name: "custom", tab: "comments" }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>(
|
||||||
|
"[role=tabpanel][data-testid=comments]",
|
||||||
|
),
|
||||||
|
).not.toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const SidebarTab = ({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||||
return (
|
return (
|
||||||
<RadixTabs.Content {...rest} value={tab}>
|
<RadixTabs.Content {...rest} value={tab} data-testid={tab}>
|
||||||
{children}
|
{children}
|
||||||
</RadixTabs.Content>
|
</RadixTabs.Content>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { NonDeletedExcalidrawElement } from "../../element/types";
|
|||||||
import { AppClassProperties, BinaryFiles } from "../../types";
|
import { AppClassProperties, BinaryFiles } from "../../types";
|
||||||
import { canvasToBlob } from "../../data/blob";
|
import { canvasToBlob } from "../../data/blob";
|
||||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
|
||||||
const resetPreview = ({
|
const resetPreview = ({
|
||||||
canvasRef,
|
canvasRef,
|
||||||
@@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({
|
|||||||
});
|
});
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
await canvasToBlob(canvas);
|
try {
|
||||||
|
await canvasToBlob(canvas);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
|
throw new Error(t("canvasError.canvasTooBig"));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
parent.style.background = "var(--default-bg-color)";
|
parent.style.background = "var(--default-bg-color)";
|
||||||
canvasNode.replaceChildren(canvas);
|
canvasNode.replaceChildren(canvas);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { CSSProperties, useCallback, useEffect, useRef } from "react";
|
||||||
import { CloseIcon } from "./icons";
|
import { CloseIcon } from "./icons";
|
||||||
import "./Toast.scss";
|
import "./Toast.scss";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
@@ -11,11 +11,13 @@ export const Toast = ({
|
|||||||
closable = false,
|
closable = false,
|
||||||
// To prevent autoclose, pass duration as Infinity
|
// To prevent autoclose, pass duration as Infinity
|
||||||
duration = DEFAULT_TOAST_TIMEOUT,
|
duration = DEFAULT_TOAST_TIMEOUT,
|
||||||
|
style,
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
style?: CSSProperties;
|
||||||
}) => {
|
}) => {
|
||||||
const timerRef = useRef<number>(0);
|
const timerRef = useRef<number>(0);
|
||||||
const shouldAutoClose = duration !== Infinity;
|
const shouldAutoClose = duration !== Infinity;
|
||||||
@@ -43,6 +45,7 @@ export const Toast = ({
|
|||||||
className="Toast"
|
className="Toast"
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseLeave={onMouseLeave}
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<p className="Toast__message">{message}</p>
|
<p className="Toast__message">{message}</p>
|
||||||
{closable && (
|
{closable && (
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
--avatar-size: 1.75rem;
|
||||||
|
--avatarList-gap: 0.625rem;
|
||||||
|
--userList-padding: var(--space-factor);
|
||||||
|
|
||||||
|
.UserList-wrapper {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.UserList {
|
.UserList {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/*github corner*/
|
padding: var(--userList-padding);
|
||||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
|
||||||
var(--space-factor);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: var(--avatarList-gap);
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -18,15 +27,16 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
// can fit max 4 avatars (3 avatars + show more) in a column
|
--max-size: calc(
|
||||||
max-height: 120px;
|
var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) *
|
||||||
|
(var(--max-avatars, 2) - 1) + var(--userList-padding) * 2
|
||||||
|
);
|
||||||
|
|
||||||
// can fit max 4 avatars (3 avatars + show more) when there's enough space
|
// max width & height set to fix the max-avatars
|
||||||
max-width: 120px;
|
max-height: var(--max-size);
|
||||||
|
max-width: var(--max-size);
|
||||||
|
|
||||||
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList > * {
|
.UserList > * {
|
||||||
@@ -45,10 +55,11 @@
|
|||||||
@include avatarStyles;
|
@include avatarStyles;
|
||||||
background-color: var(--color-gray-20);
|
background-color: var(--color-gray-20);
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
font-size: 0.5rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-gray-100);
|
color: var(--color-gray-100);
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList__collaborator-name {
|
.UserList__collaborator-name {
|
||||||
@@ -57,13 +68,82 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList__collaborator-follow-status-icon {
|
.UserList__collaborator--avatar-only {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
.UserList__collaborator-status-icon {
|
||||||
|
--size: 14px;
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
bottom: -0.25rem;
|
||||||
|
right: -0.25rem;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator-status-icons {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 1rem;
|
min-width: 2.25rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator.is-muted
|
||||||
|
.UserList__collaborator-status-icon-microphone-muted {
|
||||||
|
color: var(--color-danger);
|
||||||
|
filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator-status-icon-speaking-indicator {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 1rem;
|
||||||
|
padding: 0 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
div {
|
||||||
|
width: 0.125rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
// keep this in sync with constants.ts
|
||||||
|
background-color: #a2f1a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(1) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(2) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(3) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes speaking-indicator-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scaleY(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
--userlist-hint-bg-color: var(--color-gray-10);
|
--userlist-hint-bg-color: var(--color-gray-10);
|
||||||
--userlist-hint-heading-color: var(--color-gray-80);
|
--userlist-hint-heading-color: var(--color-gray-80);
|
||||||
--userlist-hint-text-color: var(--color-gray-60);
|
--userlist-hint-text-color: var(--color-gray-60);
|
||||||
@@ -80,7 +160,7 @@
|
|||||||
position: static;
|
position: static;
|
||||||
top: auto;
|
top: auto;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
max-height: 12rem;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./UserList.scss";
|
import "./UserList.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useLayoutEffect } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Collaborator, SocketId } from "../types";
|
import { Collaborator, SocketId } from "../types";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
@@ -12,9 +12,11 @@ import { Island } from "./Island";
|
|||||||
import { searchIcon } from "./icons";
|
import { searchIcon } from "./icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
|
import { supportsResizeObserver } from "../constants";
|
||||||
|
import { MarkRequired } from "../utility-types";
|
||||||
|
|
||||||
export type GoToCollaboratorComponentProps = {
|
export type GoToCollaboratorComponentProps = {
|
||||||
clientId: ClientId;
|
socketId: SocketId;
|
||||||
collaborator: Collaborator;
|
collaborator: Collaborator;
|
||||||
withName: boolean;
|
withName: boolean;
|
||||||
isBeingFollowed: boolean;
|
isBeingFollowed: boolean;
|
||||||
@@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
|
|||||||
/** collaborator user id or socket id (fallback) */
|
/** collaborator user id or socket id (fallback) */
|
||||||
type ClientId = string & { _brand: "UserId" };
|
type ClientId = string & { _brand: "UserId" };
|
||||||
|
|
||||||
const FIRST_N_AVATARS = 3;
|
const DEFAULT_MAX_AVATARS = 4;
|
||||||
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
||||||
|
|
||||||
const ConditionalTooltipWrapper = ({
|
const ConditionalTooltipWrapper = ({
|
||||||
shouldWrap,
|
shouldWrap,
|
||||||
children,
|
children,
|
||||||
clientId,
|
|
||||||
username,
|
username,
|
||||||
}: {
|
}: {
|
||||||
shouldWrap: boolean;
|
shouldWrap: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
clientId: ClientId;
|
|
||||||
}) =>
|
}) =>
|
||||||
shouldWrap ? (
|
shouldWrap ? (
|
||||||
<Tooltip label={username || "Unknown user"} key={clientId}>
|
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||||
{children}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={clientId}>{children}</React.Fragment>
|
<React.Fragment>{children}</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCollaborator = ({
|
const renderCollaborator = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId,
|
||||||
withName = false,
|
withName = false,
|
||||||
shouldWrapWithTooltip = false,
|
shouldWrapWithTooltip = false,
|
||||||
isBeingFollowed,
|
isBeingFollowed,
|
||||||
}: {
|
}: {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
collaborator: Collaborator;
|
collaborator: Collaborator;
|
||||||
clientId: ClientId;
|
socketId: SocketId;
|
||||||
withName?: boolean;
|
withName?: boolean;
|
||||||
shouldWrapWithTooltip?: boolean;
|
shouldWrapWithTooltip?: boolean;
|
||||||
isBeingFollowed: boolean;
|
isBeingFollowed: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const data: GoToCollaboratorComponentProps = {
|
const data: GoToCollaboratorComponentProps = {
|
||||||
clientId,
|
socketId,
|
||||||
collaborator,
|
collaborator,
|
||||||
withName,
|
withName,
|
||||||
isBeingFollowed,
|
isBeingFollowed,
|
||||||
@@ -70,8 +68,7 @@ const renderCollaborator = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionalTooltipWrapper
|
<ConditionalTooltipWrapper
|
||||||
key={clientId}
|
key={socketId}
|
||||||
clientId={clientId}
|
|
||||||
username={collaborator.username}
|
username={collaborator.username}
|
||||||
shouldWrap={shouldWrapWithTooltip}
|
shouldWrap={shouldWrapWithTooltip}
|
||||||
>
|
>
|
||||||
@@ -82,7 +79,13 @@ const renderCollaborator = ({
|
|||||||
|
|
||||||
type UserListUserObject = Pick<
|
type UserListUserObject = Pick<
|
||||||
Collaborator,
|
Collaborator,
|
||||||
"avatarUrl" | "id" | "socketId" | "username"
|
| "avatarUrl"
|
||||||
|
| "id"
|
||||||
|
| "socketId"
|
||||||
|
| "username"
|
||||||
|
| "isInCall"
|
||||||
|
| "isSpeaking"
|
||||||
|
| "isMuted"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type UserListProps = {
|
type UserListProps = {
|
||||||
@@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
|
|||||||
"id",
|
"id",
|
||||||
"socketId",
|
"socketId",
|
||||||
"username",
|
"username",
|
||||||
|
"isInCall",
|
||||||
|
"isSpeaking",
|
||||||
|
"isMuted",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const UserList = React.memo(
|
export const UserList = React.memo(
|
||||||
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
|
const uniqueCollaboratorsMap = new Map<
|
||||||
|
ClientId,
|
||||||
|
MarkRequired<Collaborator, "socketId">
|
||||||
|
>();
|
||||||
|
|
||||||
collaborators.forEach((collaborator, socketId) => {
|
collaborators.forEach((collaborator, socketId) => {
|
||||||
const userId = (collaborator.id || socketId) as ClientId;
|
const userId = (collaborator.id || socketId) as ClientId;
|
||||||
@@ -114,115 +123,147 @@ export const UserList = React.memo(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
const uniqueCollaboratorsArray = Array.from(
|
||||||
([_, collaborator]) => collaborator.username?.trim(),
|
uniqueCollaboratorsMap.values(),
|
||||||
);
|
).filter((collaborator) => collaborator.username?.trim());
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = React.useState("");
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
|
||||||
if (uniqueCollaboratorsArray.length === 0) {
|
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
||||||
return null;
|
|
||||||
}
|
useLayoutEffect(() => {
|
||||||
|
if (userListWrapper.current) {
|
||||||
|
const updateMaxAvatars = (width: number) => {
|
||||||
|
const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38)));
|
||||||
|
setMaxAvatars(maxAvatars);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMaxAvatars(userListWrapper.current.clientWidth);
|
||||||
|
|
||||||
|
if (!supportsResizeObserver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width } = entry.contentRect;
|
||||||
|
updateMaxAvatars(width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(userListWrapper.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
|
||||||
|
|
||||||
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
const filteredCollaborators = searchTermNormalized
|
const filteredCollaborators = searchTermNormalized
|
||||||
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
? uniqueCollaboratorsArray.filter((collaborator) =>
|
||||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||||
)
|
)
|
||||||
: uniqueCollaboratorsArray;
|
: uniqueCollaboratorsArray;
|
||||||
|
|
||||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||||
0,
|
0,
|
||||||
FIRST_N_AVATARS,
|
maxAvatars - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstNAvatarsJSX = firstNCollaborators.map(
|
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
|
||||||
([clientId, collaborator]) =>
|
renderCollaborator({
|
||||||
renderCollaborator({
|
actionManager,
|
||||||
actionManager,
|
collaborator,
|
||||||
collaborator,
|
socketId: collaborator.socketId,
|
||||||
clientId,
|
shouldWrapWithTooltip: true,
|
||||||
shouldWrapWithTooltip: true,
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
}),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
<div className={clsx("UserList UserList_mobile", className)}>
|
<div className={clsx("UserList UserList_mobile", className)}>
|
||||||
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
{uniqueCollaboratorsArray.map((collaborator) =>
|
||||||
renderCollaborator({
|
renderCollaborator({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId: collaborator.socketId,
|
||||||
shouldWrapWithTooltip: true,
|
shouldWrapWithTooltip: true,
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={clsx("UserList", className)}>
|
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||||
{firstNAvatarsJSX}
|
<div
|
||||||
|
className={clsx("UserList", className)}
|
||||||
|
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||||
|
>
|
||||||
|
{firstNAvatarsJSX}
|
||||||
|
|
||||||
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Trigger className="UserList__more">
|
|
||||||
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content
|
|
||||||
style={{
|
|
||||||
zIndex: 2,
|
|
||||||
width: "13rem",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
}}
|
||||||
align="end"
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
<Island style={{ overflow: "hidden" }}>
|
<Popover.Trigger className="UserList__more">
|
||||||
{uniqueCollaboratorsArray.length >=
|
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||||
SHOW_COLLABORATORS_FILTER_AT && (
|
</Popover.Trigger>
|
||||||
<div className="UserList__search-wrapper">
|
<Popover.Content
|
||||||
{searchIcon}
|
style={{
|
||||||
<input
|
zIndex: 2,
|
||||||
className="UserList__search"
|
width: "15rem",
|
||||||
type="text"
|
textAlign: "left",
|
||||||
placeholder={t("userList.search.placeholder")}
|
}}
|
||||||
value={searchTerm}
|
align="end"
|
||||||
onChange={(e) => {
|
sideOffset={10}
|
||||||
setSearchTerm(e.target.value);
|
>
|
||||||
}}
|
<Island style={{ overflow: "hidden" }}>
|
||||||
/>
|
{uniqueCollaboratorsArray.length >=
|
||||||
</div>
|
SHOW_COLLABORATORS_FILTER_AT && (
|
||||||
)}
|
<div className="UserList__search-wrapper">
|
||||||
<div className="dropdown-menu UserList__collaborators">
|
{searchIcon}
|
||||||
{filteredCollaborators.length === 0 && (
|
<input
|
||||||
<div className="UserList__collaborators__empty">
|
className="UserList__search"
|
||||||
{t("userList.search.empty")}
|
type="text"
|
||||||
|
placeholder={t("userList.search.placeholder")}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="UserList__hint">
|
<div className="dropdown-menu UserList__collaborators">
|
||||||
{t("userList.hint.text")}
|
{filteredCollaborators.length === 0 && (
|
||||||
|
<div className="UserList__collaborators__empty">
|
||||||
|
{t("userList.search.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="UserList__hint">
|
||||||
|
{t("userList.hint.text")}
|
||||||
|
</div>
|
||||||
|
{filteredCollaborators.map((collaborator) =>
|
||||||
|
renderCollaborator({
|
||||||
|
actionManager,
|
||||||
|
collaborator,
|
||||||
|
socketId: collaborator.socketId,
|
||||||
|
withName: true,
|
||||||
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{filteredCollaborators.map(([clientId, collaborator]) =>
|
</Island>
|
||||||
renderCollaborator({
|
</Popover.Content>
|
||||||
actionManager,
|
</Popover.Root>
|
||||||
collaborator,
|
)}
|
||||||
clientId,
|
</div>
|
||||||
withName: true,
|
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Island>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -236,10 +277,15 @@ export const UserList = React.memo(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextCollaboratorSocketIds = next.collaborators.keys();
|
||||||
|
|
||||||
for (const [socketId, collaborator] of prev.collaborators) {
|
for (const [socketId, collaborator] of prev.collaborators) {
|
||||||
const nextCollaborator = next.collaborators.get(socketId);
|
const nextCollaborator = next.collaborators.get(socketId);
|
||||||
if (
|
if (
|
||||||
!nextCollaborator ||
|
!nextCollaborator ||
|
||||||
|
// this checks order of collaborators in the map is the same
|
||||||
|
// as previous render
|
||||||
|
socketId !== nextCollaboratorSocketIds.next().value ||
|
||||||
!isShallowEqual(
|
!isShallowEqual(
|
||||||
collaborator,
|
collaborator,
|
||||||
nextCollaborator,
|
nextCollaborator,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
|
||||||
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
||||||
import { CURSOR_TYPE } from "../../constants";
|
import { CURSOR_TYPE } from "../../constants";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
@@ -12,6 +11,7 @@ import type {
|
|||||||
} from "../../scene/types";
|
} from "../../scene/types";
|
||||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||||
|
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||||
|
|
||||||
type InteractiveCanvasProps = {
|
type InteractiveCanvasProps = {
|
||||||
containerRef: React.RefObject<HTMLDivElement>;
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
@@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorButton: {
|
const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
|
||||||
[id: string]: string | undefined;
|
new Map();
|
||||||
} = {};
|
const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
new Map();
|
||||||
{};
|
|
||||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||||
{};
|
new Map();
|
||||||
const pointerUsernames: { [id: string]: string } = {};
|
const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
|
||||||
const pointerUserStates: { [id: string]: string } = {};
|
new Map();
|
||||||
|
const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
|
||||||
|
new Map();
|
||||||
|
|
||||||
props.appState.collaborators.forEach((user, socketId) => {
|
props.appState.collaborators.forEach((user, socketId) => {
|
||||||
if (user.selectedElementIds) {
|
if (user.selectedElementIds) {
|
||||||
for (const id of Object.keys(user.selectedElementIds)) {
|
for (const id of Object.keys(user.selectedElementIds)) {
|
||||||
if (!(id in remoteSelectedElementIds)) {
|
if (!remoteSelectedElementIds.has(id)) {
|
||||||
remoteSelectedElementIds[id] = [];
|
remoteSelectedElementIds.set(id, []);
|
||||||
}
|
}
|
||||||
remoteSelectedElementIds[id].push(socketId);
|
remoteSelectedElementIds.get(id)!.push(socketId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user.pointer) {
|
if (!user.pointer || user.pointer.renderCursor === false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.username) {
|
if (user.username) {
|
||||||
pointerUsernames[socketId] = user.username;
|
remotePointerUsernames.set(socketId, user.username);
|
||||||
}
|
}
|
||||||
if (user.userState) {
|
if (user.userState) {
|
||||||
pointerUserStates[socketId] = user.userState;
|
remotePointerUserStates.set(socketId, user.userState);
|
||||||
}
|
}
|
||||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
remotePointerViewportCoords.set(
|
||||||
{
|
socketId,
|
||||||
sceneX: user.pointer.x,
|
sceneCoordsToViewportCoords(
|
||||||
sceneY: user.pointer.y,
|
{
|
||||||
},
|
sceneX: user.pointer.x,
|
||||||
props.appState,
|
sceneY: user.pointer.y,
|
||||||
|
},
|
||||||
|
props.appState,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
cursorButton[socketId] = user.button;
|
remotePointerButton.set(socketId, user.button);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionColor =
|
const selectionColor =
|
||||||
@@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
scale: window.devicePixelRatio,
|
scale: window.devicePixelRatio,
|
||||||
appState: props.appState,
|
appState: props.appState,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
remotePointerViewportCoords: pointerViewportCoords,
|
remotePointerViewportCoords,
|
||||||
remotePointerButton: cursorButton,
|
remotePointerButton,
|
||||||
remoteSelectedElementIds,
|
remoteSelectedElementIds,
|
||||||
remotePointerUsernames: pointerUsernames,
|
remotePointerUsernames,
|
||||||
remotePointerUserStates: pointerUserStates,
|
remotePointerUserStates,
|
||||||
selectionColor,
|
selectionColor,
|
||||||
renderScrollbars: false,
|
renderScrollbars: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { renderStaticScene } from "../../renderer/renderScene";
|
import { renderStaticScene } from "../../renderer/staticScene";
|
||||||
import { isShallowEqual } from "../../utils";
|
import { isShallowEqual } from "../../utils";
|
||||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Excalidraw } from "../../index";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { Keyboard } from "../../tests/helpers/ui";
|
||||||
|
import { render, waitFor, getByTestId } from "../../tests/test-utils";
|
||||||
|
|
||||||
|
describe("Test <DropdownMenu/>", () => {
|
||||||
|
it("should", async () => {
|
||||||
|
const { container } = await render(<Excalidraw />);
|
||||||
|
|
||||||
|
expect(window.h.state.openMenu).toBe(null);
|
||||||
|
|
||||||
|
getByTestId(container, "main-menu-trigger").click();
|
||||||
|
expect(window.h.state.openMenu).toBe("canvas");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
Keyboard.keyDown(KEYS.ESCAPE);
|
||||||
|
expect(window.h.state.openMenu).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,12 @@ import { Island } from "../Island";
|
|||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Stack from "../Stack";
|
import Stack from "../Stack";
|
||||||
import React, { useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { DropdownMenuContentPropsContext } from "./common";
|
import { DropdownMenuContentPropsContext } from "./common";
|
||||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
|
import { KEYS } from "../../keys";
|
||||||
|
import { EVENT } from "../../constants";
|
||||||
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
const MenuContent = ({
|
const MenuContent = ({
|
||||||
children,
|
children,
|
||||||
@@ -25,10 +28,32 @@ const MenuContent = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const callbacksRef = useStable({ onClickOutside });
|
||||||
|
|
||||||
useOutsideClick(menuRef, () => {
|
useOutsideClick(menuRef, () => {
|
||||||
onClickOutside?.();
|
callbacksRef.onClickOutside?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === KEYS.ESCAPE) {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
callbacksRef.onClickOutside?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
// so that we can stop propagation of the event before it reaches
|
||||||
|
// event handlers that were bound before this one
|
||||||
|
capture: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
|
||||||
|
};
|
||||||
|
}, [callbacksRef]);
|
||||||
|
|
||||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||||
"dropdown-menu--mobile": device.editor.isMobile,
|
"dropdown-menu--mobile": device.editor.isMobile,
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw-hyperlinkContainer {
|
.excalidraw-hyperlinkContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
+48
-158
@@ -1,21 +1,20 @@
|
|||||||
import { AppState, ExcalidrawProps, Point, UIAppState } from "../types";
|
import { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
|
||||||
import {
|
import {
|
||||||
getShortcutKey,
|
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
wrapEvent,
|
wrapEvent,
|
||||||
} from "../utils";
|
} from "../../utils";
|
||||||
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
|
import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "../../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
|
ElementsMap,
|
||||||
ExcalidrawEmbeddableElement,
|
ExcalidrawEmbeddableElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "../../element/types";
|
||||||
|
|
||||||
import { register } from "../actions/register";
|
import { ToolButton } from "../ToolButton";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { FreedrawIcon, TrashIcon } from "../icons";
|
||||||
import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons";
|
import { t } from "../../i18n";
|
||||||
import { t } from "../i18n";
|
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -24,21 +23,19 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
|
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
||||||
import { rotate } from "../math";
|
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
|
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
|
||||||
import { Bounds } from "./bounds";
|
import { getSelectedElements } from "../../scene";
|
||||||
import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
|
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
||||||
import { getSelectedElements } from "../scene";
|
import { isLocalLink, normalizeLink } from "../../data/url";
|
||||||
import { isPointHittingElementBoundingBox } from "./collision";
|
|
||||||
import { getElementAbsoluteCoords } from ".";
|
|
||||||
import { isLocalLink, normalizeLink } from "../data/url";
|
|
||||||
|
|
||||||
import "./Hyperlink.scss";
|
import "./Hyperlink.scss";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||||
import { isEmbeddableElement } from "./typeChecks";
|
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||||
|
import { getLinkHandleFromCoords } from "./helpers";
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
const CONTAINER_WIDTH = 320;
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
@@ -46,11 +43,6 @@ const CONTAINER_PADDING = 5;
|
|||||||
const CONTAINER_HEIGHT = 42;
|
const CONTAINER_HEIGHT = 42;
|
||||||
const AUTO_HIDE_TIMEOUT = 500;
|
const AUTO_HIDE_TIMEOUT = 500;
|
||||||
|
|
||||||
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
|
||||||
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||||
|
|
||||||
const embeddableLinkCache = new Map<
|
const embeddableLinkCache = new Map<
|
||||||
@@ -60,12 +52,14 @@ const embeddableLinkCache = new Map<
|
|||||||
|
|
||||||
export const Hyperlink = ({
|
export const Hyperlink = ({
|
||||||
element,
|
element,
|
||||||
|
elementsMap,
|
||||||
setAppState,
|
setAppState,
|
||||||
onLinkOpen,
|
onLinkOpen,
|
||||||
setToast,
|
setToast,
|
||||||
updateEmbedValidationStatus,
|
updateEmbedValidationStatus,
|
||||||
}: {
|
}: {
|
||||||
element: NonDeletedExcalidrawElement;
|
element: NonDeletedExcalidrawElement;
|
||||||
|
elementsMap: ElementsMap;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||||
setToast: (
|
setToast: (
|
||||||
@@ -182,7 +176,7 @@ export const Hyperlink = ({
|
|||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
const shouldHide = shouldHideLinkPopup(element, appState, [
|
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
|
||||||
event.clientX,
|
event.clientX,
|
||||||
event.clientY,
|
event.clientY,
|
||||||
]) as boolean;
|
]) as boolean;
|
||||||
@@ -199,7 +193,7 @@ export const Hyperlink = ({
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [appState, element, isEditing, setAppState]);
|
}, [appState, element, isEditing, setAppState, elementsMap]);
|
||||||
|
|
||||||
const handleRemove = useCallback(() => {
|
const handleRemove = useCallback(() => {
|
||||||
trackEvent("hyperlink", "delete");
|
trackEvent("hyperlink", "delete");
|
||||||
@@ -214,7 +208,7 @@ export const Hyperlink = ({
|
|||||||
trackEvent("hyperlink", "edit", "popup-ui");
|
trackEvent("hyperlink", "edit", "popup-ui");
|
||||||
setAppState({ showHyperlinkPopup: "editor" });
|
setAppState({ showHyperlinkPopup: "editor" });
|
||||||
};
|
};
|
||||||
const { x, y } = getCoordsForPopover(element, appState);
|
const { x, y } = getCoordsForPopover(element, appState, elementsMap);
|
||||||
if (
|
if (
|
||||||
appState.contextMenu ||
|
appState.contextMenu ||
|
||||||
appState.draggingElement ||
|
appState.draggingElement ||
|
||||||
@@ -324,8 +318,9 @@ export const Hyperlink = ({
|
|||||||
const getCoordsForPopover = (
|
const getCoordsForPopover = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1] = getElementAbsoluteCoords(element);
|
const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||||
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
||||||
appState,
|
appState,
|
||||||
@@ -335,57 +330,12 @@ const getCoordsForPopover = (
|
|||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionLink = register({
|
|
||||||
name: "hyperlink",
|
|
||||||
perform: (elements, appState) => {
|
|
||||||
if (appState.showHyperlinkPopup === "editor") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
showHyperlinkPopup: "editor",
|
|
||||||
openMenu: null,
|
|
||||||
},
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
trackEvent: { category: "hyperlink", action: "click" },
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
|
||||||
contextItemLabel: (elements, appState) =>
|
|
||||||
getContextMenuLabel(elements, appState),
|
|
||||||
predicate: (elements, appState) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
return selectedElements.length === 1;
|
|
||||||
},
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolButton
|
|
||||||
type="button"
|
|
||||||
icon={LinkIcon}
|
|
||||||
aria-label={t(getContextMenuLabel(elements, appState))}
|
|
||||||
title={`${
|
|
||||||
isEmbeddableElement(elements[0])
|
|
||||||
? t("labels.link.labelEmbed")
|
|
||||||
: t("labels.link.label")
|
|
||||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
|
||||||
onClick={() => updateData(null)}
|
|
||||||
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getContextMenuLabel = (
|
export const getContextMenuLabel = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: UIAppState,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
const label = selectedElements[0]!.link
|
const label = selectedElements[0]?.link
|
||||||
? isEmbeddableElement(selectedElements[0])
|
? isEmbeddableElement(selectedElements[0])
|
||||||
? "labels.link.editEmbed"
|
? "labels.link.editEmbed"
|
||||||
: "labels.link.edit"
|
: "labels.link.edit"
|
||||||
@@ -395,89 +345,17 @@ export const getContextMenuLabel = (
|
|||||||
return label;
|
return label;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLinkHandleFromCoords = (
|
|
||||||
[x1, y1, x2, y2]: Bounds,
|
|
||||||
angle: number,
|
|
||||||
appState: Pick<UIAppState, "zoom">,
|
|
||||||
): Bounds => {
|
|
||||||
const size = DEFAULT_LINK_SIZE;
|
|
||||||
const linkWidth = size / appState.zoom.value;
|
|
||||||
const linkHeight = size / appState.zoom.value;
|
|
||||||
const linkMarginY = size / appState.zoom.value;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
|
||||||
const centerY = (y1 + y2) / 2;
|
|
||||||
const centeringOffset = (size - 8) / (2 * appState.zoom.value);
|
|
||||||
const dashedLineMargin = 4 / appState.zoom.value;
|
|
||||||
|
|
||||||
// Same as `ne` resize handle
|
|
||||||
const x = x2 + dashedLineMargin - centeringOffset;
|
|
||||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
|
||||||
|
|
||||||
const [rotatedX, rotatedY] = rotate(
|
|
||||||
x + linkWidth / 2,
|
|
||||||
y + linkHeight / 2,
|
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
rotatedX - linkWidth / 2,
|
|
||||||
rotatedY - linkHeight / 2,
|
|
||||||
linkWidth,
|
|
||||||
linkHeight,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isPointHittingLinkIcon = (
|
|
||||||
element: NonDeletedExcalidrawElement,
|
|
||||||
appState: AppState,
|
|
||||||
[x, y]: Point,
|
|
||||||
) => {
|
|
||||||
const threshold = 4 / appState.zoom.value;
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
|
||||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
|
||||||
[x1, y1, x2, y2],
|
|
||||||
element.angle,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
const hitLink =
|
|
||||||
x > linkX - threshold &&
|
|
||||||
x < linkX + threshold + linkWidth &&
|
|
||||||
y > linkY - threshold &&
|
|
||||||
y < linkY + linkHeight + threshold;
|
|
||||||
return hitLink;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isPointHittingLink = (
|
|
||||||
element: NonDeletedExcalidrawElement,
|
|
||||||
appState: AppState,
|
|
||||||
[x, y]: Point,
|
|
||||||
isMobile: boolean,
|
|
||||||
) => {
|
|
||||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const threshold = 4 / appState.zoom.value;
|
|
||||||
if (
|
|
||||||
!isMobile &&
|
|
||||||
appState.viewModeEnabled &&
|
|
||||||
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isPointHittingLinkIcon(element, appState, [x, y]);
|
|
||||||
};
|
|
||||||
|
|
||||||
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
|
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
|
||||||
export const showHyperlinkTooltip = (
|
export const showHyperlinkTooltip = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
|
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
|
||||||
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
|
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
|
||||||
}
|
}
|
||||||
HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
|
HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
|
||||||
() => renderTooltip(element, appState),
|
() => renderTooltip(element, appState, elementsMap),
|
||||||
HYPERLINK_TOOLTIP_DELAY,
|
HYPERLINK_TOOLTIP_DELAY,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -485,6 +363,7 @@ export const showHyperlinkTooltip = (
|
|||||||
const renderTooltip = (
|
const renderTooltip = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
if (!element.link) {
|
if (!element.link) {
|
||||||
return;
|
return;
|
||||||
@@ -496,7 +375,7 @@ const renderTooltip = (
|
|||||||
tooltipDiv.style.maxWidth = "20rem";
|
tooltipDiv.style.maxWidth = "20rem";
|
||||||
tooltipDiv.textContent = element.link;
|
tooltipDiv.textContent = element.link;
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||||
[x1, y1, x2, y2],
|
[x1, y1, x2, y2],
|
||||||
@@ -533,8 +412,9 @@ export const hideHyperlinkToolip = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldHideLinkPopup = (
|
const shouldHideLinkPopup = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[clientX, clientY]: Point,
|
[clientX, clientY]: Point,
|
||||||
): Boolean => {
|
): Boolean => {
|
||||||
@@ -546,11 +426,17 @@ export const shouldHideLinkPopup = (
|
|||||||
const threshold = 15 / appState.zoom.value;
|
const threshold = 15 / appState.zoom.value;
|
||||||
// hitbox to prevent hiding when hovered in element bounding box
|
// hitbox to prevent hiding when hovered in element bounding box
|
||||||
if (
|
if (
|
||||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
isPointHittingElementBoundingBox(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
[sceneX, sceneY],
|
||||||
|
threshold,
|
||||||
|
null,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
// hit box to prevent hiding when hovered in the vertical area between element and popover
|
// hit box to prevent hiding when hovered in the vertical area between element and popover
|
||||||
if (
|
if (
|
||||||
sceneX >= x1 &&
|
sceneX >= x1 &&
|
||||||
@@ -561,7 +447,11 @@ export const shouldHideLinkPopup = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// hit box to prevent hiding when hovered around popover within threshold
|
// hit box to prevent hiding when hovered around popover within threshold
|
||||||
const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState);
|
const { x: popoverX, y: popoverY } = getCoordsForPopover(
|
||||||
|
element,
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
clientX >= popoverX - threshold &&
|
clientX >= popoverX - threshold &&
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { MIME_TYPES } from "../../constants";
|
||||||
|
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
|
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
||||||
|
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
|
import { rotate } from "../../math";
|
||||||
|
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
||||||
|
import { AppState, Point, UIAppState } from "../../types";
|
||||||
|
|
||||||
|
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
||||||
|
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
export const getLinkHandleFromCoords = (
|
||||||
|
[x1, y1, x2, y2]: Bounds,
|
||||||
|
angle: number,
|
||||||
|
appState: Pick<UIAppState, "zoom">,
|
||||||
|
): Bounds => {
|
||||||
|
const size = DEFAULT_LINK_SIZE;
|
||||||
|
const linkWidth = size / appState.zoom.value;
|
||||||
|
const linkHeight = size / appState.zoom.value;
|
||||||
|
const linkMarginY = size / appState.zoom.value;
|
||||||
|
const centerX = (x1 + x2) / 2;
|
||||||
|
const centerY = (y1 + y2) / 2;
|
||||||
|
const centeringOffset = (size - 8) / (2 * appState.zoom.value);
|
||||||
|
const dashedLineMargin = 4 / appState.zoom.value;
|
||||||
|
|
||||||
|
// Same as `ne` resize handle
|
||||||
|
const x = x2 + dashedLineMargin - centeringOffset;
|
||||||
|
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||||
|
|
||||||
|
const [rotatedX, rotatedY] = rotate(
|
||||||
|
x + linkWidth / 2,
|
||||||
|
y + linkHeight / 2,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
rotatedX - linkWidth / 2,
|
||||||
|
rotatedY - linkHeight / 2,
|
||||||
|
linkWidth,
|
||||||
|
linkHeight,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPointHittingLinkIcon = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
[x, y]: Point,
|
||||||
|
) => {
|
||||||
|
const threshold = 4 / appState.zoom.value;
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||||
|
[x1, y1, x2, y2],
|
||||||
|
element.angle,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
const hitLink =
|
||||||
|
x > linkX - threshold &&
|
||||||
|
x < linkX + threshold + linkWidth &&
|
||||||
|
y > linkY - threshold &&
|
||||||
|
y < linkY + linkHeight + threshold;
|
||||||
|
return hitLink;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isPointHittingLink = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
[x, y]: Point,
|
||||||
|
isMobile: boolean,
|
||||||
|
) => {
|
||||||
|
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const threshold = 4 / appState.zoom.value;
|
||||||
|
if (
|
||||||
|
!isMobile &&
|
||||||
|
appState.viewModeEnabled &&
|
||||||
|
isPointHittingElementBoundingBox(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
[x, y],
|
||||||
|
threshold,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
|
||||||
|
};
|
||||||
@@ -85,7 +85,7 @@ export const PlusPromoIcon = createIcon(
|
|||||||
|
|
||||||
// tabler-icons: book
|
// tabler-icons: book
|
||||||
export const LibraryIcon = createIcon(
|
export const LibraryIcon = createIcon(
|
||||||
<g strokeWidth="1.5">
|
<g strokeWidth="1.25">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
||||||
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
||||||
@@ -386,6 +386,16 @@ export const ZoomOutIcon = createIcon(
|
|||||||
modifiedTablerIconProps,
|
modifiedTablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ZoomResetIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M21 21l-6 -6" />
|
||||||
|
<path d="M3.268 12.043a7.017 7.017 0 0 0 6.634 4.957a7.012 7.012 0 0 0 7.043 -6.131a7 7 0 0 0 -5.314 -7.672a7.021 7.021 0 0 0 -8.241 4.403" />
|
||||||
|
<path d="M3 4v4h4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const TrashIcon = createIcon(
|
export const TrashIcon = createIcon(
|
||||||
<path
|
<path
|
||||||
strokeWidth="1.25"
|
strokeWidth="1.25"
|
||||||
@@ -462,6 +472,16 @@ export const HelpIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const HelpIconThin = createIcon(
|
||||||
|
<g strokeWidth="1.25">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<circle cx="12" cy="12" r="9"></circle>
|
||||||
|
<line x1="12" y1="17" x2="12" y2="17.01"></line>
|
||||||
|
<path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const ExternalLinkIcon = createIcon(
|
export const ExternalLinkIcon = createIcon(
|
||||||
<path
|
<path
|
||||||
strokeWidth="1.25"
|
strokeWidth="1.25"
|
||||||
@@ -539,6 +559,16 @@ export const palette = createIcon(
|
|||||||
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const bucketFillIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M5 16l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737z" />
|
||||||
|
<path d="M13.737 9.737c2.299 -2.3 3.23 -5.095 2.081 -6.245c-1.15 -1.15 -3.945 -.217 -6.244 2.082c-2.3 2.299 -3.231 5.095 -2.082 6.244c1.15 1.15 3.946 .218 6.245 -2.081z" />
|
||||||
|
<path d="M7.492 11.818c.362 .362 .768 .676 1.208 .934l6.895 4.047c1.078 .557 2.255 -.075 3.692 -1.512c1.437 -1.437 2.07 -2.614 1.512 -3.692c-.372 -.718 -1.72 -3.017 -4.047 -6.895a6.015 6.015 0 0 0 -.934 -1.208" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const ExportImageIcon = createIcon(
|
export const ExportImageIcon = createIcon(
|
||||||
<g strokeWidth="1.25">
|
<g strokeWidth="1.25">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
@@ -604,11 +634,25 @@ export const share = createIcon(
|
|||||||
modifiedTablerIconProps,
|
modifiedTablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const warning = createIcon(
|
||||||
|
"M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z",
|
||||||
|
);
|
||||||
|
|
||||||
export const shareIOS = createIcon(
|
export const shareIOS = createIcon(
|
||||||
"M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z",
|
"M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z",
|
||||||
{ width: 24, height: 24 },
|
{ width: 24, height: 24 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const exportToPlus = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1" />
|
||||||
|
<path d="M12 14v-11" />
|
||||||
|
<path d="M9 6l3 -3l3 3" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const shareWindows = createIcon(
|
export const shareWindows = createIcon(
|
||||||
<>
|
<>
|
||||||
<path
|
<path
|
||||||
@@ -930,11 +974,6 @@ export const CloseIcon = createIcon(
|
|||||||
modifiedTablerIconProps,
|
modifiedTablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const back = createIcon(
|
|
||||||
"M34.52 239.03L228.87 44.69c9.37-9.37 24.57-9.37 33.94 0l22.67 22.67c9.36 9.36 9.37 24.52.04 33.9L131.49 256l154.02 154.75c9.34 9.38 9.32 24.54-.04 33.9l-22.67 22.67c-9.37 9.37-24.57 9.37-33.94 0L34.52 272.97c-9.37-9.37-9.37-24.57 0-33.94z",
|
|
||||||
{ width: 320, height: 512, style: { marginLeft: "-0.2rem" }, mirror: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clone = createIcon(
|
export const clone = createIcon(
|
||||||
"M464 0c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48H176c-26.51 0-48-21.49-48-48V48c0-26.51 21.49-48 48-48h288M176 416c-44.112 0-80-35.888-80-80V128H48c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48v-48H176z",
|
"M464 0c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48H176c-26.51 0-48-21.49-48-48V48c0-26.51 21.49-48 48-48h288M176 416c-44.112 0-80-35.888-80-80V128H48c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48v-48H176z",
|
||||||
{ mirror: true },
|
{ mirror: true },
|
||||||
@@ -1468,6 +1507,19 @@ export const FontSizeExtraLargeIcon = createIcon(
|
|||||||
modifiedTablerIconProps,
|
modifiedTablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const fontSizeIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 7v-2h13v2" />
|
||||||
|
<path d="M10 5v14" />
|
||||||
|
<path d="M12 19h-4" />
|
||||||
|
<path d="M15 13v-1h6v1" />
|
||||||
|
<path d="M18 12v7" />
|
||||||
|
<path d="M17 19h2" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const FontFamilyNormalIcon = createIcon(
|
export const FontFamilyNormalIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<g
|
<g
|
||||||
@@ -1645,6 +1697,17 @@ export const copyIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const cutIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||||
|
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||||
|
<path d="M9.15 14.85l8.85 -10.85" />
|
||||||
|
<path d="M6 4l8.85 10.85" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const helpIcon = createIcon(
|
export const helpIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
@@ -1769,6 +1832,17 @@ export const MagicIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const MagicIconThin = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" />
|
||||||
|
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
|
||||||
|
<path d="M15 6l3 3" />
|
||||||
|
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
|
||||||
|
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const OpenAIIcon = createIcon(
|
export const OpenAIIcon = createIcon(
|
||||||
<g stroke="currentColor" fill="none">
|
<g stroke="currentColor" fill="none">
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
@@ -1794,7 +1868,7 @@ export const fullscreenIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const eyeIcon = createIcon(
|
export const eyeIcon = createIcon(
|
||||||
<g stroke="currentColor" fill="none">
|
<g stroke="currentColor" fill="none" strokeWidth={1.5}>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||||
@@ -1825,6 +1899,19 @@ export const brainIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const brainIconThin = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
|
||||||
|
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
|
||||||
|
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
|
||||||
|
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
|
||||||
|
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
|
||||||
|
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
export const searchIcon = createIcon(
|
export const searchIcon = createIcon(
|
||||||
<g strokeWidth={1.5}>
|
<g strokeWidth={1.5}>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
@@ -1833,3 +1920,175 @@ export const searchIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const clockIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M20.984 12.53a9 9 0 1 0 -7.552 8.355" />
|
||||||
|
<path d="M12 7v5l3 3" />
|
||||||
|
<path d="M19 16l-2 3h4l-2 3" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const microphoneIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M9 2m0 3a3 3 0 0 1 3 -3h0a3 3 0 0 1 3 3v5a3 3 0 0 1 -3 3h0a3 3 0 0 1 -3 -3z" />
|
||||||
|
<path d="M5 10a7 7 0 0 0 14 0" />
|
||||||
|
<path d="M8 21l8 0" />
|
||||||
|
<path d="M12 17l0 4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const microphoneMutedIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
<path d="M9 5a3 3 0 0 1 6 0v5a3 3 0 0 1 -.13 .874m-2 2a3 3 0 0 1 -3.87 -2.872v-1" />
|
||||||
|
<path d="M5 10a7 7 0 0 0 10.846 5.85m2 -2a6.967 6.967 0 0 0 1.152 -3.85" />
|
||||||
|
<path d="M8 21l8 0" />
|
||||||
|
<path d="M12 17l0 4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const boltIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
export const selectAllIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M8 8m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" />
|
||||||
|
<path d="M12 20v.01" />
|
||||||
|
<path d="M16 20v.01" />
|
||||||
|
<path d="M8 20v.01" />
|
||||||
|
<path d="M4 20v.01" />
|
||||||
|
<path d="M4 16v.01" />
|
||||||
|
<path d="M4 12v.01" />
|
||||||
|
<path d="M4 8v.01" />
|
||||||
|
<path d="M4 4v.01" />
|
||||||
|
<path d="M8 4v.01" />
|
||||||
|
<path d="M12 4v.01" />
|
||||||
|
<path d="M16 4v.01" />
|
||||||
|
<path d="M20 4v.01" />
|
||||||
|
<path d="M20 8v.01" />
|
||||||
|
<path d="M20 12v.01" />
|
||||||
|
<path d="M20 16v.01" />
|
||||||
|
<path d="M20 20v.01" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const abacusIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M5 3v18" />
|
||||||
|
<path d="M19 21v-18" />
|
||||||
|
<path d="M5 7h14" />
|
||||||
|
<path d="M5 15h14" />
|
||||||
|
<path d="M8 13v4" />
|
||||||
|
<path d="M11 13v4" />
|
||||||
|
<path d="M16 13v4" />
|
||||||
|
<path d="M14 5v4" />
|
||||||
|
<path d="M11 5v4" />
|
||||||
|
<path d="M8 5v4" />
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const flipVertical = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 12l18 0" />
|
||||||
|
<path d="M7 16l10 0l-10 5l0 -5" />
|
||||||
|
<path d="M7 8l10 0l-10 -5l0 5" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const flipHorizontal = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M12 3l0 18" />
|
||||||
|
<path d="M16 7l0 10l5 0l-5 -10" />
|
||||||
|
<path d="M8 7l0 10l-5 0l5 -10" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const paintIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" />
|
||||||
|
<path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" />
|
||||||
|
<path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const zoomAreaIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0" />
|
||||||
|
<path d="M22 22l-3 -3" />
|
||||||
|
<path d="M6 18h-1a2 2 0 0 1 -2 -2v-1" />
|
||||||
|
<path d="M3 11v-1" />
|
||||||
|
<path d="M3 6v-1a2 2 0 0 1 2 -2h1" />
|
||||||
|
<path d="M10 3h1" />
|
||||||
|
<path d="M15 3h1a2 2 0 0 1 2 2v1" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const svgIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||||
|
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
|
||||||
|
<path d="M4 20.25c0 .414 .336 .75 .75 .75h1.25a1 1 0 0 0 1 -1v-1a1 1 0 0 0 -1 -1h-1a1 1 0 0 1 -1 -1v-1a1 1 0 0 1 1 -1h1.25a.75 .75 0 0 1 .75 .75" />
|
||||||
|
<path d="M10 15l2 6l2 -6" />
|
||||||
|
<path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pngIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||||
|
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
|
||||||
|
<path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
|
||||||
|
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6" />
|
||||||
|
<path d="M11 21v-6l3 6v-6" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const magnetIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0" />
|
||||||
|
<path d="M4 8l5 0" />
|
||||||
|
<path d="M15 8l4 0" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const coffeeIcon = createIcon(
|
||||||
|
<g strokeWidth={1.25}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 14c.83 .642 2.077 1.017 3.5 1c1.423 .017 2.67 -.358 3.5 -1c.83 -.642 2.077 -1.017 3.5 -1c1.423 -.017 2.67 .358 3.5 1" />
|
||||||
|
<path d="M8 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
|
||||||
|
<path d="M12 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
|
||||||
|
<path d="M3 10h14v5a6 6 0 0 1 -6 6h-2a6 6 0 0 1 -6 -6v-5z" />
|
||||||
|
<path d="M16.746 16.726a3 3 0 1 0 .252 -5.555" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useAppProps,
|
useAppProps,
|
||||||
} from "../App";
|
} from "../App";
|
||||||
import {
|
import {
|
||||||
|
boltIcon,
|
||||||
ExportIcon,
|
ExportIcon,
|
||||||
ExportImageIcon,
|
ExportImageIcon,
|
||||||
HelpIcon,
|
HelpIcon,
|
||||||
@@ -27,8 +28,6 @@ import {
|
|||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
|
|
||||||
import "./DefaultItems.scss";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
@@ -37,6 +36,8 @@ import { useUIAppState } from "../../context/ui-appState";
|
|||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
|
|
||||||
|
import "./DefaultItems.scss";
|
||||||
|
|
||||||
export const LoadScene = () => {
|
export const LoadScene = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
@@ -117,6 +118,24 @@ export const SaveAsImage = () => {
|
|||||||
};
|
};
|
||||||
SaveAsImage.displayName = "SaveAsImage";
|
SaveAsImage.displayName = "SaveAsImage";
|
||||||
|
|
||||||
|
export const CommandPalette = () => {
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={boltIcon}
|
||||||
|
data-testid="command-palette-button"
|
||||||
|
onSelect={() => setAppState({ openDialog: { name: "commandPalette" } })}
|
||||||
|
shortcut={getShortcutFromShortcutName("commandPalette")}
|
||||||
|
aria-label={t("commandPalette.title")}
|
||||||
|
>
|
||||||
|
{t("commandPalette.title")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
CommandPalette.displayName = "CommandPalette";
|
||||||
|
|
||||||
export const Help = () => {
|
export const Help = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const isIOS =
|
|||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
|
export const supportsResizeObserver =
|
||||||
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
@@ -28,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
|||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||||
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
|
||||||
|
export const DEFAULT_LASER_COLOR = "red";
|
||||||
export const CURSOR_TYPE = {
|
export const CURSOR_TYPE = {
|
||||||
TEXT: "text",
|
TEXT: "text",
|
||||||
CROSSHAIR: "crosshair",
|
CROSSHAIR: "crosshair",
|
||||||
@@ -144,6 +148,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
|
|||||||
export const DEFAULT_VERSION = "{version}";
|
export const DEFAULT_VERSION = "{version}";
|
||||||
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
||||||
|
|
||||||
|
export const COLOR_WHITE = "#ffffff";
|
||||||
|
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
||||||
|
// keep this in sync with CSS
|
||||||
|
export const COLOR_VOICE_CALL = "#a2f1a6";
|
||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
@@ -381,3 +390,9 @@ export const EDITOR_LS_KEYS = {
|
|||||||
MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw",
|
MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw",
|
||||||
PUBLISH_LIBRARY: "publish-library-data",
|
PUBLISH_LIBRARY: "publish-library-data",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* not translated as this is used only in public, stateless API as default value
|
||||||
|
* where filename is optional and we can't retrieve name from app state
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FILENAME = "Untitled";
|
||||||
|
|||||||
@@ -116,8 +116,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mixin avatarStyles {
|
@mixin avatarStyles {
|
||||||
width: 1.25rem;
|
width: var(--avatar-size, 1.5rem);
|
||||||
height: 1.25rem;
|
height: var(--avatar-size, 1.5rem);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -131,6 +131,10 @@
|
|||||||
color: var(--color-gray-90);
|
color: var(--color-gray-90);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
&-img {
|
&-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -144,14 +148,14 @@
|
|||||||
right: -3px;
|
right: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
left: -3px;
|
left: -3px;
|
||||||
border: 1px solid var(--avatar-border-color);
|
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--is-followed::before {
|
&.is-followed::before {
|
||||||
border-color: var(--color-primary-hover);
|
border-color: var(--color-primary-hover);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
&--is-current-user {
|
&.is-current-user {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -49,6 +50,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -79,6 +81,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "ellipse-1",
|
"elementId": "ellipse-1",
|
||||||
@@ -132,6 +135,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "ellipse-1",
|
"elementId": "ellipse-1",
|
||||||
@@ -190,6 +194,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -226,6 +231,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -269,6 +275,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -311,6 +318,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "text-2",
|
"elementId": "text-2",
|
||||||
@@ -365,6 +373,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id48",
|
"containerId": "id48",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -407,6 +416,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id40",
|
"elementId": "id40",
|
||||||
@@ -461,6 +471,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id37",
|
"containerId": "id37",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -503,6 +514,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -538,6 +550,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||||||
"type": "arrow",
|
"type": "arrow",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -573,6 +586,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": {
|
"endBinding": {
|
||||||
"elementId": "id44",
|
"elementId": "id44",
|
||||||
@@ -627,6 +641,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id41",
|
"containerId": "id41",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -670,6 +685,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -713,6 +729,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -750,6 +767,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -780,6 +798,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -825,6 +844,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "triangle",
|
"endArrowhead": "triangle",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -870,6 +890,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -915,6 +936,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -960,6 +982,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -990,6 +1013,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1020,6 +1044,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1050,6 +1075,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "#c0eb75",
|
"backgroundColor": "#c0eb75",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1080,6 +1106,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "#ffc9c9",
|
"backgroundColor": "#ffc9c9",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1110,6 +1137,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
|||||||
"angle": 0,
|
"angle": 0,
|
||||||
"backgroundColor": "#a5d8ff",
|
"backgroundColor": "#a5d8ff",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "cross-hatch",
|
"fillStyle": "cross-hatch",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1141,6 +1169,7 @@ exports[`Test Transform > should transform text element 1`] = `
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1179,6 +1208,7 @@ exports[`Test Transform > should transform text element 2`] = `
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": null,
|
"containerId": null,
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1221,6 +1251,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -1271,6 +1302,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -1321,6 +1353,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -1371,6 +1404,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
@@ -1417,6 +1451,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id25",
|
"containerId": "id25",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1455,6 +1490,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id26",
|
"containerId": "id26",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1493,6 +1529,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id27",
|
"containerId": "id27",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1532,6 +1569,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id28",
|
"containerId": "id28",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1575,6 +1613,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1610,6 +1649,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1645,6 +1685,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1680,6 +1721,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1715,6 +1757,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1750,6 +1793,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"frameId": null,
|
"frameId": null,
|
||||||
"groupIds": [],
|
"groupIds": [],
|
||||||
@@ -1781,6 +1825,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id13",
|
"containerId": "id13",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1819,6 +1864,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id14",
|
"containerId": "id14",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1858,6 +1904,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id15",
|
"containerId": "id15",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1899,6 +1946,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id16",
|
"containerId": "id16",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1938,6 +1986,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id17",
|
"containerId": "id17",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
@@ -1978,6 +2027,7 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"containerId": "id18",
|
"containerId": "id18",
|
||||||
|
"customData": undefined,
|
||||||
"fillStyle": "solid",
|
"fillStyle": "solid",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 20,
|
"fontSize": 20,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
|||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
import { ExcalidrawElement, FileId } from "../element/types";
|
import { ExcalidrawElement, FileId } from "../element/types";
|
||||||
import { CanvasError, ImageSceneDataError } from "../errors";
|
import { CanvasError, ImageSceneDataError } from "../errors";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { AppState, DataURL, LibraryItem } from "../types";
|
import { AppState, DataURL, LibraryItem } from "../types";
|
||||||
import { ValueOf } from "../utility-types";
|
import { ValueOf } from "../utility-types";
|
||||||
@@ -23,11 +22,11 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new ImageSceneDataError(
|
throw new ImageSceneDataError(
|
||||||
t("alerts.imageDoesNotContainScene"),
|
"Image doesn't contain scene",
|
||||||
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
|
throw new ImageSceneDataError("Error: cannot restore image");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -54,11 +53,11 @@ const parseFileContents = async (blob: Blob | File) => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === "INVALID") {
|
if (error.message === "INVALID") {
|
||||||
throw new ImageSceneDataError(
|
throw new ImageSceneDataError(
|
||||||
t("alerts.imageDoesNotContainScene"),
|
"Image doesn't contain scene",
|
||||||
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
|
throw new ImageSceneDataError("Error: cannot restore image");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +129,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isSupportedImageFile(blob)) {
|
if (isSupportedImageFile(blob)) {
|
||||||
throw new ImageSceneDataError(
|
throw new ImageSceneDataError(
|
||||||
t("alerts.imageDoesNotContainScene"),
|
"Image doesn't contain scene",
|
||||||
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
"IMAGE_NOT_CONTAINS_SCENE_DATA",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,12 +162,12 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error("Error: invalid file");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof ImageSceneDataError) {
|
if (error instanceof ImageSceneDataError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error("Error: invalid file");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -187,7 +186,7 @@ export const loadFromBlob = async (
|
|||||||
fileHandle,
|
fileHandle,
|
||||||
);
|
);
|
||||||
if (ret.type !== MIME_TYPES.excalidraw) {
|
if (ret.type !== MIME_TYPES.excalidraw) {
|
||||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
throw new Error("Error: invalid file");
|
||||||
}
|
}
|
||||||
return ret.data;
|
return ret.data;
|
||||||
};
|
};
|
||||||
@@ -222,10 +221,7 @@ export const canvasToBlob = async (
|
|||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
return reject(
|
return reject(
|
||||||
new CanvasError(
|
new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"),
|
||||||
t("canvasError.canvasTooBig"),
|
|
||||||
"CANVAS_POSSIBLY_TOO_BIG",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
@@ -314,7 +310,7 @@ export const resizeImageFile = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSupportedImageFile(file)) {
|
if (!isSupportedImageFile(file)) {
|
||||||
throw new Error(t("errors.unsupportedFileType"));
|
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
@@ -340,11 +336,11 @@ export const ImageURLToFile = async (
|
|||||||
try {
|
try {
|
||||||
response = await fetch(imageUrl);
|
response = await fetch(imageUrl);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw new Error(t("errors.failedToFetchImage"));
|
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(t("errors.failedToFetchImage"));
|
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -354,7 +350,7 @@ export const ImageURLToFile = async (
|
|||||||
return new File([blob], name, { type: blob.type });
|
return new File([blob], name, { type: blob.type });
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(t("errors.unsupportedFileType"));
|
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFileFromEvent = async (
|
export const getFileFromEvent = async (
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import {
|
|||||||
copyBlobToClipboardAsPng,
|
copyBlobToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
import {
|
||||||
|
DEFAULT_EXPORT_PADDING,
|
||||||
|
DEFAULT_FILENAME,
|
||||||
|
isFirefox,
|
||||||
|
MIME_TYPES,
|
||||||
|
} from "../constants";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
@@ -84,14 +89,15 @@ export const exportCanvas = async (
|
|||||||
exportBackground,
|
exportBackground,
|
||||||
exportPadding = DEFAULT_EXPORT_PADDING,
|
exportPadding = DEFAULT_EXPORT_PADDING,
|
||||||
viewBackgroundColor,
|
viewBackgroundColor,
|
||||||
name,
|
name = appState.name || DEFAULT_FILENAME,
|
||||||
fileHandle = null,
|
fileHandle = null,
|
||||||
exportingFrame = null,
|
exportingFrame = null,
|
||||||
}: {
|
}: {
|
||||||
exportBackground: boolean;
|
exportBackground: boolean;
|
||||||
exportPadding?: number;
|
exportPadding?: number;
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
name: string;
|
/** filename, if applicable */
|
||||||
|
name?: string;
|
||||||
fileHandle?: FileSystemHandle | null;
|
fileHandle?: FileSystemHandle | null;
|
||||||
exportingFrame: ExcalidrawFrameLikeElement | null;
|
exportingFrame: ExcalidrawFrameLikeElement | null;
|
||||||
},
|
},
|
||||||
@@ -127,9 +133,12 @@ export const exportCanvas = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (type === "clipboard-svg") {
|
} else if (type === "clipboard-svg") {
|
||||||
await copyTextToSystemClipboard(
|
const svg = await svgPromise.then((svg) => svg.outerHTML);
|
||||||
await svgPromise.then((svg) => svg.outerHTML),
|
try {
|
||||||
);
|
await copyTextToSystemClipboard(svg);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +179,7 @@ export const exportCanvas = async (
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
throw error;
|
throw new Error(t("canvasError.canvasTooBig"));
|
||||||
}
|
}
|
||||||
// TypeError *probably* suggests ClipboardItem not defined, which
|
// TypeError *probably* suggests ClipboardItem not defined, which
|
||||||
// people on Firefox can enable through a flag, so let's tell them.
|
// people on Firefox can enable through a flag, so let's tell them.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fileOpen, fileSave } from "./filesystem";
|
import { fileOpen, fileSave } from "./filesystem";
|
||||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_FILENAME,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
EXPORT_SOURCE,
|
EXPORT_SOURCE,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
@@ -71,6 +72,8 @@ export const saveAsJSON = async (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
|
/** filename */
|
||||||
|
name: string = appState.name || DEFAULT_FILENAME,
|
||||||
) => {
|
) => {
|
||||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||||
const blob = new Blob([serialized], {
|
const blob = new Blob([serialized], {
|
||||||
@@ -78,7 +81,7 @@ export const saveAsJSON = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fileHandle = await fileSave(blob, {
|
const fileHandle = await fileSave(blob, {
|
||||||
name: appState.name,
|
name,
|
||||||
extension: "excalidraw",
|
extension: "excalidraw",
|
||||||
description: "Excalidraw file",
|
description: "Excalidraw file",
|
||||||
fileHandle: isImageFileHandle(appState.fileHandle)
|
fileHandle: isImageFileHandle(appState.fileHandle)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
LibraryItem,
|
LibraryItem,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
LibraryItemsSource,
|
LibraryItemsSource,
|
||||||
|
LibraryItems_anyVersion,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { restoreLibraryItems } from "./restore";
|
import { restoreLibraryItems } from "./restore";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
@@ -23,13 +24,72 @@ import {
|
|||||||
LIBRARY_SIDEBAR_TAB,
|
LIBRARY_SIDEBAR_TAB,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
|
||||||
import { cloneJSON } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
cloneJSON,
|
||||||
|
preventUnload,
|
||||||
|
promiseTry,
|
||||||
|
resolvablePromise,
|
||||||
|
} from "../utils";
|
||||||
|
import { MaybePromise } from "../utility-types";
|
||||||
|
import { Emitter } from "../emitter";
|
||||||
|
import { Queue } from "../queue";
|
||||||
|
import { hashElementsVersion, hashString } from "../element";
|
||||||
|
|
||||||
|
type LibraryUpdate = {
|
||||||
|
/** deleted library items since last onLibraryChange event */
|
||||||
|
deletedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||||
|
/** newly added items in the library */
|
||||||
|
addedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// an object so that we can later add more properties to it without breaking,
|
||||||
|
// such as schema version
|
||||||
|
export type LibraryPersistedData = { libraryItems: LibraryItems };
|
||||||
|
|
||||||
|
const onLibraryUpdateEmitter = new Emitter<
|
||||||
|
[update: LibraryUpdate, libraryItems: LibraryItems]
|
||||||
|
>();
|
||||||
|
|
||||||
|
export type LibraryAdatapterSource = "load" | "save";
|
||||||
|
|
||||||
|
export interface LibraryPersistenceAdapter {
|
||||||
|
/**
|
||||||
|
* Should load data that were previously saved into the database using the
|
||||||
|
* `save` method. Should throw if saving fails.
|
||||||
|
*
|
||||||
|
* Will be used internally in multiple places, such as during save to
|
||||||
|
* in order to reconcile changes with latest store data.
|
||||||
|
*/
|
||||||
|
load(metadata: {
|
||||||
|
/**
|
||||||
|
* Indicates whether we're loading data for save purposes, or reading
|
||||||
|
* purposes, in which case host app can implement more aggressive caching.
|
||||||
|
*/
|
||||||
|
source: LibraryAdatapterSource;
|
||||||
|
}): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
|
||||||
|
/** Should persist to the database as is (do no change the data structure). */
|
||||||
|
save(libraryData: LibraryPersistedData): MaybePromise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryMigrationAdapter {
|
||||||
|
/**
|
||||||
|
* loads data from legacy data source. Returns `null` if no data is
|
||||||
|
* to be migrated.
|
||||||
|
*/
|
||||||
|
load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
|
||||||
|
|
||||||
|
/** clears entire storage afterwards */
|
||||||
|
clear(): MaybePromise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export const libraryItemsAtom = atom<{
|
export const libraryItemsAtom = atom<{
|
||||||
status: "loading" | "loaded";
|
status: "loading" | "loaded";
|
||||||
|
/** indicates whether library is initialized with library items (has gone
|
||||||
|
* through at least one update). Used in UI. Specific to this atom only. */
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
libraryItems: LibraryItems;
|
libraryItems: LibraryItems;
|
||||||
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
}>({ status: "loaded", isInitialized: false, libraryItems: [] });
|
||||||
|
|
||||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||||
cloneJSON(libraryItems);
|
cloneJSON(libraryItems);
|
||||||
@@ -74,12 +134,45 @@ export const mergeLibraryItems = (
|
|||||||
return [...newItems, ...localItems];
|
return [...newItems, ...localItems];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns { deletedItems, addedItems } maps of all added and deleted items
|
||||||
|
* since last onLibraryChange event.
|
||||||
|
*
|
||||||
|
* Host apps are recommended to diff with the latest state they have.
|
||||||
|
*/
|
||||||
|
const createLibraryUpdate = (
|
||||||
|
prevLibraryItems: LibraryItems,
|
||||||
|
nextLibraryItems: LibraryItems,
|
||||||
|
): LibraryUpdate => {
|
||||||
|
const nextItemsMap = arrayToMap(nextLibraryItems);
|
||||||
|
|
||||||
|
const update: LibraryUpdate = {
|
||||||
|
deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||||
|
addedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of prevLibraryItems) {
|
||||||
|
if (!nextItemsMap.has(item.id)) {
|
||||||
|
update.deletedItems.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevItemsMap = arrayToMap(prevLibraryItems);
|
||||||
|
|
||||||
|
for (const item of nextLibraryItems) {
|
||||||
|
if (!prevItemsMap.has(item.id)) {
|
||||||
|
update.addedItems.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return update;
|
||||||
|
};
|
||||||
|
|
||||||
class Library {
|
class Library {
|
||||||
/** latest libraryItems */
|
/** latest libraryItems */
|
||||||
private lastLibraryItems: LibraryItems = [];
|
private currLibraryItems: LibraryItems = [];
|
||||||
/** indicates whether library is initialized with library items (has gone
|
/** snapshot of library items since last onLibraryChange call */
|
||||||
* though at least one update) */
|
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
||||||
private isInitialized = false;
|
|
||||||
|
|
||||||
private app: App;
|
private app: App;
|
||||||
|
|
||||||
@@ -95,21 +188,29 @@ class Library {
|
|||||||
|
|
||||||
private notifyListeners = () => {
|
private notifyListeners = () => {
|
||||||
if (this.updateQueue.length > 0) {
|
if (this.updateQueue.length > 0) {
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
jotaiStore.set(libraryItemsAtom, (s) => ({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
libraryItems: this.lastLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: this.isInitialized,
|
isInitialized: s.isInitialized,
|
||||||
});
|
}));
|
||||||
} else {
|
} else {
|
||||||
this.isInitialized = true;
|
|
||||||
jotaiStore.set(libraryItemsAtom, {
|
jotaiStore.set(libraryItemsAtom, {
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
libraryItems: this.lastLibraryItems,
|
libraryItems: this.currLibraryItems,
|
||||||
isInitialized: this.isInitialized,
|
isInitialized: true,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
this.app.props.onLibraryChange?.(
|
const prevLibraryItems = this.prevLibraryItems;
|
||||||
cloneLibraryItems(this.lastLibraryItems),
|
this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
||||||
|
|
||||||
|
const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
||||||
|
|
||||||
|
this.app.props.onLibraryChange?.(nextLibraryItems);
|
||||||
|
|
||||||
|
// for internal use in `useHandleLibrary` hook
|
||||||
|
onLibraryUpdateEmitter.trigger(
|
||||||
|
createLibraryUpdate(prevLibraryItems, nextLibraryItems),
|
||||||
|
nextLibraryItems,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -119,9 +220,8 @@ class Library {
|
|||||||
|
|
||||||
/** call on excalidraw instance unmount */
|
/** call on excalidraw instance unmount */
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
this.isInitialized = false;
|
|
||||||
this.updateQueue = [];
|
this.updateQueue = [];
|
||||||
this.lastLibraryItems = [];
|
this.currLibraryItems = [];
|
||||||
jotaiStore.set(libraryItemSvgsCache, new Map());
|
jotaiStore.set(libraryItemSvgsCache, new Map());
|
||||||
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
// TODO uncomment after/if we make jotai store scoped to each excal instance
|
||||||
// jotaiStore.set(libraryItemsAtom, {
|
// jotaiStore.set(libraryItemsAtom, {
|
||||||
@@ -142,14 +242,14 @@ class Library {
|
|||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
try {
|
try {
|
||||||
const libraryItems = await (this.getLastUpdateTask() ||
|
const libraryItems = await (this.getLastUpdateTask() ||
|
||||||
this.lastLibraryItems);
|
this.currLibraryItems);
|
||||||
if (this.updateQueue.length > 0) {
|
if (this.updateQueue.length > 0) {
|
||||||
resolve(this.getLatestLibrary());
|
resolve(this.getLatestLibrary());
|
||||||
} else {
|
} else {
|
||||||
resolve(cloneLibraryItems(libraryItems));
|
resolve(cloneLibraryItems(libraryItems));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return resolve(this.lastLibraryItems);
|
return resolve(this.currLibraryItems);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -181,7 +281,7 @@ class Library {
|
|||||||
try {
|
try {
|
||||||
const source = await (typeof libraryItems === "function" &&
|
const source = await (typeof libraryItems === "function" &&
|
||||||
!(libraryItems instanceof Blob)
|
!(libraryItems instanceof Blob)
|
||||||
? libraryItems(this.lastLibraryItems)
|
? libraryItems(this.currLibraryItems)
|
||||||
: libraryItems);
|
: libraryItems);
|
||||||
|
|
||||||
let nextItems;
|
let nextItems;
|
||||||
@@ -207,7 +307,7 @@ class Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (merge) {
|
if (merge) {
|
||||||
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
|
resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
|
||||||
} else {
|
} else {
|
||||||
resolve(nextItems);
|
resolve(nextItems);
|
||||||
}
|
}
|
||||||
@@ -244,12 +344,12 @@ class Library {
|
|||||||
await this.getLastUpdateTask();
|
await this.getLastUpdateTask();
|
||||||
|
|
||||||
if (typeof libraryItems === "function") {
|
if (typeof libraryItems === "function") {
|
||||||
libraryItems = libraryItems(this.lastLibraryItems);
|
libraryItems = libraryItems(this.currLibraryItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
this.currLibraryItems = cloneLibraryItems(await libraryItems);
|
||||||
|
|
||||||
resolve(this.lastLibraryItems);
|
resolve(this.currLibraryItems);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
@@ -257,7 +357,7 @@ class Library {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
console.warn("Library update aborted by user");
|
console.warn("Library update aborted by user");
|
||||||
return this.lastLibraryItems;
|
return this.currLibraryItems;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
})
|
})
|
||||||
@@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => {
|
|||||||
return libraryUrl ? { libraryUrl, idToken } : null;
|
return libraryUrl ? { libraryUrl, idToken } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useHandleLibrary = ({
|
class AdapterTransaction {
|
||||||
excalidrawAPI,
|
static queue = new Queue();
|
||||||
getInitialLibraryItems,
|
|
||||||
}: {
|
static async getLibraryItems(
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
adapter: LibraryPersistenceAdapter,
|
||||||
getInitialLibraryItems?: () => LibraryItemsSource;
|
source: LibraryAdatapterSource,
|
||||||
}) => {
|
_queue = true,
|
||||||
const getInitialLibraryRef = useRef(getInitialLibraryItems);
|
): Promise<LibraryItems> {
|
||||||
|
const task = () =>
|
||||||
|
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const data = await adapter.load({ source });
|
||||||
|
resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
|
||||||
|
} catch (error: any) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_queue) {
|
||||||
|
return AdapterTransaction.queue.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task();
|
||||||
|
}
|
||||||
|
|
||||||
|
static run = async <T>(
|
||||||
|
adapter: LibraryPersistenceAdapter,
|
||||||
|
fn: (transaction: AdapterTransaction) => Promise<T>,
|
||||||
|
) => {
|
||||||
|
const transaction = new AdapterTransaction(adapter);
|
||||||
|
return AdapterTransaction.queue.push(() => fn(transaction));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------
|
||||||
|
|
||||||
|
private adapter: LibraryPersistenceAdapter;
|
||||||
|
|
||||||
|
constructor(adapter: LibraryPersistenceAdapter) {
|
||||||
|
this.adapter = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLibraryItems(source: LibraryAdatapterSource) {
|
||||||
|
return AdapterTransaction.getLibraryItems(this.adapter, source, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastSavedLibraryItemsHash = 0;
|
||||||
|
let librarySaveCounter = 0;
|
||||||
|
|
||||||
|
export const getLibraryItemsHash = (items: LibraryItems) => {
|
||||||
|
return hashString(
|
||||||
|
items
|
||||||
|
.map((item) => {
|
||||||
|
return `${item.id}:${hashElementsVersion(item.elements)}`;
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistLibraryUpdate = async (
|
||||||
|
adapter: LibraryPersistenceAdapter,
|
||||||
|
update: LibraryUpdate,
|
||||||
|
): Promise<LibraryItems> => {
|
||||||
|
try {
|
||||||
|
librarySaveCounter++;
|
||||||
|
|
||||||
|
return await AdapterTransaction.run(adapter, async (transaction) => {
|
||||||
|
const nextLibraryItemsMap = arrayToMap(
|
||||||
|
await transaction.getLibraryItems("save"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [id] of update.deletedItems) {
|
||||||
|
nextLibraryItemsMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedItems: LibraryItem[] = [];
|
||||||
|
|
||||||
|
// we want to merge current library items with the ones stored in the
|
||||||
|
// DB so that we don't lose any elements that for some reason aren't
|
||||||
|
// in the current editor library, which could happen when:
|
||||||
|
//
|
||||||
|
// 1. we haven't received an update deleting some elements
|
||||||
|
// (in which case it's still better to keep them in the DB lest
|
||||||
|
// it was due to a different reason)
|
||||||
|
// 2. we keep a single DB for all active editors, but the editors'
|
||||||
|
// libraries aren't synced or there's a race conditions during
|
||||||
|
// syncing
|
||||||
|
// 3. some other race condition, e.g. during init where emit updates
|
||||||
|
// for partial updates (e.g. you install a 3rd party library and
|
||||||
|
// init from DB only after — we emit events for both updates)
|
||||||
|
for (const [id, item] of update.addedItems) {
|
||||||
|
if (nextLibraryItemsMap.has(id)) {
|
||||||
|
// replace item with latest version
|
||||||
|
// TODO we could prefer the newer item instead
|
||||||
|
nextLibraryItemsMap.set(id, item);
|
||||||
|
} else {
|
||||||
|
// we want to prepend the new items with the ones that are already
|
||||||
|
// in DB to preserve the ordering we do in editor (newly added
|
||||||
|
// items are added to the beginning)
|
||||||
|
addedItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLibraryItems = addedItems.concat(
|
||||||
|
Array.from(nextLibraryItemsMap.values()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const version = getLibraryItemsHash(nextLibraryItems);
|
||||||
|
|
||||||
|
if (version !== lastSavedLibraryItemsHash) {
|
||||||
|
await adapter.save({ libraryItems: nextLibraryItems });
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSavedLibraryItemsHash = version;
|
||||||
|
|
||||||
|
return nextLibraryItems;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
librarySaveCounter--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHandleLibrary = (
|
||||||
|
opts: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
/** @deprecated we recommend using `opts.adapter` instead */
|
||||||
|
getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
adapter: LibraryPersistenceAdapter;
|
||||||
|
/**
|
||||||
|
* Adapter that takes care of loading data from legacy data store.
|
||||||
|
* Supply this if you want to migrate data on initial load from legacy
|
||||||
|
* data store.
|
||||||
|
*
|
||||||
|
* Can be a different LibraryPersistenceAdapter.
|
||||||
|
*/
|
||||||
|
migrationAdapter?: LibraryMigrationAdapter;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
) => {
|
||||||
|
const { excalidrawAPI } = opts;
|
||||||
|
|
||||||
|
const optsRef = useRef(opts);
|
||||||
|
optsRef.current = opts;
|
||||||
|
|
||||||
|
const isLibraryLoadedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!excalidrawAPI) {
|
if (!excalidrawAPI) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reset on editor remount (excalidrawAPI changed)
|
||||||
|
isLibraryLoadedRef.current = false;
|
||||||
|
|
||||||
const importLibraryFromURL = async ({
|
const importLibraryFromURL = async ({
|
||||||
libraryUrl,
|
libraryUrl,
|
||||||
idToken,
|
idToken,
|
||||||
@@ -463,23 +708,209 @@ export const useHandleLibrary = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// ------ init load --------------------------------------------------------
|
// ---------------------------------- init ---------------------------------
|
||||||
if (getInitialLibraryRef.current) {
|
// -------------------------------------------------------------------------
|
||||||
excalidrawAPI.updateLibrary({
|
|
||||||
libraryItems: getInitialLibraryRef.current(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||||
|
|
||||||
if (libraryUrlTokens) {
|
if (libraryUrlTokens) {
|
||||||
importLibraryFromURL(libraryUrlTokens);
|
importLibraryFromURL(libraryUrlTokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------ (A) init load (legacy) -------------------------------------------
|
||||||
|
if (
|
||||||
|
"getInitialLibraryItems" in optsRef.current &&
|
||||||
|
optsRef.current.getInitialLibraryItems
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
"useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.",
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.resolve(optsRef.current.getInitialLibraryItems())
|
||||||
|
.then((libraryItems) => {
|
||||||
|
excalidrawAPI.updateLibrary({
|
||||||
|
libraryItems,
|
||||||
|
// merge with current library items because we may have already
|
||||||
|
// populated it (e.g. by installing 3rd party library which can
|
||||||
|
// happen before the DB data is loaded)
|
||||||
|
merge: true,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error(
|
||||||
|
`UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
// --------------------------------------------------------- init load -----
|
// --------------------------------------------------------- init load -----
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ------ (B) data source adapter ------------------------------------------
|
||||||
|
|
||||||
|
if ("adapter" in optsRef.current && optsRef.current.adapter) {
|
||||||
|
const adapter = optsRef.current.adapter;
|
||||||
|
const migrationAdapter = optsRef.current.migrationAdapter;
|
||||||
|
|
||||||
|
const initDataPromise = resolvablePromise<LibraryItems | null>();
|
||||||
|
|
||||||
|
// migrate from old data source if needed
|
||||||
|
// (note, if `migrate` function is defined, we always migrate even
|
||||||
|
// if the data has already been migrated. In that case it'll be a no-op,
|
||||||
|
// though with several unnecessary steps — we will still load latest
|
||||||
|
// DB data during the `persistLibraryChange()` step)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
if (migrationAdapter) {
|
||||||
|
initDataPromise.resolve(
|
||||||
|
promiseTry(migrationAdapter.load)
|
||||||
|
.then(async (libraryData) => {
|
||||||
|
let restoredData: LibraryItems | null = null;
|
||||||
|
try {
|
||||||
|
// if no library data to migrate, assume no migration needed
|
||||||
|
// and skip persisting to new data store, as well as well
|
||||||
|
// clearing the old store via `migrationAdapter.clear()`
|
||||||
|
if (!libraryData) {
|
||||||
|
return AdapterTransaction.getLibraryItems(adapter, "load");
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredData = restoreLibraryItems(
|
||||||
|
libraryData.libraryItems || [],
|
||||||
|
"published",
|
||||||
|
);
|
||||||
|
|
||||||
|
// we don't queue this operation because it's running inside
|
||||||
|
// a promise that's running inside Library update queue itself
|
||||||
|
const nextItems = await persistLibraryUpdate(
|
||||||
|
adapter,
|
||||||
|
createLibraryUpdate([], restoredData),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await migrationAdapter.clear();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`couldn't delete legacy library data: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// migration suceeded, load migrated data
|
||||||
|
return nextItems;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`couldn't migrate legacy library data: ${error.message}`,
|
||||||
|
);
|
||||||
|
// migration failed, load data from previous store, if any
|
||||||
|
return restoredData;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// errors caught during `migrationAdapter.load()`
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error(`error during library migration: ${error.message}`);
|
||||||
|
// as a default, load latest library from current data source
|
||||||
|
return AdapterTransaction.getLibraryItems(adapter, "load");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
initDataPromise.resolve(
|
||||||
|
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// load initial (or migrated) library
|
||||||
|
excalidrawAPI
|
||||||
|
.updateLibrary({
|
||||||
|
libraryItems: initDataPromise.then((libraryItems) => {
|
||||||
|
const _libraryItems = libraryItems || [];
|
||||||
|
lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
|
||||||
|
return _libraryItems;
|
||||||
|
}),
|
||||||
|
// merge with current library items because we may have already
|
||||||
|
// populated it (e.g. by installing 3rd party library which can
|
||||||
|
// happen before the DB data is loaded)
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isLibraryLoadedRef.current = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ---------------------------------------------- data source datapter -----
|
||||||
|
|
||||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
|
||||||
};
|
};
|
||||||
}, [excalidrawAPI]);
|
}, [
|
||||||
|
// important this useEffect only depends on excalidrawAPI so it only reruns
|
||||||
|
// on editor remounts (the excalidrawAPI changes)
|
||||||
|
excalidrawAPI,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// This effect is run without excalidrawAPI dependency so that host apps
|
||||||
|
// can run this hook outside of an active editor instance and the library
|
||||||
|
// update queue/loop survives editor remounts
|
||||||
|
//
|
||||||
|
// This effect is still only meant to be run if host apps supply an persitence
|
||||||
|
// adapter. If we don't have access to it, it the update listener doesn't
|
||||||
|
// do anything.
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
// on update, merge with current library items and persist
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(
|
||||||
|
async (update, nextLibraryItems) => {
|
||||||
|
const isLoaded = isLibraryLoadedRef.current;
|
||||||
|
// we want to operate with the latest adapter, but we don't want this
|
||||||
|
// effect to rerun on every adapter change in case host apps' adapter
|
||||||
|
// isn't stable
|
||||||
|
const adapter =
|
||||||
|
("adapter" in optsRef.current && optsRef.current.adapter) || null;
|
||||||
|
try {
|
||||||
|
if (adapter) {
|
||||||
|
if (
|
||||||
|
// if nextLibraryItems hash identical to previously saved hash,
|
||||||
|
// exit early, even if actual upstream state ends up being
|
||||||
|
// different (e.g. has more data than we have locally), as it'd
|
||||||
|
// be low-impact scenario.
|
||||||
|
lastSavedLibraryItemsHash !==
|
||||||
|
getLibraryItemsHash(nextLibraryItems)
|
||||||
|
) {
|
||||||
|
await persistLibraryUpdate(adapter, update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`couldn't persist library update: ${error.message}`,
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
|
||||||
|
// currently we only show error if an editor is loaded
|
||||||
|
if (isLoaded && optsRef.current.excalidrawAPI) {
|
||||||
|
optsRef.current.excalidrawAPI.updateScene({
|
||||||
|
appState: {
|
||||||
|
errorMessage: t("errors.saveLibraryError"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUnload = (event: Event) => {
|
||||||
|
if (librarySaveCounter) {
|
||||||
|
preventUnload(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
|
||||||
|
unsubOnLibraryUpdate();
|
||||||
|
lastSavedLibraryItemsHash = 0;
|
||||||
|
librarySaveCounter = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[
|
||||||
|
// this effect must not have any deps so it doesn't rerun
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ export const resaveAsImageWithScene = async (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
|
const { exportBackground, viewBackgroundColor, fileHandle } = appState;
|
||||||
|
|
||||||
const fileHandleType = getFileHandleType(fileHandle);
|
const fileHandleType = getFileHandleType(fileHandle);
|
||||||
|
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ export const restoreElements = (
|
|||||||
refreshTextDimensions(
|
refreshTextDimensions(
|
||||||
element,
|
element,
|
||||||
getContainerElement(element, restoredElementsMap),
|
getContainerElement(element, restoredElementsMap),
|
||||||
|
restoredElementsMap,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -822,4 +822,22 @@ describe("Test Transform", () => {
|
|||||||
"Duplicate id found for rect-1",
|
"Duplicate id found for rect-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should contains customData if provided", () => {
|
||||||
|
const rawData = [
|
||||||
|
{
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
customData: { createdBy: "user01" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const convertedElements = convertToExcalidrawElements(
|
||||||
|
rawData as ExcalidrawElementSkeleton[],
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
expect(convertedElements[0].customData).toStrictEqual({
|
||||||
|
createdBy: "user01",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ import {
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FileId,
|
FileId,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
|
NonDeletedSceneElementsMap,
|
||||||
TextAlign,
|
TextAlign,
|
||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
import { arrayToMap, assertNever, cloneJSON, getFontString } from "../utils";
|
import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ const bindTextToContainer = (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(textElement, container, elementsMap);
|
||||||
return [container, textElement] as const;
|
return [container, textElement] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,6 +232,7 @@ const bindLinearElementToElement = (
|
|||||||
start: ValidLinearElement["start"],
|
start: ValidLinearElement["start"],
|
||||||
end: ValidLinearElement["end"],
|
end: ValidLinearElement["end"],
|
||||||
elementStore: ElementStore,
|
elementStore: ElementStore,
|
||||||
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): {
|
): {
|
||||||
linearElement: ExcalidrawLinearElement;
|
linearElement: ExcalidrawLinearElement;
|
||||||
startBoundElement?: ExcalidrawElement;
|
startBoundElement?: ExcalidrawElement;
|
||||||
@@ -316,6 +318,7 @@ const bindLinearElementToElement = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
startBoundElement as ExcalidrawBindableElement,
|
startBoundElement as ExcalidrawBindableElement,
|
||||||
"start",
|
"start",
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,6 +393,7 @@ const bindLinearElementToElement = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
endBoundElement as ExcalidrawBindableElement,
|
endBoundElement as ExcalidrawBindableElement,
|
||||||
"end",
|
"end",
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,6 +461,10 @@ class ElementStore {
|
|||||||
return Array.from(this.excalidrawElements.values());
|
return Array.from(this.excalidrawElements.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getElementsMap = () => {
|
||||||
|
return toBrandedType<NonDeletedSceneElementsMap>(this.excalidrawElements);
|
||||||
|
};
|
||||||
|
|
||||||
getElement = (id: string) => {
|
getElement = (id: string) => {
|
||||||
return this.excalidrawElements.get(id);
|
return this.excalidrawElements.get(id);
|
||||||
};
|
};
|
||||||
@@ -612,6 +620,7 @@ export const convertToExcalidrawElements = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const elementsMap = elementStore.getElementsMap();
|
||||||
// Add labels and arrow bindings
|
// Add labels and arrow bindings
|
||||||
for (const [id, element] of elementsWithIds) {
|
for (const [id, element] of elementsWithIds) {
|
||||||
const excalidrawElement = elementStore.getElement(id)!;
|
const excalidrawElement = elementStore.getElement(id)!;
|
||||||
@@ -625,7 +634,7 @@ export const convertToExcalidrawElements = (
|
|||||||
let [container, text] = bindTextToContainer(
|
let [container, text] = bindTextToContainer(
|
||||||
excalidrawElement,
|
excalidrawElement,
|
||||||
element?.label,
|
element?.label,
|
||||||
arrayToMap(elementStore.getElements()),
|
elementsMap,
|
||||||
);
|
);
|
||||||
elementStore.add(container);
|
elementStore.add(container);
|
||||||
elementStore.add(text);
|
elementStore.add(text);
|
||||||
@@ -653,6 +662,7 @@ export const convertToExcalidrawElements = (
|
|||||||
originalStart,
|
originalStart,
|
||||||
originalEnd,
|
originalEnd,
|
||||||
elementStore,
|
elementStore,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
container = linearElement;
|
container = linearElement;
|
||||||
elementStore.add(linearElement);
|
elementStore.add(linearElement);
|
||||||
@@ -677,6 +687,7 @@ export const convertToExcalidrawElements = (
|
|||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
elementStore,
|
elementStore,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
elementStore.add(linearElement);
|
elementStore.add(linearElement);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// taken from lodash (MIT)
|
||||||
|
// https://github.com/lodash/lodash/blob/67389a8c78975d97505fa15aa79bec6397749807/lodash.js#L14180
|
||||||
|
|
||||||
|
const rsComboMarksRange = "\\u0300-\\u036f";
|
||||||
|
const reComboHalfMarksRange = "\\ufe20-\\ufe2f";
|
||||||
|
const rsComboSymbolsRange = "\\u20d0-\\u20ff";
|
||||||
|
const rsComboRange =
|
||||||
|
rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange;
|
||||||
|
const rsCombo = `[${rsComboRange}]`;
|
||||||
|
|
||||||
|
const reComboMark = RegExp(rsCombo, "g");
|
||||||
|
|
||||||
|
const reLatin = /[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g;
|
||||||
|
|
||||||
|
// NOTE below letter replacements are modified from lodash to always convert
|
||||||
|
// to single-letter form by phonetic similarity to keep indexing identical.
|
||||||
|
// Doing this is only useful for search highlighting, and only insofar
|
||||||
|
// we use a library that can highlight the original source string using
|
||||||
|
// the matching indices. As such, we'll likely need to write our own highlighter
|
||||||
|
// anyway. Ultimately, we'll want to write our own matcher altogether
|
||||||
|
// so we don't have to do any deburring, which will be the most correct
|
||||||
|
// solution.
|
||||||
|
//
|
||||||
|
// prettier-ignore
|
||||||
|
const deburredLetters = {
|
||||||
|
'\xc0': 'A', '\xc1': 'A', '\xc2': 'A', '\xc3': 'A', '\xc4': 'A', '\xc5': 'A',
|
||||||
|
'\xe0': 'a', '\xe1': 'a', '\xe2': 'a', '\xe3': 'a', '\xe4': 'a', '\xe5': 'a',
|
||||||
|
'\xc7': 'C', '\xe7': 'c',
|
||||||
|
'\xd0': 'D', '\xf0': 'd',
|
||||||
|
'\xc8': 'E', '\xc9': 'E', '\xca': 'E', '\xcb': 'E',
|
||||||
|
'\xe8': 'e', '\xe9': 'e', '\xea': 'e', '\xeb': 'e',
|
||||||
|
'\xcc': 'I', '\xcd': 'I', '\xce': 'I', '\xcf': 'I',
|
||||||
|
'\xec': 'i', '\xed': 'i', '\xee': 'i', '\xef': 'i',
|
||||||
|
'\xd1': 'N', '\xf1': 'n',
|
||||||
|
'\xd2': 'O', '\xd3': 'O', '\xd4': 'O', '\xd5': 'O', '\xd6': 'O', '\xd8': 'O',
|
||||||
|
'\xf2': 'o', '\xf3': 'o', '\xf4': 'o', '\xf5': 'o', '\xf6': 'o', '\xf8': 'o',
|
||||||
|
'\xd9': 'U', '\xda': 'U', '\xdb': 'U', '\xdc': 'U',
|
||||||
|
'\xf9': 'u', '\xfa': 'u', '\xfb': 'u', '\xfc': 'u',
|
||||||
|
'\xdd': 'Y', '\xfd': 'y', '\xff': 'y',
|
||||||
|
// normaly Ae/ae
|
||||||
|
'\xc6': 'E', '\xe6': 'e',
|
||||||
|
// normally Th/th
|
||||||
|
'\xde': 'T', '\xfe': 't',
|
||||||
|
// normally ss
|
||||||
|
'\xdf': 's',
|
||||||
|
'\u0100': 'A', '\u0102': 'A', '\u0104': 'A',
|
||||||
|
'\u0101': 'a', '\u0103': 'a', '\u0105': 'a',
|
||||||
|
'\u0106': 'C', '\u0108': 'C', '\u010a': 'C', '\u010c': 'C',
|
||||||
|
'\u0107': 'c', '\u0109': 'c', '\u010b': 'c', '\u010d': 'c',
|
||||||
|
'\u010e': 'D', '\u0110': 'D', '\u010f': 'd', '\u0111': 'd',
|
||||||
|
'\u0112': 'E', '\u0114': 'E', '\u0116': 'E', '\u0118': 'E', '\u011a': 'E',
|
||||||
|
'\u0113': 'e', '\u0115': 'e', '\u0117': 'e', '\u0119': 'e', '\u011b': 'e',
|
||||||
|
'\u011c': 'G', '\u011e': 'G', '\u0120': 'G', '\u0122': 'G',
|
||||||
|
'\u011d': 'g', '\u011f': 'g', '\u0121': 'g', '\u0123': 'g',
|
||||||
|
'\u0124': 'H', '\u0126': 'H', '\u0125': 'h', '\u0127': 'h',
|
||||||
|
'\u0128': 'I', '\u012a': 'I', '\u012c': 'I', '\u012e': 'I', '\u0130': 'I',
|
||||||
|
'\u0129': 'i', '\u012b': 'i', '\u012d': 'i', '\u012f': 'i', '\u0131': 'i',
|
||||||
|
'\u0134': 'J', '\u0135': 'j',
|
||||||
|
'\u0136': 'K', '\u0137': 'k', '\u0138': 'k',
|
||||||
|
'\u0139': 'L', '\u013b': 'L', '\u013d': 'L', '\u013f': 'L', '\u0141': 'L',
|
||||||
|
'\u013a': 'l', '\u013c': 'l', '\u013e': 'l', '\u0140': 'l', '\u0142': 'l',
|
||||||
|
'\u0143': 'N', '\u0145': 'N', '\u0147': 'N', '\u014a': 'N',
|
||||||
|
'\u0144': 'n', '\u0146': 'n', '\u0148': 'n', '\u014b': 'n',
|
||||||
|
'\u014c': 'O', '\u014e': 'O', '\u0150': 'O',
|
||||||
|
'\u014d': 'o', '\u014f': 'o', '\u0151': 'o',
|
||||||
|
'\u0154': 'R', '\u0156': 'R', '\u0158': 'R',
|
||||||
|
'\u0155': 'r', '\u0157': 'r', '\u0159': 'r',
|
||||||
|
'\u015a': 'S', '\u015c': 'S', '\u015e': 'S', '\u0160': 'S',
|
||||||
|
'\u015b': 's', '\u015d': 's', '\u015f': 's', '\u0161': 's',
|
||||||
|
'\u0162': 'T', '\u0164': 'T', '\u0166': 'T',
|
||||||
|
'\u0163': 't', '\u0165': 't', '\u0167': 't',
|
||||||
|
'\u0168': 'U', '\u016a': 'U', '\u016c': 'U', '\u016e': 'U', '\u0170': 'U', '\u0172': 'U',
|
||||||
|
'\u0169': 'u', '\u016b': 'u', '\u016d': 'u', '\u016f': 'u', '\u0171': 'u', '\u0173': 'u',
|
||||||
|
'\u0174': 'W', '\u0175': 'w',
|
||||||
|
'\u0176': 'Y', '\u0177': 'y', '\u0178': 'Y',
|
||||||
|
'\u0179': 'Z', '\u017b': 'Z', '\u017d': 'Z',
|
||||||
|
'\u017a': 'z', '\u017c': 'z', '\u017e': 'z',
|
||||||
|
// normally IJ/ij
|
||||||
|
'\u0132': 'I', '\u0133': 'i',
|
||||||
|
// normally OE/oe
|
||||||
|
'\u0152': 'E', '\u0153': 'e',
|
||||||
|
// normally "'n"
|
||||||
|
'\u0149': "n",
|
||||||
|
'\u017f': 's'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deburr = (str: string) => {
|
||||||
|
return str
|
||||||
|
.replace(reLatin, (key: string) => {
|
||||||
|
return deburredLetters[key as keyof typeof deburredLetters] || key;
|
||||||
|
})
|
||||||
|
.replace(reComboMark, "");
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user