Compare commits
38 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 | |||
| 0513b647ec | |||
| a289c42830 | |||
| d67eaa8710 |
+2
-1
@@ -25,4 +25,5 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
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
|
||||
"use client";
|
||||
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||
|
||||
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,
|
||||
},]));
|
||||
return (
|
||||
<div style={{height:"500px", width:"500px"}}
|
||||
<div style={{height:"500px", width:"500px"}}>
|
||||
<Excalidraw />
|
||||
</div>
|
||||
);
|
||||
|
||||
+311
-71
@@ -1,6 +1,6 @@
|
||||
import polyfill from "../packages/excalidraw/polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../packages/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "../packages/excalidraw/appState";
|
||||
import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "../packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
@@ -48,13 +47,13 @@ import {
|
||||
} from "../packages/excalidraw/utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
isExcalidrawPlusSignedUser,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
import Collab, {
|
||||
CollabAPI,
|
||||
collabAPIAtom,
|
||||
collabDialogShownAtom,
|
||||
isCollaboratingAtom,
|
||||
isOfflineAtom,
|
||||
} from "./collab/Collab";
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
loadScene,
|
||||
} from "./data";
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
@@ -83,7 +81,11 @@ import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
@@ -104,6 +106,21 @@ import { ShareableLinkDialog } from "../packages/excalidraw/components/Shareable
|
||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../packages/excalidraw/components/Trans";
|
||||
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();
|
||||
|
||||
@@ -305,15 +322,18 @@ const ExcalidrawWrapper = () => {
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
return isCollaborationLink(window.location.href);
|
||||
});
|
||||
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
||||
adapter: LibraryIndexedDBAdapter,
|
||||
// TODO maybe remove this in several months (shipped: 24-03-11)
|
||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -443,8 +463,12 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.updateScene({
|
||||
...localDataState,
|
||||
});
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
if (data) {
|
||||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: data.libraryItems,
|
||||
});
|
||||
}
|
||||
});
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
@@ -607,37 +631,38 @@ const ExcalidrawWrapper = () => {
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => {
|
||||
if (exportedElements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
viewBackgroundColor: appState.exportBackground
|
||||
? appState.viewBackgroundColor
|
||||
: getDefaultAppState().viewBackgroundColor,
|
||||
},
|
||||
files,
|
||||
);
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
viewBackgroundColor: appState.exportBackground
|
||||
? appState.viewBackgroundColor
|
||||
: getDefaultAppState().viewBackgroundColor,
|
||||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
console.error(error, { width, height });
|
||||
throw new Error(error.message);
|
||||
}
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = appState;
|
||||
console.error(error, {
|
||||
width,
|
||||
height,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
});
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -655,17 +680,13 @@ 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 onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -685,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 (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
@@ -703,27 +763,30 @@ const ExcalidrawWrapper = () => {
|
||||
toggleTheme: true,
|
||||
export: {
|
||||
onExportToBackend,
|
||||
renderCustomUI: (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
renderCustomUI: excalidrawAPI
|
||||
? (elements, appState, files) => {
|
||||
return (
|
||||
<ExportToExcalidrawPlus
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
files={files}
|
||||
name={excalidrawAPI.getName()}
|
||||
onError={(error) => {
|
||||
excalidrawAPI?.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
excalidrawAPI.updateScene({
|
||||
appState: { openDialog: null },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -731,7 +794,6 @@ const ExcalidrawWrapper = () => {
|
||||
renderCustomStats={renderCustomStats}
|
||||
detectScroll={false}
|
||||
handleKeyboardGlobally={true}
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
@@ -739,20 +801,25 @@ const ExcalidrawWrapper = () => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => setCollabDialogShown(true)}
|
||||
/>
|
||||
<div className="top-right-ui">
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<OverwriteConfirmDialog>
|
||||
@@ -767,6 +834,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
excalidrawAPI.getName(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -848,11 +916,183 @@ const ExcalidrawWrapper = () => {
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
|
||||
<ShareDialog
|
||||
collabAPI={collabAPI}
|
||||
onExportToBackend={async () => {
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
await onExportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { t } from "../../packages/excalidraw/i18n";
|
||||
import { UserIdleState } from "../../packages/excalidraw/types";
|
||||
import {
|
||||
@@ -77,23 +76,27 @@ import {
|
||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string;
|
||||
errorMessage: string | null;
|
||||
/** errors related to saving */
|
||||
dialogNotifiedErrors: Record<string, boolean>;
|
||||
username: string;
|
||||
activeRoomLink: string;
|
||||
activeRoomLink: string | null;
|
||||
}
|
||||
|
||||
export const activeRoomLinkAtom = atom<string | null>(null);
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
|
||||
export interface CollabAPI {
|
||||
@@ -104,19 +107,20 @@ export interface CollabAPI {
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
getUsername: CollabInstance["getUsername"];
|
||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||
setCollabError: CollabInstance["setErrorDialog"];
|
||||
}
|
||||
|
||||
interface PublicProps {
|
||||
interface CollabProps {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}
|
||||
|
||||
type Props = PublicProps & { modalIsShown: boolean };
|
||||
|
||||
class Collab extends PureComponent<Props, CollabState> {
|
||||
class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
excalidrawAPI: CollabProps["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
@@ -124,12 +128,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<SocketId, Collaborator>();
|
||||
|
||||
constructor(props: Props) {
|
||||
constructor(props: CollabProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
errorMessage: "",
|
||||
errorMessage: null,
|
||||
dialogNotifiedErrors: {},
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: "",
|
||||
activeRoomLink: null,
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
@@ -194,6 +199,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
getUsername: this.getUsername,
|
||||
getActiveRoomLink: this.getActiveRoomLink,
|
||||
setCollabError: this.setErrorDialog,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@@ -272,18 +280,35 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
this.resetErrorIndicator();
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.setState({
|
||||
// firestore doesn't return a specific error code when size exceeded
|
||||
errorMessage: /is longer than.*?bytes/.test(error.message)
|
||||
? t("errors.collabSaveFailed_sizeExceeded")
|
||||
: t("errors.collabSaveFailed"),
|
||||
});
|
||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||
? 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);
|
||||
}
|
||||
};
|
||||
@@ -292,6 +317,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
this.resetErrorIndicator(true);
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
@@ -341,9 +367,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.fileManager.reset();
|
||||
if (!opts?.isUnload) {
|
||||
this.setIsCollaborating(false);
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.setActiveRoomLink(null);
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
@@ -409,7 +433,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.onUsernameChange(username);
|
||||
this.setUsername(username);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -462,7 +486,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
this.setErrorDialog(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -624,9 +648,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
this.initializeIdleDetector();
|
||||
|
||||
this.setState({
|
||||
activeRoomLink: window.location.href,
|
||||
});
|
||||
this.setActiveRoomLink(window.location.href);
|
||||
|
||||
return scenePromise;
|
||||
};
|
||||
@@ -909,41 +931,49 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
{ leading: false },
|
||||
);
|
||||
|
||||
handleClose = () => {
|
||||
appJotaiStore.set(collabDialogShownAtom, false);
|
||||
};
|
||||
|
||||
setUsername = (username: string) => {
|
||||
this.setState({ username });
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
this.setUsername(username);
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { username, errorMessage, activeRoomLink } = this.state;
|
||||
getUsername = () => this.state.username;
|
||||
|
||||
const { modalIsShown } = this.props;
|
||||
setActiveRoomLink = (activeRoomLink: string | null) => {
|
||||
this.setState({ activeRoomLink });
|
||||
appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
|
||||
};
|
||||
|
||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||
|
||||
setErrorIndicator = (errorMessage: string | null) => {
|
||||
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() {
|
||||
const { errorMessage } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<RoomDialog
|
||||
handleClose={this.handleClose}
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onRoomCreate={() => this.startCollaboration(null)}
|
||||
onRoomDestroy={this.stopCollaboration}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
this.setState({ errorMessage });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => this.setState({ errorMessage: "" })}>
|
||||
{errorMessage != null && (
|
||||
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
@@ -962,11 +992,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
const _Collab: React.FC<PublicProps> = (props) => {
|
||||
const [collabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
return <Collab {...props} modalIsShown={collabDialogShown} />;
|
||||
};
|
||||
|
||||
export default _Collab;
|
||||
export default Collab;
|
||||
|
||||
export type TCollabClass = Collab;
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
@@ -120,7 +119,7 @@ export const RoomModal = ({
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
startIcon={getShareIcon()}
|
||||
icon={getShareIcon()}
|
||||
className="RoomDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
@@ -130,7 +129,7 @@ export const RoomModal = ({
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
startIcon={copyIcon}
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
@@ -166,7 +165,7 @@ export const RoomModal = ({
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
startIcon={playerStopFilledIcon}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
onRoomDestroy();
|
||||
@@ -195,7 +194,7 @@ export const RoomModal = ({
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
startIcon={playerPlayIcon}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
onRoomCreate();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
@@ -17,10 +17,10 @@ export const AppMainMenu: React.FC<{
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.CommandPalette />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
onCollabDialogOpen: () => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
@@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
onSelect={() => props.onCollabDialogOpen()}
|
||||
/>
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
|
||||
@@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
name: string,
|
||||
) => {
|
||||
const firebase = await loadFirebaseStorage();
|
||||
|
||||
@@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
|
||||
.ref(`/migrations/scenes/${id}`)
|
||||
.put(blob, {
|
||||
customMetadata: {
|
||||
data: JSON.stringify({ version: 2, name: appState.name }),
|
||||
data: JSON.stringify({ version: 2, name }),
|
||||
created: Date.now().toString(),
|
||||
},
|
||||
});
|
||||
@@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: Partial<AppState>;
|
||||
files: BinaryFiles;
|
||||
name: string;
|
||||
onError: (error: Error) => void;
|
||||
onSuccess: () => void;
|
||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
||||
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Card color="primary">
|
||||
@@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||
await exportToExcalidrawPlus(elements, appState, files);
|
||||
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
@@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
|
||||
|
||||
window.open(
|
||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||
"_blank",
|
||||
"noopener noreferrer",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,18 @@
|
||||
* (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 { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
@@ -22,6 +32,7 @@ import {
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
} from "../../packages/excalidraw/types";
|
||||
import { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||
import { debounce } from "../../packages/excalidraw/utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
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";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
@@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
|
||||
try {
|
||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
||||
|
||||
const appStateSize = appState?.length || 0;
|
||||
const collabSize = collab?.length || 0;
|
||||
const librarySize = library?.length || 0;
|
||||
|
||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
||||
return appStateSize + collabSize + getElementsStorageSize();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
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>
|
||||
<!------------------------------------------------------------------------->
|
||||
<% if ("%PROD%" === "true") { %>
|
||||
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||
<script>
|
||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||
//
|
||||
@@ -122,7 +122,8 @@
|
||||
/>
|
||||
|
||||
<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>
|
||||
{
|
||||
const _WebSocket = window.WebSocket;
|
||||
@@ -196,7 +197,8 @@
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<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 -->
|
||||
<script>
|
||||
// need to load this script dynamically bcs. of iframe embed tracking
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
&.theme--dark {
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog {
|
||||
.ShareDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
@@ -10,8 +10,25 @@
|
||||
height: calc(100vh - 5rem);
|
||||
}
|
||||
|
||||
&__separator {
|
||||
border-top: 1px solid var(--dialog-border-color);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 1em;
|
||||
|
||||
span {
|
||||
background: var(--island-bg-color);
|
||||
padding: 0px 0.75rem;
|
||||
transform: translateY(-1ch);
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@keyframes RoomDialog__popover__scaleIn {
|
||||
@keyframes ShareDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -50,10 +67,10 @@
|
||||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
||||
animation: ShareDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__inactive {
|
||||
&__picker {
|
||||
font-family: "Assistant";
|
||||
|
||||
&__illustration {
|
||||
@@ -95,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__start_session {
|
||||
&__button {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||
import { getFrame } from "../../packages/excalidraw/utils";
|
||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||
import { KEYS } from "../../packages/excalidraw/keys";
|
||||
import { Dialog } from "../../packages/excalidraw/components/Dialog";
|
||||
import {
|
||||
copyIcon,
|
||||
LinkIcon,
|
||||
playerPlayIcon,
|
||||
playerStopFilledIcon,
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
tablerCheckIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||
|
||||
type OnExportToBackend = () => void;
|
||||
type ShareDialogType = "share" | "collaborationOnly";
|
||||
|
||||
export const shareDialogStateAtom = atom<
|
||||
{ isOpen: false } | { isOpen: true; type: ShareDialogType }
|
||||
>({ isOpen: false });
|
||||
|
||||
const getShareIcon = () => {
|
||||
const navigator = window.navigator as any;
|
||||
const isAppleBrowser = /Apple/.test(navigator.vendor);
|
||||
const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
|
||||
|
||||
if (isAppleBrowser) {
|
||||
return shareIOS;
|
||||
} else if (isWindowsBrowser) {
|
||||
return shareWindows;
|
||||
}
|
||||
|
||||
return share;
|
||||
};
|
||||
|
||||
export type ShareDialogProps = {
|
||||
collabAPI: CollabAPI | null;
|
||||
handleClose: () => void;
|
||||
onExportToBackend: OnExportToBackend;
|
||||
type: ShareDialogType;
|
||||
};
|
||||
|
||||
const ActiveRoomDialog = ({
|
||||
collabAPI,
|
||||
activeRoomLink,
|
||||
handleClose,
|
||||
}: {
|
||||
collabAPI: CollabAPI;
|
||||
activeRoomLink: string;
|
||||
handleClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isShareSupported = "share" in navigator;
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
} catch (e) {
|
||||
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
const shareRoomLink = async () => {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: t("roomDialog.shareTitle"),
|
||||
text: t("roomDialog.shareTitle"),
|
||||
url: activeRoomLink,
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Just ignore.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="ShareDialog__active__header">
|
||||
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||
</h3>
|
||||
<TextField
|
||||
defaultValue={collabAPI.getUsername()}
|
||||
placeholder="Your name"
|
||||
label="Your name"
|
||||
onChange={collabAPI.setUsername}
|
||||
onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
|
||||
/>
|
||||
<div className="ShareDialog__active__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={activeRoomLink}
|
||||
/>
|
||||
{isShareSupported && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="icon"
|
||||
label="Share"
|
||||
icon={getShareIcon()}
|
||||
className="ShareDialog__active__share"
|
||||
onClick={shareRoomLink}
|
||||
/>
|
||||
)}
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className="ShareDialog__active__description__emoji"
|
||||
>
|
||||
🔒{" "}
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__active__actions">
|
||||
<FilledButton
|
||||
size="large"
|
||||
variant="outlined"
|
||||
color="danger"
|
||||
label={t("roomDialog.button_stopSession")}
|
||||
icon={playerStopFilledIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room closed");
|
||||
collabAPI.stopCollaboration();
|
||||
if (!collabAPI.isCollaborating()) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { collabAPI } = props;
|
||||
|
||||
const startCollabJSX = collabAPI ? (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
{t("labels.liveCollaboration").replace(/\./g, "")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__description">
|
||||
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_startSession")}
|
||||
icon={playerPlayIcon}
|
||||
onClick={() => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
collabAPI.startCollaboration(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{props.type === "share" && (
|
||||
<div className="ShareDialog__separator">
|
||||
<span>{t("shareDialog.or")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{startCollabJSX}
|
||||
|
||||
{props.type === "share" && (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
{t("exportDialog.link_title")}
|
||||
</div>
|
||||
<div className="ShareDialog__picker__description">
|
||||
{t("exportDialog.link_details")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("exportDialog.link_button")}
|
||||
icon={LinkIcon}
|
||||
onClick={async () => {
|
||||
await props.onExportToBackend();
|
||||
props.handleClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ShareDialogInner = (props: ShareDialogProps) => {
|
||||
const activeRoomLink = useAtomValue(activeRoomLinkAtom);
|
||||
|
||||
return (
|
||||
<Dialog size="small" onCloseRequest={props.handleClose} title={false}>
|
||||
<div className="ShareDialog">
|
||||
{props.collabAPI && activeRoomLink ? (
|
||||
<ActiveRoomDialog
|
||||
collabAPI={props.collabAPI}
|
||||
activeRoomLink={activeRoomLink}
|
||||
handleClose={props.handleClose}
|
||||
/>
|
||||
) : (
|
||||
<ShareDialogPicker {...props} />
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareDialog = (props: {
|
||||
collabAPI: CollabAPI | null;
|
||||
onExportToBackend: OnExportToBackend;
|
||||
}) => {
|
||||
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
|
||||
const { openDialog } = useUIAppState();
|
||||
|
||||
useEffect(() => {
|
||||
if (openDialog) {
|
||||
setShareDialogState({ isOpen: false });
|
||||
}
|
||||
}, [openDialog, setShareDialogState]);
|
||||
|
||||
if (!shareDialogState.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShareDialogInner
|
||||
handleClose={() => setShareDialogState({ isOpen: false })}
|
||||
collabAPI={props.collabAPI}
|
||||
onExportToBackend={props.onExportToBackend}
|
||||
type={shareDialogState.type}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr";
|
||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
|
||||
// To load .env.local variables
|
||||
const envVars = loadEnv("", `../`);
|
||||
@@ -189,6 +190,9 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
}),
|
||||
createHtmlPlugin({
|
||||
minify: true,
|
||||
}),
|
||||
],
|
||||
publicDir: "../public",
|
||||
});
|
||||
|
||||
@@ -15,14 +15,24 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### 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).
|
||||
|
||||
- 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
|
||||
|
||||
- `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
|
||||
|
||||
#### 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)
|
||||
|
||||
## 0.17.1 (2023-11-28)
|
||||
## 0.17.3 (2024-02-09)
|
||||
|
||||
### 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.
|
||||
|
||||
```
|
||||
@@ -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
|
||||
|
||||
### 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 { KEYS } from "../keys";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
@@ -59,6 +59,8 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
label: "labels.alignTop",
|
||||
icon: AlignTopIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -90,6 +92,8 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
label: "labels.alignBottom",
|
||||
icon: AlignBottomIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -121,6 +125,8 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
label: "labels.alignLeft",
|
||||
icon: AlignLeftIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -152,6 +158,8 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
label: "labels.alignRight",
|
||||
icon: AlignRightIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -183,6 +191,8 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
label: "labels.centerVertically",
|
||||
icon: CenterVerticallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -210,6 +220,8 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
label: "labels.centerHorizontally",
|
||||
icon: CenterHorizontallyIcon,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState, _, app) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
label: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -49,7 +49,7 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
const { width, height } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
@@ -58,12 +58,15 @@ export const actionUnbindText = register({
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
x,
|
||||
y,
|
||||
@@ -88,7 +91,7 @@ export const actionUnbindText = register({
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
label: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -145,7 +148,11 @@ export const actionBindText = register({
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
@@ -196,7 +203,7 @@ const pushContainerBelowText = (
|
||||
|
||||
export const actionWrapTextInContainer = register({
|
||||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
label: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -286,7 +293,11 @@ export const actionWrapTextInContainer = register({
|
||||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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 { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
@@ -25,6 +34,8 @@ import { setCursor } from "../cursor";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
paletteName: "Change canvas background color",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@@ -59,6 +70,9 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
label: "labels.clearCanvas",
|
||||
paletteName: "Clear canvas",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@@ -95,7 +109,9 @@ export const actionClearCanvas = register({
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
label: "buttons.zoomIn",
|
||||
viewMode: true,
|
||||
icon: ZoomInIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@@ -133,6 +149,8 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
label: "buttons.zoomOut",
|
||||
icon: ZoomOutIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@@ -171,6 +189,8 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
label: "buttons.resetZoom",
|
||||
icon: ZoomResetIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
@@ -340,6 +360,8 @@ export const zoomToFit = ({
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
label: "labels.zoomToFitViewport",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -363,6 +385,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
label: "helpDialog.zoomToSelection",
|
||||
icon: zoomAreaIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@@ -385,6 +409,8 @@ export const actionZoomToFitSelection = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
label: "helpDialog.zoomToFit",
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
@@ -405,6 +431,11 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
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,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
@@ -425,6 +456,7 @@ export const actionToggleTheme = register({
|
||||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
@@ -459,7 +491,11 @@ export const actionToggleEraserTool = register({
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
label: "toolBar.hand",
|
||||
paletteName: "Toggle hand tool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: handIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
import { isFirefox } from "../constants";
|
||||
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
label: "labels.copy",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
@@ -40,13 +43,13 @@ export const actionCopy = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
label: "labels.paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, data, app) => {
|
||||
let types;
|
||||
@@ -97,24 +100,26 @@ export const actionPaste = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
label: "labels.cut",
|
||||
icon: cutIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, event: ClipboardEvent | null, 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,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
label: "labels.copyAsSvg",
|
||||
icon: svgIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
@@ -138,6 +143,7 @@ export const actionCopyAsSvg = register({
|
||||
{
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
},
|
||||
);
|
||||
return {
|
||||
@@ -157,11 +163,13 @@ export const actionCopyAsSvg = register({
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
keywords: ["svg", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
label: "labels.copyAsPng",
|
||||
icon: pngIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
@@ -184,6 +192,7 @@ export const actionCopyAsPng = register({
|
||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||
...appState,
|
||||
exportingFrame,
|
||||
name: app.getName(),
|
||||
});
|
||||
return {
|
||||
appState: {
|
||||
@@ -215,12 +224,13 @@ export const actionCopyAsPng = register({
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
keywords: ["png", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
label: "labels.copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@@ -236,7 +246,11 @@ export const copyText = register({
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
try {
|
||||
copyTextToSystemClipboard(text);
|
||||
} catch (e) {
|
||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -252,5 +266,5 @@ export const copyText = register({
|
||||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
keywords: ["text", "clipboard", "copy"],
|
||||
});
|
||||
|
||||
@@ -72,8 +72,10 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
label: "labels.delete",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
elementId,
|
||||
@@ -81,7 +83,8 @@ export const actionDeleteSelected = register({
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
@@ -167,7 +170,6 @@ export const actionDeleteSelected = register({
|
||||
),
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
keyTest: (event, appState, elements) =>
|
||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
|
||||
@@ -49,6 +49,7 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
label: "labels.distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@@ -79,6 +80,7 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
label: "labels.distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
|
||||
@@ -34,11 +34,17 @@ import {
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
label: "labels.duplicateSelection",
|
||||
icon: DuplicateIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
return false;
|
||||
@@ -56,7 +62,6 @@ export const actionDuplicateSelection = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.duplicateSelection",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isFrameLikeElement } from "../element/typeChecks";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -10,11 +12,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
|
||||
export const actionToggleElementLock = register({
|
||||
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" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
!selectedElements.some((element) => element.locked && element.frameId)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -47,21 +69,6 @@ export const actionToggleElementLock = register({
|
||||
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) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
@@ -77,10 +84,16 @@ export const actionToggleElementLock = register({
|
||||
|
||||
export const actionUnlockAllElements = register({
|
||||
name: "unlockAllElements",
|
||||
paletteName: "Unlock all elements",
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
predicate: (elements) => {
|
||||
return elements.some((element) => element.locked);
|
||||
icon: UnlockedIcon,
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 0 &&
|
||||
elements.some((element) => element.locked)
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
@@ -101,5 +114,5 @@ export const actionUnlockAllElements = register({
|
||||
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 { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@@ -22,18 +22,16 @@ import "../components/ToolIcon.scss";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
label: "labels.fileTitle",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
||||
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || "Unnamed"}
|
||||
value={app.getName()}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
isNameEditable={
|
||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
||||
}
|
||||
ignoreFocus={data?.ignoreFocus ?? false}
|
||||
/>
|
||||
),
|
||||
@@ -41,6 +39,7 @@ export const actionChangeProjectName = register({
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
label: "imageExportDialog.scale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
@@ -90,6 +89,7 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
label: "imageExportDialog.label.withBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
@@ -109,6 +109,7 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
label: "imageExportDialog.tooltip.embedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
@@ -131,6 +132,8 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
label: "buttons.save",
|
||||
icon: ExportIcon,
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@@ -144,8 +147,13 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
try {
|
||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
||||
: await saveAsJSON(elements, appState, app.files);
|
||||
? await resaveAsImageWithScene(
|
||||
elements,
|
||||
appState,
|
||||
app.files,
|
||||
app.getName(),
|
||||
)
|
||||
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -179,6 +187,8 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
label: "exportDialog.disk_title",
|
||||
icon: ExportIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
@@ -190,6 +200,7 @@ export const actionSaveFileToDisk = register({
|
||||
fileHandle: null,
|
||||
},
|
||||
app.files,
|
||||
app.getName(),
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@@ -227,6 +238,7 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
label: "buttons.load",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@@ -264,6 +276,7 @@ export const actionLoadScene = register({
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
label: "imageExportDialog.label.darkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { KEYS } from "../keys";
|
||||
import { isInvisiblySmallElement } from "../element";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { arrayToMap, updateActiveTool } from "../utils";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
@@ -19,6 +19,7 @@ import { resetCursor } from "../cursor";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
@@ -26,10 +27,12 @@ export const actionFinalize = register({
|
||||
_,
|
||||
{ interactiveCanvas, focusContainer, scene },
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
if (isBindingElement(element)) {
|
||||
@@ -37,6 +40,7 @@ export const actionFinalize = register({
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -125,12 +129,14 @@ export const actionFinalize = register({
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
Scene.getScene(multiPointElement)!,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -186,7 +192,7 @@ export const actionFinalize = register({
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
? new LinearElementEditor(multiPointElement)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getNonDeletedElements } from "../element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { resizeMultipleElements } from "../element/resizeElements";
|
||||
@@ -18,9 +17,12 @@ import {
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
label: "labels.flipHorizontal",
|
||||
icon: flipHorizontal,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@@ -39,11 +41,12 @@ export const actionFlipHorizontal = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
});
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
label: "labels.flipVertical",
|
||||
icon: flipVertical,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
@@ -63,12 +66,11 @@ export const actionFlipVertical = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
});
|
||||
|
||||
const flipSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
) => {
|
||||
@@ -83,6 +85,7 @@ const flipSelectedElements = (
|
||||
|
||||
const updatedElements = flipElements(
|
||||
selectedElements,
|
||||
elements,
|
||||
elementsMap,
|
||||
appState,
|
||||
flipDirection,
|
||||
@@ -97,7 +100,8 @@ const flipSelectedElements = (
|
||||
|
||||
const flipElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
): ExcalidrawElement[] => {
|
||||
@@ -113,9 +117,9 @@ const flipElements = (
|
||||
flipDirection === "horizontal" ? minY : maxY,
|
||||
);
|
||||
|
||||
(isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements
|
||||
: unbindLinearElements)(selectedElements);
|
||||
isBindingEnabled(appState)
|
||||
? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
|
||||
: unbindLinearElements(selectedElements, elementsMap);
|
||||
|
||||
return selectedElements;
|
||||
};
|
||||
|
||||
@@ -3,13 +3,17 @@ import { ExcalidrawElement } from "../element/types";
|
||||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameChildren } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
import { updateActiveTool } from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { register } from "./register";
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -19,6 +23,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
label: "labels.selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
@@ -49,13 +54,13 @@ export const actionSelectAllElementsInFrame = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
label: "labels.removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElement =
|
||||
@@ -80,13 +85,13 @@ export const actionRemoveAllElementsFromFrame = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
label: "labels.updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
@@ -102,13 +107,15 @@ export const actionupdateFrameRendering = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
label: "toolBar.frame",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: frameToolIcon,
|
||||
viewMode: false,
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
|
||||
@@ -61,6 +61,8 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
label: "labels.group",
|
||||
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
@@ -157,7 +159,6 @@ export const actionGroup = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
keyTest: (event) =>
|
||||
@@ -177,9 +178,13 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
label: "labels.ungroup",
|
||||
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
@@ -226,7 +231,12 @@ export const actionUngroup = register({
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
getElementsInResizingFrame(
|
||||
nextElements,
|
||||
frame,
|
||||
appState,
|
||||
elementsMap,
|
||||
),
|
||||
frame,
|
||||
app,
|
||||
);
|
||||
@@ -256,7 +266,6 @@ export const actionUngroup = register({
|
||||
event.shiftKey &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
contextItemLabel: "labels.ungroup",
|
||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
|
||||
@@ -63,7 +63,10 @@ type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: (event) =>
|
||||
@@ -84,7 +87,10 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: (event) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
@@ -5,6 +6,16 @@ import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
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: {
|
||||
category: "element",
|
||||
},
|
||||
@@ -24,7 +35,7 @@ export const actionToggleLinearEditor = register({
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, app.scene);
|
||||
: new LinearElementEditor(selectedElement);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -33,13 +44,4 @@ export const actionToggleLinearEditor = register({
|
||||
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 { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
@@ -7,6 +7,7 @@ import { KEYS } from "../keys";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
label: "buttons.menu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
@@ -28,6 +29,7 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
label: "buttons.edit",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
@@ -53,6 +55,8 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
label: "welcomeScreen.defaults.helpHint",
|
||||
icon: HelpIconThin,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
label: "Go to a collaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||
@@ -39,14 +45,45 @@ export const actionGoToCollaborator = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
||||
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||
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 ? (
|
||||
<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)}
|
||||
>
|
||||
<Avatar
|
||||
@@ -54,32 +91,42 @@ export const actionGoToCollaborator = register({
|
||||
onClick={() => {}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
<div className="UserList__collaborator-name">
|
||||
{collaborator.username}
|
||||
</div>
|
||||
<div
|
||||
className="UserList__collaborator-follow-status-icon"
|
||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
{eyeIcon}
|
||||
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||
{isBeingFollowed && (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-is-followed"
|
||||
title={t("userList.hint.followStatus")}
|
||||
>
|
||||
{eyeIcon}
|
||||
</div>
|
||||
)}
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
<div
|
||||
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
{statusIconJSX && (
|
||||
<div className="UserList__collaborator-status-icon">
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
ArrowheadCircleOutlineIcon,
|
||||
ArrowheadDiamondIcon,
|
||||
ArrowheadDiamondOutlineIcon,
|
||||
fontSizeIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
@@ -209,6 +210,7 @@ const changeFontSize = (
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
@@ -237,6 +239,7 @@ const changeFontSize = (
|
||||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
label: "labels.stroke",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -287,6 +290,7 @@ export const actionChangeStrokeColor = register({
|
||||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -330,6 +334,7 @@ export const actionChangeBackgroundColor = register({
|
||||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
label: "labels.fill",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
trackEvent(
|
||||
@@ -407,6 +412,7 @@ export const actionChangeFillStyle = register({
|
||||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
label: "labels.strokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -460,6 +466,7 @@ export const actionChangeStrokeWidth = register({
|
||||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
label: "labels.sloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -511,6 +518,7 @@ export const actionChangeSloppiness = register({
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
label: "labels.strokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -561,6 +569,7 @@ export const actionChangeStrokeStyle = register({
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
label: "labels.opacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -602,6 +611,7 @@ export const actionChangeOpacity = register({
|
||||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
label: "labels.fontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, () => value, value);
|
||||
@@ -672,6 +682,8 @@ export const actionChangeFontSize = register({
|
||||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
label: "labels.decreaseFontSize",
|
||||
icon: fontSizeIcon,
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
@@ -694,6 +706,8 @@ export const actionDecreaseFontSize = register({
|
||||
|
||||
export const actionIncreaseFontSize = register({
|
||||
name: "increaseFontSize",
|
||||
label: "labels.increaseFontSize",
|
||||
icon: fontSizeIcon,
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, (element) =>
|
||||
@@ -712,6 +726,7 @@ export const actionIncreaseFontSize = register({
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
@@ -730,6 +745,7 @@ export const actionChangeFontFamily = register({
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
@@ -814,6 +830,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
label: "Change text alignment",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
@@ -829,6 +846,7 @@ export const actionChangeTextAlign = register({
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
@@ -902,6 +920,7 @@ export const actionChangeTextAlign = register({
|
||||
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
label: "Change vertical alignment",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
@@ -918,6 +937,7 @@ export const actionChangeVerticalAlign = register({
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
@@ -990,6 +1010,7 @@ export const actionChangeVerticalAlign = register({
|
||||
|
||||
export const actionChangeRoundness = register({
|
||||
name: "changeRoundness",
|
||||
label: "Change edge roundness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
@@ -1128,6 +1149,7 @@ const getArrowheadOptions = (flip: boolean) => {
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
label: "Change arrowheads",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
|
||||
@@ -6,10 +6,14 @@ import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
label: "labels.selectAll",
|
||||
icon: selectAllIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@@ -43,12 +47,11 @@ export const actionSelectAll = register({
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
? new LinearElementEditor(elements[0])
|
||||
: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAll",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||
});
|
||||
|
||||
@@ -25,12 +25,15 @@ import {
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
label: "labels.copyStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = [];
|
||||
@@ -54,13 +57,14 @@ export const actionCopyStyles = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
label: "labels.pasteStyles",
|
||||
icon: paintIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
const elementsCopied = JSON.parse(copiedStyles);
|
||||
@@ -128,7 +132,11 @@ export const actionPasteStyles = register({
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
redrawTextBoundingBox(newElement, container);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -155,7 +163,6 @@ export const actionPasteStyles = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppState } from "../types";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
label: "labels.showGrid",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@@ -24,6 +25,5 @@ export const actionToggleGridMode = register({
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.showGrid",
|
||||
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 { register } from "./register";
|
||||
|
||||
export const actionToggleObjectsSnapMode = register({
|
||||
name: "objectsSnapMode",
|
||||
viewMode: true,
|
||||
label: "buttons.objectsSnapMode",
|
||||
icon: magnetIcon,
|
||||
viewMode: false,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||
@@ -22,7 +25,6 @@ export const actionToggleObjectsSnapMode = register({
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.objectsSnapMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { register } from "./register";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { abacusIcon } from "../components/icons";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.title",
|
||||
icon: abacusIcon,
|
||||
paletteName: "Toggle stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
@@ -15,7 +19,6 @@ export const actionToggleStats = register({
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
keyTest: (event) =>
|
||||
!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 { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
label: "labels.viewMode",
|
||||
paletteName: "Toggle view mode",
|
||||
icon: eyeIcon,
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@@ -21,7 +25,6 @@ export const actionToggleViewMode = register({
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.viewModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!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 { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
label: "buttons.zenMode",
|
||||
icon: coffeeIcon,
|
||||
paletteName: "Toggle zen mode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
@@ -21,7 +25,6 @@ export const actionToggleZenMode = register({
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.zenModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import { isDarwin } from "../constants";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
label: "labels.sendBackward",
|
||||
icon: SendBackwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
@@ -27,7 +29,6 @@ export const actionSendBackward = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendBackward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -47,6 +48,8 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
label: "labels.bringForward",
|
||||
icon: BringForwardIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
@@ -55,7 +58,6 @@ export const actionBringForward = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringForward",
|
||||
keyPriority: 40,
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -75,6 +77,8 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
label: "labels.sendToBack",
|
||||
icon: SendToBackIcon,
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
@@ -83,7 +87,6 @@ export const actionSendToBack = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.sendToBack",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -110,6 +113,8 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
label: "labels.bringToFront",
|
||||
icon: BringToFrontIcon,
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
@@ -119,7 +124,6 @@ export const actionBringToFront = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.bringToFront",
|
||||
keyTest: (event) =>
|
||||
isDarwin
|
||||
? event[KEYS.CTRL_OR_CMD] &&
|
||||
|
||||
@@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
@@ -55,7 +56,7 @@ export class ActionManager {
|
||||
app: AppClassProperties,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
if (isPromiseLike(actionResult)) {
|
||||
actionResult.then((actionResult) => {
|
||||
return updater(actionResult);
|
||||
});
|
||||
|
||||
@@ -36,9 +36,22 @@ export type ShortcutName =
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleElementLock"
|
||||
| "resetZoom"
|
||||
| "zoomOut"
|
||||
| "zoomIn"
|
||||
| "zoomToFit"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "zoomToFitSelection"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "setFrameAsActiveTool"
|
||||
| "saveFileToDisk"
|
||||
| "saveToActiveFile"
|
||||
| "toggleShortcuts"
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport";
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@@ -46,6 +59,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||
commandPalette: [
|
||||
getShortcutKey("CtrlOrCmd+/"),
|
||||
getShortcutKey("CtrlOrCmd+Shift+P"),
|
||||
],
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||
@@ -83,10 +100,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
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];
|
||||
// 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,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIAppState,
|
||||
} from "../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 */
|
||||
export type ActionResult =
|
||||
@@ -124,7 +130,8 @@ export type ActionName =
|
||||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -137,6 +144,20 @@ export type PanelComponentProps = {
|
||||
|
||||
export interface Action {
|
||||
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>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
@@ -146,13 +167,6 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
EXPORT_SCALES,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
|
||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||
? devicePixelRatio
|
||||
@@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit<
|
||||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
name: null,
|
||||
contextMenu: null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<svg width="178" height="162" viewBox="0 0 178 162" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.3329 54.3823L38.5547 94.3134L39.7731 111.754L40.1282 118.907L41.0832 123.59L44.3502 131.942L48.9438 137.693L52.5472 143.333L58.5544 147.755L62.5364 150.239L72.3634 154.486L83.15 156.361L91.1212 158.708L101.174 157.525L110.808 156.719L115.983 154.049L124.511 151.377L129.276 148.71L133.701 143.947L139.666 135.877L142.001 128.136L145.746 118.192L145.188 111.065L145.489 94.3675L145.873 75.2546L143.227 59.7779L142.022 47.4695L138.595 46.8345L102.952 45.4703L56.9173 46.7498L46.0719 49.1207L41.9323 50.6825L39.5684 53.4297" fill="#E3E2FE"/>
|
||||
<path d="M41.0014 54.2859C41.0861 64.8796 38.3765 102.581 40.9779 117.876C43.5793 133.17 48.2646 139.346 56.6121 146.047C64.9596 152.746 79.1214 157.662 91.0653 158.078C103.009 158.492 119.347 155.242 128.277 148.543C137.206 141.842 142.112 133.527 144.641 117.874C147.169 102.221 146.061 66.4132 143.446 54.6222C140.83 42.8289 143.97 48.2857 128.948 47.1238C113.925 45.9619 67.9608 46.477 53.3051 47.6483C38.6493 48.8197 43.2053 53.0675 41.0155 54.1518M40.5263 53.9801C40.5404 64.6138 37.9249 103.418 40.5921 118.587C43.257 133.755 48.147 138.325 56.5251 144.991C64.9008 151.655 78.7263 157.935 90.8536 158.577C102.981 159.221 120.212 155.413 129.289 148.844C138.368 142.277 142.872 134.995 145.321 119.168C147.767 103.343 146.805 65.8698 143.977 53.8837C141.148 41.8975 143.615 48.4292 128.348 47.2508C113.081 46.0724 67.14 45.6726 52.3737 46.8157C37.6074 47.9564 41.7776 53.091 39.7524 54.1024" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M64.7935 45.726L66.36 36.6964L65.5979 34.4501L67.3384 30.7668L70.4197 26.5048L74.8157 21.0598L81.9095 16.9131L89.9419 14.4951L95.6127 12.8534L97.7555 13.2133L103.819 15.269L106.552 18.5807L109.967 22.3793L114.45 27.2387L114.904 34.3937L117.153 38.9214L116.031 44.5005L116.765 45.5448L118.863 47.3559L126.164 47.6782L127.744 46.7797L128.76 44.7052L123.882 25.8227L120.641 20.4835L116.351 15.8758L112.809 11.0729L108.617 7.36601L101.352 5.43025L93.263 3.31104L84.2451 5.1433L77.7299 7.86229L76.0011 8.0975L62.1168 19.8532L59.5366 24.5597L55.7733 32.3215L55.0371 39.5282L56.0626 44.8933L56.5636 47.1725L59.6401 46.8644L66.1648 46.0388" fill="#E3E2FE"/>
|
||||
<path d="M65.0037 46.0106C65.1166 43.8231 64.9237 36.8492 66.1421 33.2412C67.3605 29.6307 69.0799 27.2857 72.314 24.3527C75.5481 21.422 81.273 17.6093 85.5444 15.6453C89.8157 13.6813 94.3317 12.1148 97.9421 12.5664C101.555 13.0157 104.544 15.857 107.219 18.3478C109.893 20.8387 112.356 24.0869 113.986 27.5115C115.618 30.9338 116.389 35.8684 117.006 38.8861C117.622 41.9038 115.992 44.2888 117.69 45.6178C119.388 46.949 125.617 48.1721 127.195 46.8667C128.773 45.5613 127.717 41.1888 127.157 37.7877C126.597 34.3866 125.494 30.1247 123.838 26.4601C122.183 22.7956 119.746 18.9029 117.222 15.7982C114.698 12.6934 112.791 9.9086 108.696 7.83642C104.601 5.76189 97.8081 3.42863 92.6547 3.35337C87.5013 3.2781 81.5529 5.74308 77.7708 7.38717C73.991 9.03127 72.8879 10.6166 69.9666 13.218C67.043 15.8217 62.4306 19.6768 60.2384 23.0026C58.0486 26.3284 57.4818 29.252 56.8185 33.1753C56.1529 37.0962 54.6499 44.39 56.2517 46.5327C57.8511 48.6731 64.7756 45.98 66.4267 46.0247M65.9704 45.5096C65.9845 43.348 64.2652 37.5525 65.5423 33.8456C66.8172 30.1364 70.2959 26.4789 73.6264 23.259C76.9546 20.039 81.3177 16.3015 85.5208 14.5281C89.7216 12.7523 95.1079 11.7903 98.8383 12.6111C102.569 13.432 105.283 16.8072 107.903 19.4486C110.526 22.09 113.146 25.3029 114.567 28.4664C115.987 31.6276 116.03 35.4051 116.425 38.4204C116.82 41.4334 115.124 45.1426 116.937 46.5515C118.751 47.9604 125.539 48.2968 127.31 46.8761C129.081 45.4578 127.978 41.4428 127.562 38.0347C127.145 34.6265 126.501 30.1646 124.81 26.4296C123.116 22.6921 120.195 18.7594 117.413 15.6194C114.63 12.4818 112.247 9.39349 108.117 7.58945C103.987 5.78541 97.5776 5.02099 92.6335 4.79519C87.6895 4.56939 82.3503 4.78813 78.4505 6.23466C74.5484 7.68118 72.0882 10.6542 69.228 13.4696C66.3679 16.2851 63.4725 19.7873 61.2898 23.1319C59.1071 26.4789 56.9761 29.4896 56.1293 33.5469C55.285 37.6043 54.577 45.2132 56.2117 47.4759C57.8487 49.7409 64.2675 47.3418 65.9445 47.1301" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M140.37 54.8958C137.884 58.1322 127.704 71.2286 125.185 74.5427M139.697 54.209C137.098 57.5466 127.005 71.7884 124.51 75.3565" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M141.663 63.1765C139.661 66.0413 131.311 77.1501 129.077 79.9726M141.065 62.5908C139.021 65.2792 130.631 76.1364 128.717 78.8625" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M141.888 72.9917C139.475 75.8589 130.268 86.8478 127.966 89.7455M141.02 72.726C138.503 75.6496 129.775 87.2476 127.58 90.3242" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M141.948 82.215C139.815 85.1057 130.308 96.8214 127.961 99.7709M141.459 81.7375C139.298 84.4119 129.816 95.9888 127.479 98.8606" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M141.357 91.7838C138.885 95.2484 128.808 108.535 126.428 111.76M142.474 91.4757C139.917 94.7921 128.38 107.493 125.781 110.883" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M142.568 101.479C140.028 104.403 129.867 115.528 127.195 118.356M141.811 101.018C139.212 104.055 129.477 115.975 126.828 118.97" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M141.023 112.172C138.591 114.775 128.028 125.905 125.422 128.664M140.51 113.465C138.008 116.147 127.36 125.233 124.742 127.582" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M139.004 123.69C136.501 126.275 125.952 137.248 123.287 140.108M138.343 124.817C135.805 127.454 125.487 138.261 122.848 140.75" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M132.192 139.862C129.854 141.624 120.87 148.168 118.574 150.012M131.39 139.496C128.97 141.333 120.524 148.89 118.322 150.621" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M82.6351 92.3124L78.2767 89.0148L78.6718 88.8784L75.6282 79.6865L74.4922 76.0525L75.0379 74.1074L78.6248 69.5444L83.6182 65.186L86.6924 64.0711L93.7768 63.9864L99.9181 63.9276L103.905 64.4215L106.038 66.068L109.333 67.6392L110.251 69.4479L112.438 73.1877L112.702 81.928L111.674 82.93L110.907 85.5573L107.828 89.2336L101.273 92.9193L102.785 120.401L99.5488 125.521L98.0059 127.838L96.1313 129.414L93.17 130.237L92.2198 130.033L90.1358 129.233L88.8328 126.594L87.8378 95.2549L88.9386 93.3215L86.0409 91.294L80.9533 91.1552" fill="white"/>
|
||||
<path d="M82.8214 92.0607C82.0664 91.4327 79.291 90.7201 77.8539 88.2033C76.4167 85.6866 73.5284 80.4438 74.1964 76.9581C74.862 73.4723 78.6959 69.6384 81.8524 67.2887C85.0089 64.939 88.9227 63.1138 93.1353 62.8574C97.3478 62.6034 103.957 63.9888 107.132 65.7575C110.31 67.5263 111.416 70.5651 112.196 73.4747C112.977 76.3842 112.606 80.6626 111.82 83.2122C111.035 85.7642 109.078 87.1661 107.481 88.7749C105.883 90.3837 103.106 91.2751 102.233 92.8651C101.363 94.4551 102.327 95.3254 102.25 98.3125C102.172 101.3 101.76 107.227 101.767 110.788C101.772 114.349 102.487 116.981 102.285 119.676C102.085 122.374 101.52 125.126 100.556 126.965C99.5917 128.805 98.077 130.256 96.5011 130.715C94.9275 131.171 92.4485 130.36 91.1101 129.713C89.7742 129.066 89.0144 128.341 88.4805 126.836C87.9489 125.331 87.9678 123.964 87.9137 120.681C87.8596 117.397 88.1159 111.599 88.1583 107.14C88.203 102.68 89.2779 96.445 88.1724 93.9236C87.0693 91.4022 82.7791 92.4347 81.5325 92.0137M82.0194 91.6068C81.222 90.7624 78.4536 89.7886 77.3623 87.1567C76.2733 84.5247 74.6621 79.3125 75.4783 75.815C76.2921 72.3151 79.1428 68.3166 82.2522 66.1597C85.3617 64.0029 90.1693 63.062 94.1302 62.8739C98.0911 62.6857 102.925 63.0832 106.02 65.0331C109.118 66.9853 111.834 71.5836 112.705 74.5801C113.572 77.5743 111.949 80.7731 111.234 83.0076C110.519 85.2444 109.835 86.3711 108.417 87.9916C107.001 89.6146 103.738 90.9623 102.732 92.7358C101.725 94.5092 102.351 95.6382 102.377 98.6301C102.405 101.62 102.866 106.949 102.894 110.682C102.922 114.417 102.955 118.291 102.544 121.038C102.13 123.783 101.408 125.54 100.42 127.161C99.4318 128.781 98.1005 130.233 96.6163 130.759C95.1322 131.286 92.9353 130.893 91.51 130.322C90.0846 129.753 88.7769 128.889 88.0618 127.335C87.3468 125.78 87.0128 124.317 87.2198 120.998C87.4268 117.68 89.0874 112.046 89.299 107.422C89.5107 102.798 89.8494 95.9322 88.4946 93.2509C87.1398 90.5695 82.4804 91.4845 81.1679 91.3316" stroke="#6965DB" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M28.1943 139.31C26.7936 139.432 25.332 140.402 23.8703 140.523C23.1395 140.766 22.5914 140.16 21.9824 139.735C21.5561 139.553 21.008 138.461 20.8253 138.219C20.5817 136.884 19.9118 134.276 20.0336 133.002C19.7291 131.364 21.5561 129.787 23.0786 129.727C23.2613 129.727 23.8094 129.787 23.8703 129.787C25.7583 130.151 27.5853 131.546 29.5341 131.728C29.595 131.728 29.6559 131.728 29.6559 131.668C30.4476 130.333 30.204 126.937 30.813 125.542C30.813 125.36 31.1784 123.54 31.1784 123.237C31.6048 122.327 32.1529 121.781 33.1273 122.084C33.7972 122.266 34.6498 122.388 34.6498 123.237V128.635C34.8325 129.242 36.1114 128.999 36.5986 128.999C38.7911 128.028 40.8617 127.422 43.3586 127.058C45.6729 127.179 46.7082 129.242 46.5864 131.304C46.6473 132.396 45.4293 133.245 44.6985 133.973C44.4549 134.094 43.4804 134.519 43.115 134.397C42.2624 133.791 41.1662 134.033 40.1309 134.094C40.1309 134.155 40.0091 134.337 40.07 134.397C41.288 135.853 43.5413 136.096 45.0639 137.066C46.1601 138.34 47.4999 138.643 47.1345 140.341C47.0736 141.191 47.1345 142.1 46.221 142.404C45.9774 142.586 44.5767 142.828 44.2722 142.828C43.9677 142.768 43.3586 142.343 43.115 142.04C40.9835 141.13 38.6693 140.402 36.2332 140.159V145.133C35.9896 146.468 35.6851 147.923 34.6498 148.955C34.2844 149.015 33.1273 149.015 32.7619 148.955C32.4574 148.773 31.4221 147.741 31.1784 147.438C30.5694 145.133 30.4476 142.404 29.6559 140.159C29.1687 139.553 28.986 139.25 28.1943 139.31Z" fill="#6965DB"/>
|
||||
<path d="M59.5964 139.31C58.1956 139.432 56.734 140.402 55.2724 140.523C54.5416 140.766 53.9935 140.16 53.3845 139.735C52.9582 139.553 52.41 138.461 52.2273 138.219C51.9837 136.884 51.3138 134.276 51.4356 133.002C51.1311 131.364 52.9582 129.787 54.4807 129.727C54.6634 129.727 55.2115 129.787 55.2724 129.787C57.1603 130.151 58.9874 131.546 60.9362 131.728C60.9971 131.728 61.058 131.728 61.058 131.668C61.8497 130.333 61.6061 126.937 62.2151 125.542C62.2151 125.36 62.5805 123.54 62.5805 123.237C63.0068 122.327 63.5549 121.781 64.5293 122.084C65.1992 122.266 66.0519 122.388 66.0519 123.237V128.635C66.2346 129.242 67.5135 128.999 68.0007 128.999C70.1931 128.028 72.2638 127.422 74.7607 127.058C77.0749 127.179 78.1103 129.242 77.9885 131.304C78.0494 132.396 76.8313 133.245 76.1005 133.973C75.8569 134.094 74.8825 134.519 74.5171 134.397C73.6645 133.791 72.5683 134.033 71.5329 134.094C71.5329 134.155 71.4112 134.337 71.4721 134.397C72.6901 135.853 74.9434 136.096 76.4659 137.066C77.5621 138.34 78.902 138.643 78.5366 140.341C78.4757 141.191 78.5366 142.1 77.623 142.404C77.3794 142.586 75.9787 142.828 75.6742 142.828C75.3697 142.768 74.7607 142.343 74.5171 142.04C72.3856 141.13 70.0713 140.402 67.6353 140.159V145.133C67.3917 146.468 67.0872 147.923 66.0519 148.955C65.6865 149.015 64.5293 149.015 64.1639 148.955C63.8594 148.773 62.8241 147.741 62.5805 147.438C61.9715 145.133 61.8497 142.404 61.058 140.159C60.5708 139.553 60.3881 139.25 59.5964 139.31Z" fill="#6965DB"/>
|
||||
<path d="M90.9984 139.31C89.5977 139.432 88.1361 140.402 86.6745 140.523C85.9436 140.766 85.3955 140.16 84.7865 139.735C84.3602 139.553 83.8121 138.461 83.6294 138.219C83.3858 136.884 82.7159 134.276 82.8377 133.002C82.5332 131.364 84.3602 129.787 85.8827 129.727C86.0654 129.727 86.6136 129.787 86.6745 129.787C88.5624 130.151 90.3894 131.546 92.3382 131.728C92.3991 131.728 92.46 131.728 92.46 131.668C93.2518 130.333 93.0082 126.937 93.6172 125.542C93.6172 125.36 93.9826 123.54 93.9826 123.237C94.4089 122.327 94.957 121.781 95.9314 122.084C96.6013 122.266 97.4539 122.388 97.4539 123.237V128.635C97.6366 129.242 98.9155 128.999 99.4028 128.999C101.595 128.028 103.666 127.422 106.163 127.058C108.477 127.179 109.512 129.242 109.391 131.304C109.451 132.396 108.233 133.245 107.503 133.973C107.259 134.094 106.285 134.519 105.919 134.397C105.067 133.791 103.97 134.033 102.935 134.094C102.935 134.155 102.813 134.337 102.874 134.397C104.092 135.853 106.345 136.096 107.868 137.066C108.964 138.34 110.304 138.643 109.939 140.341C109.878 141.191 109.939 142.1 109.025 142.404C108.782 142.586 107.381 142.828 107.076 142.828C106.772 142.768 106.163 142.343 105.919 142.04C103.788 141.13 101.473 140.402 99.0373 140.159V145.133C98.7937 146.468 98.4892 147.923 97.4539 148.955C97.0885 149.015 95.9314 149.015 95.566 148.955C95.2615 148.773 94.2262 147.741 93.9826 147.438C93.3736 145.133 93.2518 142.404 92.46 140.159C91.9728 139.553 91.7901 139.25 90.9984 139.31Z" fill="#6965DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -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) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
@@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
||||
}
|
||||
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
socketId: SocketId,
|
||||
collaborator: Collaborator | undefined,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// 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
|
||||
// words a hue value of step size 10). There are 37 such values including 0.
|
||||
const hue = (hash % 37) * 10;
|
||||
@@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
||||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||
).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 { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isMemberOf, isPromiseLike } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@@ -126,6 +125,7 @@ export const serializeAsClipboardJSON = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
}) => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => isFrameLikeElement(element)),
|
||||
);
|
||||
@@ -152,8 +152,8 @@ export const serializeAsClipboardJSON = ({
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
getContainingFrame(element, elementsMap) &&
|
||||
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
@@ -434,7 +434,7 @@ export const copyTextToSystemClipboard = async (
|
||||
|
||||
// (3) if that fails, use document.execCommand
|
||||
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 { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
@@ -45,6 +46,40 @@ import {
|
||||
import { KEYS } from "../keys";
|
||||
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 = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
@@ -75,35 +110,17 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
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 (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame" &&
|
||||
commonSelectedType !== "magicframe") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
{canChangeStrokeColor(appState, targetElements) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
{showChangeBackgroundIcons && (
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div>{renderAction("changeBackgroundColor")}</div>
|
||||
)}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
@@ -306,6 +323,25 @@ export const ShapesSwitcher = ({
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
{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.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,7 @@ type AvatarProps = {
|
||||
color: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
isBeingFollowed?: boolean;
|
||||
isCurrentUser: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
@@ -18,22 +17,14 @@ export const Avatar = ({
|
||||
onClick,
|
||||
name,
|
||||
src,
|
||||
isBeingFollowed,
|
||||
isCurrentUser,
|
||||
className,
|
||||
}: AvatarProps) => {
|
||||
const shortName = getNameInitial(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg ? undefined : { background: color };
|
||||
return (
|
||||
<div
|
||||
className={clsx("Avatar", {
|
||||
"Avatar--is-followed": isBeingFollowed,
|
||||
"Avatar--is-current-user": isCurrentUser,
|
||||
})}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import "./Button.scss";
|
||||
|
||||
|
||||
@@ -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;
|
||||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
if (item.label) {
|
||||
if (typeof item.label === "function") {
|
||||
label = t(
|
||||
item.contextItemLabel(
|
||||
item.label(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
||||
label = t(item.label as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
& + .Dialog__content {
|
||||
--offset: 28px;
|
||||
height: calc(100% - var(--offset)) !important;
|
||||
margin-top: var(--offset) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.Dialog--fullscreen {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
useExcalidrawContainer,
|
||||
useDevice,
|
||||
@@ -9,13 +8,14 @@ import {
|
||||
} from "./App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, CloseIcon } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { t } from "../i18n";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||
|
||||
@@ -58,10 +58,12 @@ export const Dialog = (props: DialogProps) => {
|
||||
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
@@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => {
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
className="Dialog__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{isFullscreen ? back : CloseIcon}
|
||||
</button>
|
||||
{isFullscreen && (
|
||||
<button
|
||||
className="Dialog__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
)}
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
</Modal>
|
||||
|
||||
@@ -10,11 +10,44 @@
|
||||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
&:hover {
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--color-surface-lowest);
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
pointer-events: none;
|
||||
|
||||
.ExcButton__contents {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&,
|
||||
&__contents {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
// needed because of .Spinner
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-surface-lowest);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-brand-hover);
|
||||
}
|
||||
@@ -27,9 +60,13 @@
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-border-outline);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-brand-hover);
|
||||
--border-color: var(--color-brand-hover);
|
||||
@@ -47,6 +84,10 @@
|
||||
--text-color: var(--color-danger-text);
|
||||
--back-color: var(--color-danger-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-danger-darker);
|
||||
}
|
||||
@@ -62,6 +103,10 @@
|
||||
--border-color: var(--color-danger);
|
||||
--back-color: transparent;
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-danger-darkest);
|
||||
--border-color: var(--color-danger-darkest);
|
||||
@@ -79,6 +124,10 @@
|
||||
--text-color: var(--island-bg-color);
|
||||
--back-color: var(--color-gray-50);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-gray-60);
|
||||
}
|
||||
@@ -94,6 +143,10 @@
|
||||
--border-color: var(--color-muted);
|
||||
--back-color: var(--island-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darker);
|
||||
@@ -111,6 +164,10 @@
|
||||
--text-color: black;
|
||||
--back-color: var(--color-warning-dark);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-warning-darker);
|
||||
}
|
||||
@@ -126,6 +183,10 @@
|
||||
--border-color: var(--color-warning-dark);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
.Spinner {
|
||||
--spinner-color: var(--text-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-warning-darker);
|
||||
--border-color: var(--color-warning-darker);
|
||||
@@ -138,30 +199,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-family: var(--font-family);
|
||||
|
||||
user-select: none;
|
||||
|
||||
transition: all 150ms ease-out;
|
||||
|
||||
&--size-large {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--size-medium {
|
||||
@@ -169,9 +225,12 @@
|
||||
font-size: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
letter-spacing: normal;
|
||||
|
||||
.ExcButton__contents {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-icon {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import React, { forwardRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./FilledButton.scss";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||
@@ -11,7 +14,7 @@ export type FilledButtonProps = {
|
||||
label: string;
|
||||
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
variant?: ButtonVariant;
|
||||
color?: ButtonColor;
|
||||
@@ -19,14 +22,14 @@ export type FilledButtonProps = {
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
|
||||
startIcon?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
startIcon,
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
variant = "filled",
|
||||
@@ -37,6 +40,27 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const _onClick = async (event: React.MouseEvent) => {
|
||||
const ret = onClick?.(event);
|
||||
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
} catch (error: any) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -47,17 +71,21 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={_onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
ref={ref}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{startIcon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{startIcon}
|
||||
</div>
|
||||
)}
|
||||
{variant !== "icon" && (children ?? label)}
|
||||
<div className="ExcButton__contents">
|
||||
{isLoading && <Spinner />}
|
||||
{icon && (
|
||||
<div className="ExcButton__icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
{variant !== "icon" && (children ?? label)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import "./HelpDialog.scss";
|
||||
import { ExternalLinkIcon } from "./icons";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog__header">
|
||||
@@ -278,6 +279,17 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("stats.title")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
isFirefox
|
||||
? [getShortcutFromShortcutName("commandPalette")]
|
||||
: [
|
||||
getShortcutFromShortcutName("commandPalette"),
|
||||
getShortcutFromShortcutName("commandPalette", 1),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland
|
||||
className="HelpDialog__island--editor"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
user-select: none;
|
||||
|
||||
& h3 {
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
|
||||
@@ -32,7 +32,6 @@ import { Switch } from "./Switch";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./ImageExportDialog.scss";
|
||||
import { useAppProps } from "./App";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { cloneJSON } from "../utils";
|
||||
import { prepareElementsForExport } from "../data";
|
||||
@@ -58,6 +57,7 @@ type ImageExportModalProps = {
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
name: string;
|
||||
};
|
||||
|
||||
const ImageExportModal = ({
|
||||
@@ -66,14 +66,14 @@ const ImageExportModal = ({
|
||||
files,
|
||||
actionManager,
|
||||
onExportImage,
|
||||
name,
|
||||
}: ImageExportModalProps) => {
|
||||
const hasSelection = isSomeElementSelected(
|
||||
elementsSnapshot,
|
||||
appStateSnapshot,
|
||||
);
|
||||
|
||||
const appProps = useAppProps();
|
||||
const [projectName, setProjectName] = useState(appStateSnapshot.name);
|
||||
const [projectName, setProjectName] = useState(name);
|
||||
const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection);
|
||||
const [exportWithBackground, setExportWithBackground] = useState(
|
||||
appStateSnapshot.exportBackground,
|
||||
@@ -124,9 +124,16 @@ const ImageExportModal = ({
|
||||
setRenderError(null);
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas).then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
});
|
||||
return canvasToBlob(canvas)
|
||||
.then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw new Error(t("canvasError.canvasTooBig"));
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
@@ -158,10 +165,6 @@ const ImageExportModal = ({
|
||||
className="TextInput"
|
||||
value={projectName}
|
||||
style={{ width: "30ch" }}
|
||||
disabled={
|
||||
typeof appProps.name !== "undefined" ||
|
||||
appStateSnapshot.viewModeEnabled
|
||||
}
|
||||
onChange={(event) => {
|
||||
setProjectName(event.target.value);
|
||||
actionManager.executeAction(
|
||||
@@ -271,7 +274,7 @@ const ImageExportModal = ({
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
icon={downloadIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.exportToPng")}
|
||||
</FilledButton>
|
||||
@@ -283,7 +286,7 @@ const ImageExportModal = ({
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={downloadIcon}
|
||||
icon={downloadIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.exportToSvg")}
|
||||
</FilledButton>
|
||||
@@ -296,7 +299,7 @@ const ImageExportModal = ({
|
||||
exportingFrame,
|
||||
})
|
||||
}
|
||||
startIcon={copyIcon}
|
||||
icon={copyIcon}
|
||||
>
|
||||
{t("imageExportDialog.button.copyPngToClipboard")}
|
||||
</FilledButton>
|
||||
@@ -347,6 +350,7 @@ export const ImageExportDialog = ({
|
||||
actionManager,
|
||||
onExportImage,
|
||||
onCloseRequest,
|
||||
name,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
@@ -354,6 +358,7 @@ export const ImageExportDialog = ({
|
||||
actionManager: ActionManager;
|
||||
onExportImage: AppClassProperties["onExportImage"];
|
||||
onCloseRequest: () => void;
|
||||
name: string;
|
||||
}) => {
|
||||
// we need to take a snapshot so that the exported state can't be modified
|
||||
// while the dialog is open
|
||||
@@ -372,6 +377,7 @@ export const ImageExportDialog = ({
|
||||
files={files}
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
name={name}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
|
||||
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -78,7 +78,7 @@ const JSONExportModal = ({
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
await onExportToBackend(elements, appState, files, canvas);
|
||||
await onExportToBackend(elements, appState, files);
|
||||
onCloseRequest();
|
||||
} catch (error: any) {
|
||||
setAppState({ errorMessage: error.message });
|
||||
|
||||
@@ -19,7 +19,14 @@
|
||||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
||||
@@ -195,6 +195,7 @@ const LayerUI = ({
|
||||
actionManager={actionManager}
|
||||
onExportImage={onExportImage}
|
||||
onCloseRequest={() => setAppState({ openDialog: null })}
|
||||
name={app.getName()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
|
||||
.Island {
|
||||
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;
|
||||
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 {
|
||||
@@ -47,7 +61,8 @@
|
||||
|
||||
opacity: 0;
|
||||
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;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -56,7 +71,7 @@
|
||||
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
box-shadow: var(--modal-shadow);
|
||||
border-radius: 6px;
|
||||
border-radius: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
|
||||
@@ -5,6 +5,7 @@ import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppState } from "../types";
|
||||
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
|
||||
import { useRef } from "react";
|
||||
|
||||
export const Modal: React.FC<{
|
||||
className?: string;
|
||||
@@ -20,6 +21,10 @@ export const Modal: React.FC<{
|
||||
className: "excalidraw-modal-container",
|
||||
});
|
||||
|
||||
const animationsDisabledRef = useRef(
|
||||
document.body.classList.contains("excalidraw-animations-disabled"),
|
||||
);
|
||||
|
||||
if (!modalRoot) {
|
||||
return null;
|
||||
}
|
||||
@@ -34,7 +39,9 @@ export const Modal: React.FC<{
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={clsx("Modal", props.className)}
|
||||
className={clsx("Modal", props.className, {
|
||||
"animations-disabled": animationsDisabledRef.current,
|
||||
})}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleKeydown}
|
||||
|
||||
@@ -11,7 +11,6 @@ type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
isNameEditable: boolean;
|
||||
ignoreFocus?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => {
|
||||
return (
|
||||
<div className="ProjectName">
|
||||
<label className="ProjectName-label" htmlFor="filename">
|
||||
{`${props.label}${props.isNameEditable ? "" : ":"}`}
|
||||
{`${props.label}:`}
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="TextInput TextInput--readonly" id={`${id}-filename`}>
|
||||
{props.value}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
id={`${id}-filename`}
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@keyframes RoomDialog__popover__scaleIn {
|
||||
@keyframes ShareableLinkDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
||||
animation: ShareableLinkDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__linkRow {
|
||||
|
||||
@@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(link);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
} catch (e) {
|
||||
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
@@ -66,7 +65,7 @@ export const ShareableLinkDialog = ({
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
startIcon={copyIcon}
|
||||
icon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -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(
|
||||
<Excalidraw>
|
||||
<Sidebar name="customSidebar">
|
||||
@@ -158,6 +158,20 @@ describe("Sidebar", () => {
|
||||
const sidebars = container.querySelectorAll(".sidebar");
|
||||
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;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<RadixTabs.Content {...rest} value={tab}>
|
||||
<RadixTabs.Content {...rest} value={tab} data-testid={tab}>
|
||||
{children}
|
||||
</RadixTabs.Content>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { AppClassProperties, BinaryFiles } from "../../types";
|
||||
import { canvasToBlob } from "../../data/blob";
|
||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
const resetPreview = ({
|
||||
canvasRef,
|
||||
@@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({
|
||||
});
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// 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)";
|
||||
canvasNode.replaceChildren(canvas);
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -13,8 +13,6 @@ import { Button } from "./Button";
|
||||
import { eyeIcon, eyeClosedIcon } from "./icons";
|
||||
|
||||
type TextFieldProps = {
|
||||
value?: string;
|
||||
|
||||
onChange?: (value: string) => void;
|
||||
onClick?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
@@ -26,12 +24,11 @@ type TextFieldProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
};
|
||||
} & ({ value: string } | { defaultValue: string });
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
fullWidth,
|
||||
@@ -40,6 +37,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isRedacted = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -73,10 +71,17 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
>
|
||||
<input
|
||||
className={clsx({
|
||||
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
|
||||
"is-redacted":
|
||||
"value" in rest &&
|
||||
rest.value &&
|
||||
isRedacted &&
|
||||
!isTemporarilyUnredacted,
|
||||
})}
|
||||
readOnly={readonly}
|
||||
value={value}
|
||||
value={"value" in rest ? rest.value : undefined}
|
||||
defaultValue={
|
||||
"defaultValue" in rest ? rest.defaultValue : undefined
|
||||
}
|
||||
placeholder={placeholder}
|
||||
ref={innerRef}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { CSSProperties, useCallback, useEffect, useRef } from "react";
|
||||
import { CloseIcon } from "./icons";
|
||||
import "./Toast.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
@@ -11,11 +11,13 @@ export const Toast = ({
|
||||
closable = false,
|
||||
// To prevent autoclose, pass duration as Infinity
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
style,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
closable?: boolean;
|
||||
duration?: number;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
const shouldAutoClose = duration !== Infinity;
|
||||
@@ -43,6 +45,7 @@ export const Toast = ({
|
||||
className="Toast"
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={style}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{closable && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
import Spinner from "./Spinner";
|
||||
import { PointerType } from "../element/types";
|
||||
import { isPromiseLike } from "../utils";
|
||||
|
||||
export type ToolButtonSize = "small" | "medium";
|
||||
|
||||
@@ -65,7 +66,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const onClick = async (event: React.MouseEvent) => {
|
||||
const ret = "onClick" in props && props.onClick?.(event);
|
||||
|
||||
if (ret && "then" in ret) {
|
||||
if (isPromiseLike(ret)) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await ret;
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.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 {
|
||||
pointer-events: none;
|
||||
/*github corner*/
|
||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
||||
var(--space-factor);
|
||||
padding: var(--userList-padding);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
gap: var(--avatarList-gap);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
@@ -18,15 +27,16 @@
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
// can fit max 4 avatars (3 avatars + show more) in a column
|
||||
max-height: 120px;
|
||||
--max-size: calc(
|
||||
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: 120px;
|
||||
// max width & height set to fix the max-avatars
|
||||
max-height: var(--max-size);
|
||||
max-width: var(--max-size);
|
||||
|
||||
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.UserList > * {
|
||||
@@ -45,10 +55,11 @@
|
||||
@include avatarStyles;
|
||||
background-color: var(--color-gray-20);
|
||||
border: 0 !important;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-gray-100);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.UserList__collaborator-name {
|
||||
@@ -57,13 +68,82 @@
|
||||
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;
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
min-width: 2.25rem;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
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-heading-color: var(--color-gray-80);
|
||||
--userlist-hint-text-color: var(--color-gray-60);
|
||||
@@ -80,7 +160,7 @@
|
||||
position: static;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
max-height: 12rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./UserList.scss";
|
||||
|
||||
import React from "react";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Collaborator, SocketId } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -12,9 +12,11 @@ import { Island } from "./Island";
|
||||
import { searchIcon } from "./icons";
|
||||
import { t } from "../i18n";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { supportsResizeObserver } from "../constants";
|
||||
import { MarkRequired } from "../utility-types";
|
||||
|
||||
export type GoToCollaboratorComponentProps = {
|
||||
clientId: ClientId;
|
||||
socketId: SocketId;
|
||||
collaborator: Collaborator;
|
||||
withName: boolean;
|
||||
isBeingFollowed: boolean;
|
||||
@@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
|
||||
/** collaborator user id or socket id (fallback) */
|
||||
type ClientId = string & { _brand: "UserId" };
|
||||
|
||||
const FIRST_N_AVATARS = 3;
|
||||
const DEFAULT_MAX_AVATARS = 4;
|
||||
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
||||
|
||||
const ConditionalTooltipWrapper = ({
|
||||
shouldWrap,
|
||||
children,
|
||||
clientId,
|
||||
username,
|
||||
}: {
|
||||
shouldWrap: boolean;
|
||||
children: React.ReactNode;
|
||||
username?: string | null;
|
||||
clientId: ClientId;
|
||||
}) =>
|
||||
shouldWrap ? (
|
||||
<Tooltip label={username || "Unknown user"} key={clientId}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{children}</React.Fragment>
|
||||
<React.Fragment>{children}</React.Fragment>
|
||||
);
|
||||
|
||||
const renderCollaborator = ({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
socketId,
|
||||
withName = false,
|
||||
shouldWrapWithTooltip = false,
|
||||
isBeingFollowed,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
collaborator: Collaborator;
|
||||
clientId: ClientId;
|
||||
socketId: SocketId;
|
||||
withName?: boolean;
|
||||
shouldWrapWithTooltip?: boolean;
|
||||
isBeingFollowed: boolean;
|
||||
}) => {
|
||||
const data: GoToCollaboratorComponentProps = {
|
||||
clientId,
|
||||
socketId,
|
||||
collaborator,
|
||||
withName,
|
||||
isBeingFollowed,
|
||||
@@ -70,8 +68,7 @@ const renderCollaborator = ({
|
||||
|
||||
return (
|
||||
<ConditionalTooltipWrapper
|
||||
key={clientId}
|
||||
clientId={clientId}
|
||||
key={socketId}
|
||||
username={collaborator.username}
|
||||
shouldWrap={shouldWrapWithTooltip}
|
||||
>
|
||||
@@ -82,7 +79,13 @@ const renderCollaborator = ({
|
||||
|
||||
type UserListUserObject = Pick<
|
||||
Collaborator,
|
||||
"avatarUrl" | "id" | "socketId" | "username"
|
||||
| "avatarUrl"
|
||||
| "id"
|
||||
| "socketId"
|
||||
| "username"
|
||||
| "isInCall"
|
||||
| "isSpeaking"
|
||||
| "isMuted"
|
||||
>;
|
||||
|
||||
type UserListProps = {
|
||||
@@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
|
||||
"id",
|
||||
"socketId",
|
||||
"username",
|
||||
"isInCall",
|
||||
"isSpeaking",
|
||||
"isMuted",
|
||||
] as const;
|
||||
|
||||
export const UserList = React.memo(
|
||||
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
|
||||
const uniqueCollaboratorsMap = new Map<
|
||||
ClientId,
|
||||
MarkRequired<Collaborator, "socketId">
|
||||
>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
const userId = (collaborator.id || socketId) as ClientId;
|
||||
@@ -114,115 +123,147 @@ export const UserList = React.memo(
|
||||
);
|
||||
});
|
||||
|
||||
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
||||
([_, collaborator]) => collaborator.username?.trim(),
|
||||
);
|
||||
const uniqueCollaboratorsArray = Array.from(
|
||||
uniqueCollaboratorsMap.values(),
|
||||
).filter((collaborator) => collaborator.username?.trim());
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
if (uniqueCollaboratorsArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const userListWrapper = React.useRef<HTMLDivElement | null>(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 filteredCollaborators = searchTermNormalized
|
||||
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
||||
? uniqueCollaboratorsArray.filter((collaborator) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
)
|
||||
: uniqueCollaboratorsArray;
|
||||
|
||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||
0,
|
||||
FIRST_N_AVATARS,
|
||||
maxAvatars - 1,
|
||||
);
|
||||
|
||||
const firstNAvatarsJSX = firstNCollaborators.map(
|
||||
([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
);
|
||||
|
||||
return mobile ? (
|
||||
<div className={clsx("UserList UserList_mobile", className)}>
|
||||
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
||||
{uniqueCollaboratorsArray.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
socketId: collaborator.socketId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx("UserList", className)}>
|
||||
{firstNAvatarsJSX}
|
||||
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||
<div
|
||||
className={clsx("UserList", className)}
|
||||
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||
>
|
||||
{firstNAvatarsJSX}
|
||||
|
||||
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
style={{
|
||||
zIndex: 2,
|
||||
width: "13rem",
|
||||
textAlign: "left",
|
||||
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
style={{
|
||||
zIndex: 2,
|
||||
width: "15rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{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>
|
||||
{filteredCollaborators.map(([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
withName: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -236,10 +277,15 @@ export const UserList = React.memo(
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextCollaboratorSocketIds = next.collaborators.keys();
|
||||
|
||||
for (const [socketId, collaborator] of prev.collaborators) {
|
||||
const nextCollaborator = next.collaborators.get(socketId);
|
||||
if (
|
||||
!nextCollaborator ||
|
||||
// this checks order of collaborators in the map is the same
|
||||
// as previous render
|
||||
socketId !== nextCollaboratorSocketIds.next().value ||
|
||||
!isShallowEqual(
|
||||
collaborator,
|
||||
nextCollaborator,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { renderInteractiveScene } from "../../renderer/renderScene";
|
||||
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
||||
import { CURSOR_TYPE } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
@@ -12,6 +11,7 @@ import type {
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||
|
||||
type InteractiveCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
|
||||
new Map();
|
||||
const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
new Map();
|
||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
new Map();
|
||||
const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
|
||||
new Map();
|
||||
const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
|
||||
new Map();
|
||||
|
||||
props.appState.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
if (!(id in remoteSelectedElementIds)) {
|
||||
remoteSelectedElementIds[id] = [];
|
||||
if (!remoteSelectedElementIds.has(id)) {
|
||||
remoteSelectedElementIds.set(id, []);
|
||||
}
|
||||
remoteSelectedElementIds[id].push(socketId);
|
||||
remoteSelectedElementIds.get(id)!.push(socketId);
|
||||
}
|
||||
}
|
||||
if (!user.pointer) {
|
||||
if (!user.pointer || user.pointer.renderCursor === false) {
|
||||
return;
|
||||
}
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
remotePointerUsernames.set(socketId, user.username);
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
remotePointerUserStates.set(socketId, user.userState);
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
remotePointerViewportCoords.set(
|
||||
socketId,
|
||||
sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
),
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
remotePointerButton.set(socketId, user.button);
|
||||
});
|
||||
|
||||
const selectionColor =
|
||||
@@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remotePointerViewportCoords,
|
||||
remotePointerButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
remotePointerUsernames,
|
||||
remotePointerUserStates,
|
||||
selectionColor,
|
||||
renderScrollbars: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { renderStaticScene } from "../../renderer/renderScene";
|
||||
import { renderStaticScene } from "../../renderer/staticScene";
|
||||
import { isShallowEqual } from "../../utils";
|
||||
import type { AppState, StaticCanvasAppState } from "../../types";
|
||||
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 clsx from "clsx";
|
||||
import Stack from "../Stack";
|
||||
import React, { useRef } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { DropdownMenuContentPropsContext } from "./common";
|
||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||
import { KEYS } from "../../keys";
|
||||
import { EVENT } from "../../constants";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
const MenuContent = ({
|
||||
children,
|
||||
@@ -25,10 +28,32 @@ const MenuContent = ({
|
||||
const device = useDevice();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const callbacksRef = useStable({ onClickOutside });
|
||||
|
||||
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}`, {
|
||||
"dropdown-menu--mobile": device.editor.isMobile,
|
||||
}).trim();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import "../css/variables.module.scss";
|
||||
@import "../../css/variables.module.scss";
|
||||
|
||||
.excalidraw-hyperlinkContainer {
|
||||
display: flex;
|
||||
+48
-158
@@ -1,21 +1,20 @@
|
||||
import { AppState, ExcalidrawProps, Point, UIAppState } from "../types";
|
||||
import { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
|
||||
import {
|
||||
getShortcutKey,
|
||||
sceneCoordsToViewportCoords,
|
||||
viewportCoordsToSceneCoords,
|
||||
wrapEvent,
|
||||
} from "../utils";
|
||||
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
} from "../../utils";
|
||||
import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import {
|
||||
ElementsMap,
|
||||
ExcalidrawEmbeddableElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
} from "../../element/types";
|
||||
|
||||
import { register } from "../actions/register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { ToolButton } from "../ToolButton";
|
||||
import { FreedrawIcon, TrashIcon } from "../icons";
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -24,21 +23,19 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
|
||||
import { rotate } from "../math";
|
||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
|
||||
import { Bounds } from "./bounds";
|
||||
import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { isPointHittingElementBoundingBox } from "./collision";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { isLocalLink, normalizeLink } from "../data/url";
|
||||
import { KEYS } from "../../keys";
|
||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
|
||||
import { getSelectedElements } from "../../scene";
|
||||
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
||||
import { isLocalLink, normalizeLink } from "../../data/url";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||
import { getLinkHandleFromCoords } from "./helpers";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -46,11 +43,6 @@ const CONTAINER_PADDING = 5;
|
||||
const CONTAINER_HEIGHT = 42;
|
||||
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;
|
||||
|
||||
const embeddableLinkCache = new Map<
|
||||
@@ -60,12 +52,14 @@ const embeddableLinkCache = new Map<
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
elementsMap,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
setToast,
|
||||
updateEmbedValidationStatus,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
setToast: (
|
||||
@@ -182,7 +176,7 @@ export const Hyperlink = ({
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const shouldHide = shouldHideLinkPopup(element, appState, [
|
||||
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]) as boolean;
|
||||
@@ -199,7 +193,7 @@ export const Hyperlink = ({
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [appState, element, isEditing, setAppState]);
|
||||
}, [appState, element, isEditing, setAppState, elementsMap]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
trackEvent("hyperlink", "delete");
|
||||
@@ -214,7 +208,7 @@ export const Hyperlink = ({
|
||||
trackEvent("hyperlink", "edit", "popup-ui");
|
||||
setAppState({ showHyperlinkPopup: "editor" });
|
||||
};
|
||||
const { x, y } = getCoordsForPopover(element, appState);
|
||||
const { x, y } = getCoordsForPopover(element, appState, elementsMap);
|
||||
if (
|
||||
appState.contextMenu ||
|
||||
appState.draggingElement ||
|
||||
@@ -324,8 +318,9 @@ export const Hyperlink = ({
|
||||
const getCoordsForPopover = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||
const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||
{ sceneX: x1 + element.width / 2, sceneY: y1 },
|
||||
appState,
|
||||
@@ -335,57 +330,12 @@ const getCoordsForPopover = (
|
||||
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 = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appState: UIAppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const label = selectedElements[0]!.link
|
||||
const label = selectedElements[0]?.link
|
||||
? isEmbeddableElement(selectedElements[0])
|
||||
? "labels.link.editEmbed"
|
||||
: "labels.link.edit"
|
||||
@@ -395,89 +345,17 @@ export const getContextMenuLabel = (
|
||||
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;
|
||||
export const showHyperlinkTooltip = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
|
||||
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
|
||||
}
|
||||
HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
|
||||
() => renderTooltip(element, appState),
|
||||
() => renderTooltip(element, appState, elementsMap),
|
||||
HYPERLINK_TOOLTIP_DELAY,
|
||||
);
|
||||
};
|
||||
@@ -485,6 +363,7 @@ export const showHyperlinkTooltip = (
|
||||
const renderTooltip = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!element.link) {
|
||||
return;
|
||||
@@ -496,7 +375,7 @@ const renderTooltip = (
|
||||
tooltipDiv.style.maxWidth = "20rem";
|
||||
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(
|
||||
[x1, y1, x2, y2],
|
||||
@@ -533,8 +412,9 @@ export const hideHyperlinkToolip = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldHideLinkPopup = (
|
||||
const shouldHideLinkPopup = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
[clientX, clientY]: Point,
|
||||
): Boolean => {
|
||||
@@ -546,11 +426,17 @@ export const shouldHideLinkPopup = (
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (
|
||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
||||
isPointHittingElementBoundingBox(
|
||||
element,
|
||||
elementsMap,
|
||||
[sceneX, sceneY],
|
||||
threshold,
|
||||
null,
|
||||
)
|
||||
) {
|
||||
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
|
||||
if (
|
||||
sceneX >= x1 &&
|
||||
@@ -561,7 +447,11 @@ export const shouldHideLinkPopup = (
|
||||
return false;
|
||||
}
|
||||
// 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 (
|
||||
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
|
||||
export const LibraryIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<g strokeWidth="1.25">
|
||||
<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 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
|
||||
@@ -386,6 +386,16 @@ export const ZoomOutIcon = createIcon(
|
||||
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(
|
||||
<path
|
||||
strokeWidth="1.25"
|
||||
@@ -462,6 +472,16 @@ export const HelpIcon = createIcon(
|
||||
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(
|
||||
<path
|
||||
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",
|
||||
);
|
||||
|
||||
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(
|
||||
<g strokeWidth="1.25">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@@ -604,11 +634,25 @@ export const share = createIcon(
|
||||
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(
|
||||
"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 },
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<path
|
||||
@@ -930,11 +974,6 @@ export const CloseIcon = createIcon(
|
||||
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(
|
||||
"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 },
|
||||
@@ -1468,6 +1507,19 @@ export const FontSizeExtraLargeIcon = createIcon(
|
||||
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(
|
||||
<>
|
||||
<g
|
||||
@@ -1645,6 +1697,17 @@ export const copyIcon = createIcon(
|
||||
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(
|
||||
<>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
@@ -1769,6 +1832,17 @@ export const MagicIcon = createIcon(
|
||||
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(
|
||||
<g stroke="currentColor" fill="none">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
@@ -1794,7 +1868,7 @@ export const fullscreenIcon = 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 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" />
|
||||
@@ -1825,6 +1899,19 @@ export const brainIcon = createIcon(
|
||||
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(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
@@ -1833,3 +1920,175 @@ export const searchIcon = createIcon(
|
||||
</g>,
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.excalidraw {
|
||||
.collab-button {
|
||||
--button-bg: var(--color-primary);
|
||||
--button-color: white;
|
||||
--button-color: var(--color-surface-lowest);
|
||||
--button-border: var(--color-primary);
|
||||
|
||||
--button-width: var(--lg-button-size);
|
||||
@@ -35,12 +35,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.collab-button {
|
||||
color: var(--color-gray-90);
|
||||
}
|
||||
}
|
||||
|
||||
.CollabButton.is-collaborating {
|
||||
background-color: var(--button-special-active-bg-color);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "../../i18n";
|
||||
import { usersIcon } from "../icons";
|
||||
import { share } from "../icons";
|
||||
import { Button } from "../Button";
|
||||
|
||||
import clsx from "clsx";
|
||||
@@ -17,16 +17,18 @@ const LiveCollaborationTrigger = ({
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const appState = useUIAppState();
|
||||
|
||||
const showIconOnly = appState.width < 830;
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
className={clsx("collab-button", { active: isCollaborating })}
|
||||
type="button"
|
||||
onSelect={onSelect}
|
||||
style={{ position: "relative" }}
|
||||
style={{ position: "relative", width: showIconOnly ? undefined : "auto" }}
|
||||
title={t("labels.liveCollaboration")}
|
||||
>
|
||||
{usersIcon}
|
||||
{showIconOnly ? share : t("labels.share")}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<div className="CollabButton-collaborators">
|
||||
{appState.collaborators.size}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user