Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage
This commit is contained in:
commit
81e3dd5406
@ -4,8 +4,15 @@
|
|||||||
!.eslintrc.json
|
!.eslintrc.json
|
||||||
!.npmrc
|
!.npmrc
|
||||||
!.prettierrc
|
!.prettierrc
|
||||||
|
!excalidraw-app/
|
||||||
!package.json
|
!package.json
|
||||||
!public/
|
!public/
|
||||||
!packages/
|
!packages/
|
||||||
!tsconfig.json
|
!tsconfig.json
|
||||||
!yarn.lock
|
!yarn.lock
|
||||||
|
|
||||||
|
# keep (sub)sub directories at the end to exclude from explicit included
|
||||||
|
# e.g. ./packages/excalidraw/{dist,node_modules}
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
**/node_modules
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-restricted-globals": "off"
|
"no-restricted-globals": "off",
|
||||||
|
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,4 +25,5 @@ packages/excalidraw/types
|
|||||||
coverage
|
coverage
|
||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
examples/**/bundle.*
|
examples/**/bundle.*
|
||||||
|
meta*.json
|
12
Dockerfile
12
Dockerfile
@ -2,16 +2,18 @@ FROM node:18 AS build
|
|||||||
|
|
||||||
WORKDIR /opt/node_app
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
COPY package.json yarn.lock ./
|
COPY . .
|
||||||
RUN yarn --ignore-optional --network-timeout 600000
|
|
||||||
|
# do not ignore optional dependencies:
|
||||||
|
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||||
|
RUN yarn --network-timeout 600000
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN yarn build:app:docker
|
RUN yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.21-alpine
|
FROM nginx:1.24-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
|
||||||
|
@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
|
|||||||
| API | Signature | Usage |
|
| API | Signature | Usage |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
|
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
|
||||||
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
|
| [updateLibrary](#updatelibrary) | `function` | updates the library |
|
||||||
| [addFiles](#addfiles) | `function` | add files data to the appState |
|
| [addFiles](#addfiles) | `function` | add files data to the appState |
|
||||||
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
|
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
|
||||||
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
|
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
|
||||||
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
|
|||||||
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
|
||||||
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
|
||||||
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
|
||||||
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
|
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
|
||||||
|
|
||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
@ -115,7 +115,7 @@ function App() {
|
|||||||
<button className="custom-button" onClick={updateScene}>
|
<button className="custom-button" onClick={updateScene}>
|
||||||
Update Scene
|
Update Scene
|
||||||
</button>
|
</button>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -188,7 +188,7 @@ function App() {
|
|||||||
Update Library
|
Update Library
|
||||||
</button>
|
</button>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
|
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
|
||||||
initialData={{
|
initialData={{
|
||||||
libraryItems: initialData.libraryItems,
|
libraryItems: initialData.libraryItems,
|
||||||
|
@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
|
|
||||||
```jsx showLineNumbers
|
```jsx showLineNumbers
|
||||||
"use client";
|
"use client";
|
||||||
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
|
|||||||
height: 141.9765625,
|
height: 141.9765625,
|
||||||
},]));
|
},]));
|
||||||
return (
|
return (
|
||||||
<div style={{height:"500px", width:"500px"}}
|
<div style={{height:"500px", width:"500px"}}>
|
||||||
<Excalidraw />
|
<Excalidraw />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
|
|||||||
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
import type { ResolvablePromise } from "../utils";
|
||||||
import {
|
import {
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
ResolvablePromise,
|
|
||||||
distance2d,
|
distance2d,
|
||||||
fileOpen,
|
fileOpen,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
|
||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
import { fileOpen as _fileOpen } from "browser-fs-access";
|
import { fileOpen as _fileOpen } from "browser-fs-access";
|
||||||
import type { MIME_TYPES } from "@excalidraw/excalidraw";
|
import { MIME_TYPES } from "@excalidraw/excalidraw";
|
||||||
import { AbortError } from "../../packages/excalidraw/errors";
|
import { AbortError } from "../../packages/excalidraw/errors";
|
||||||
|
|
||||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
@ -14,11 +14,10 @@ import {
|
|||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
} from "../packages/excalidraw/constants";
|
} from "../packages/excalidraw/constants";
|
||||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
|
||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
Theme,
|
OrderedExcalidrawElement,
|
||||||
} from "../packages/excalidraw/element/types";
|
} from "../packages/excalidraw/element/types";
|
||||||
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
@ -28,32 +27,34 @@ import {
|
|||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialog,
|
TTDDialog,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
} from "../packages/excalidraw/index";
|
StoreAction,
|
||||||
import {
|
reconcileElements,
|
||||||
|
} from "../packages/excalidraw";
|
||||||
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
LibraryItems,
|
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
ExcalidrawInitialDataState,
|
ExcalidrawInitialDataState,
|
||||||
UIAppState,
|
UIAppState,
|
||||||
} from "../packages/excalidraw/types";
|
} from "../packages/excalidraw/types";
|
||||||
|
import type { ResolvablePromise } from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
getFrame,
|
getFrame,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
preventUnload,
|
preventUnload,
|
||||||
ResolvablePromise,
|
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
isRunningInIframe,
|
isRunningInIframe,
|
||||||
} from "../packages/excalidraw/utils";
|
} from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
FIREBASE_STORAGE_PREFIXES,
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
|
isExcalidrawPlusSignedUser,
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
|
import type { CollabAPI } from "./collab/Collab";
|
||||||
import Collab, {
|
import Collab, {
|
||||||
CollabAPI,
|
|
||||||
collabAPIAtom,
|
collabAPIAtom,
|
||||||
isCollaboratingAtom,
|
isCollaboratingAtom,
|
||||||
isOfflineAtom,
|
isOfflineAtom,
|
||||||
@ -65,16 +66,12 @@ import {
|
|||||||
loadScene,
|
loadScene,
|
||||||
} from "./data";
|
} from "./data";
|
||||||
import {
|
import {
|
||||||
getLibraryItemsFromStorage,
|
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
import CustomStats from "./CustomStats";
|
import CustomStats from "./CustomStats";
|
||||||
import {
|
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
|
||||||
restore,
|
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
|
||||||
restoreAppState,
|
|
||||||
RestoredDataState,
|
|
||||||
} from "../packages/excalidraw/data/restore";
|
|
||||||
import {
|
import {
|
||||||
ExportToExcalidrawPlus,
|
ExportToExcalidrawPlus,
|
||||||
exportToExcalidrawPlus,
|
exportToExcalidrawPlus,
|
||||||
@ -83,10 +80,13 @@ import { updateStaleImageStatuses } from "./data/FileManager";
|
|||||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||||
import { loadFilesFromFirebase } from "./data/firebase";
|
import { loadFilesFromFirebase } from "./data/firebase";
|
||||||
import { LocalData } from "./data/LocalData";
|
import {
|
||||||
|
LibraryIndexedDBAdapter,
|
||||||
|
LibraryLocalStorageMigrationAdapter,
|
||||||
|
LocalData,
|
||||||
|
} from "./data/LocalData";
|
||||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { reconcileElements } from "./collab/reconciliation";
|
|
||||||
import {
|
import {
|
||||||
parseLibraryTokensFromUrl,
|
parseLibraryTokensFromUrl,
|
||||||
useHandleLibrary,
|
useHandleLibrary,
|
||||||
@ -99,12 +99,29 @@ import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
|||||||
import { appJotaiStore } from "./app-jotai";
|
import { appJotaiStore } from "./app-jotai";
|
||||||
|
|
||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
import { ResolutionType } from "../packages/excalidraw/utility-types";
|
import type { ResolutionType } from "../packages/excalidraw/utility-types";
|
||||||
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
|
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
|
||||||
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
|
||||||
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
|
||||||
import Trans from "../packages/excalidraw/components/Trans";
|
import Trans from "../packages/excalidraw/components/Trans";
|
||||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||||
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
|
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
|
||||||
|
import {
|
||||||
|
GithubIcon,
|
||||||
|
XBrandIcon,
|
||||||
|
DiscordIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
usersIcon,
|
||||||
|
exportToPlus,
|
||||||
|
share,
|
||||||
|
youtubeIcon,
|
||||||
|
} from "../packages/excalidraw/components/icons";
|
||||||
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@ -253,7 +270,7 @@ const initializeScene = async (opts: {
|
|||||||
},
|
},
|
||||||
elements: reconcileElements(
|
elements: reconcileElements(
|
||||||
scene?.elements || [],
|
scene?.elements || [],
|
||||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -284,6 +301,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||||
const isCollabDisabled = isRunningInIframe();
|
const isCollabDisabled = isRunningInIframe();
|
||||||
|
|
||||||
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const { editorTheme } = useHandleAppTheme();
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@ -313,10 +333,13 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||||
return isCollaborationLink(window.location.href);
|
return isCollaborationLink(window.location.href);
|
||||||
});
|
});
|
||||||
|
const collabError = useAtomValue(collabErrorIndicatorAtom);
|
||||||
|
|
||||||
useHandleLibrary({
|
useHandleLibrary({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
getInitialLibraryItems: getLibraryItemsFromStorage,
|
adapter: LibraryIndexedDBAdapter,
|
||||||
|
// TODO maybe remove this in several months (shipped: 24-03-11)
|
||||||
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -414,7 +437,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
...restore(data.scene, null, null, { repairBindings: true }),
|
...restore(data.scene, null, null, { repairBindings: true }),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -445,9 +468,14 @@ const ExcalidrawWrapper = () => {
|
|||||||
setLangCode(langCode);
|
setLangCode(langCode);
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
excalidrawAPI.updateLibrary({
|
LibraryIndexedDBAdapter.load().then((data) => {
|
||||||
libraryItems: getLibraryItemsFromStorage(),
|
if (data) {
|
||||||
|
excalidrawAPI.updateLibrary({
|
||||||
|
libraryItems: data.libraryItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
collabAPI?.setUsername(username || "");
|
collabAPI?.setUsername(username || "");
|
||||||
}
|
}
|
||||||
@ -542,25 +570,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
languageDetector.cacheUserLanguage(langCode);
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
}, [langCode]);
|
}, [langCode]);
|
||||||
|
|
||||||
const [theme, setTheme] = useState<Theme>(
|
|
||||||
() =>
|
|
||||||
(localStorage.getItem(
|
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
|
||||||
) as Theme | null) ||
|
|
||||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
|
||||||
importFromLocalStorage().appState?.theme ||
|
|
||||||
THEME.LIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
|
||||||
// currently only used for body styling during init (see public/index.html),
|
|
||||||
// but may change in the future
|
|
||||||
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => {
|
) => {
|
||||||
@ -568,8 +579,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
collabAPI.syncElements(elements);
|
collabAPI.syncElements(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(appState.theme);
|
|
||||||
|
|
||||||
// this check is redundant, but since this is a hot path, it's best
|
// this check is redundant, but since this is a hot path, it's best
|
||||||
// not to evaludate the nested expression every time
|
// not to evaludate the nested expression every time
|
||||||
if (!LocalData.isSavePaused()) {
|
if (!LocalData.isSavePaused()) {
|
||||||
@ -595,6 +604,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (didChange) {
|
if (didChange) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -659,15 +669,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLibraryChange = async (items: LibraryItems) => {
|
|
||||||
if (!items.length) {
|
|
||||||
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const serializedItems = JSON.stringify(items);
|
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
@ -694,6 +695,45 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExcalidrawPlusCommand = {
|
||||||
|
label: "Excalidraw+",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: ["plus", "cloud", "server"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ExcalidrawPlusAppCommand = {
|
||||||
|
label: "Sign up",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: [
|
||||||
|
"excalidraw",
|
||||||
|
"plus",
|
||||||
|
"cloud",
|
||||||
|
"server",
|
||||||
|
"signin",
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
@ -712,27 +752,30 @@ const ExcalidrawWrapper = () => {
|
|||||||
toggleTheme: true,
|
toggleTheme: true,
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
renderCustomUI: (elements, appState, files) => {
|
renderCustomUI: excalidrawAPI
|
||||||
return (
|
? (elements, appState, files) => {
|
||||||
<ExportToExcalidrawPlus
|
return (
|
||||||
elements={elements}
|
<ExportToExcalidrawPlus
|
||||||
appState={appState}
|
elements={elements}
|
||||||
files={files}
|
appState={appState}
|
||||||
onError={(error) => {
|
files={files}
|
||||||
excalidrawAPI?.updateScene({
|
name={excalidrawAPI.getName()}
|
||||||
appState: {
|
onError={(error) => {
|
||||||
errorMessage: error.message,
|
excalidrawAPI?.updateScene({
|
||||||
},
|
appState: {
|
||||||
});
|
errorMessage: error.message,
|
||||||
}}
|
},
|
||||||
onSuccess={() => {
|
});
|
||||||
excalidrawAPI?.updateScene({
|
}}
|
||||||
appState: { openDialog: null },
|
onSuccess={() => {
|
||||||
});
|
excalidrawAPI.updateScene({
|
||||||
}}
|
appState: { openDialog: null },
|
||||||
/>
|
});
|
||||||
);
|
}}
|
||||||
},
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -740,20 +783,22 @@ const ExcalidrawWrapper = () => {
|
|||||||
renderCustomStats={renderCustomStats}
|
renderCustomStats={renderCustomStats}
|
||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
onLibraryChange={onLibraryChange}
|
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={theme}
|
theme={editorTheme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LiveCollaborationTrigger
|
<div className="top-right-ui">
|
||||||
isCollaborating={isCollaborating}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
onSelect={() =>
|
<LiveCollaborationTrigger
|
||||||
setShareDialogState({ isOpen: true, type: "share" })
|
isCollaborating={isCollaborating}
|
||||||
}
|
onSelect={() =>
|
||||||
/>
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -761,6 +806,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
|
theme={appTheme}
|
||||||
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
/>
|
/>
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
@ -778,6 +825,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.getSceneElements(),
|
excalidrawAPI.getSceneElements(),
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
excalidrawAPI.getFiles(),
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -882,6 +930,181 @@ const ExcalidrawWrapper = () => {
|
|||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
customCommandPaletteItems={[
|
||||||
|
{
|
||||||
|
label: t("labels.liveCollaboration"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
keywords: [
|
||||||
|
"team",
|
||||||
|
"multiplayer",
|
||||||
|
"share",
|
||||||
|
"public",
|
||||||
|
"session",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
icon: usersIcon,
|
||||||
|
perform: () => {
|
||||||
|
setShareDialogState({
|
||||||
|
isOpen: true,
|
||||||
|
type: "collaborationOnly",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("roomDialog.button_stopSession"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: () => !!collabAPI?.isCollaborating(),
|
||||||
|
keywords: [
|
||||||
|
"stop",
|
||||||
|
"session",
|
||||||
|
"end",
|
||||||
|
"leave",
|
||||||
|
"close",
|
||||||
|
"exit",
|
||||||
|
"collaboration",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
if (collabAPI) {
|
||||||
|
collabAPI.stopCollaboration();
|
||||||
|
if (!collabAPI.isCollaborating()) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.share"),
|
||||||
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
predicate: true,
|
||||||
|
icon: share,
|
||||||
|
keywords: [
|
||||||
|
"link",
|
||||||
|
"shareable",
|
||||||
|
"readonly",
|
||||||
|
"export",
|
||||||
|
"publish",
|
||||||
|
"snapshot",
|
||||||
|
"url",
|
||||||
|
"collaborate",
|
||||||
|
"invite",
|
||||||
|
],
|
||||||
|
perform: async () => {
|
||||||
|
setShareDialogState({ isOpen: true, type: "share" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GitHub",
|
||||||
|
icon: GithubIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: [
|
||||||
|
"issues",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"features",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://github.com/excalidraw/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.followUs"),
|
||||||
|
icon: XBrandIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["twitter", "contact", "social", "community"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://x.com/excalidraw",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("labels.discordChat"),
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: DiscordIcon,
|
||||||
|
keywords: [
|
||||||
|
"chat",
|
||||||
|
"talk",
|
||||||
|
"contact",
|
||||||
|
"bugs",
|
||||||
|
"requests",
|
||||||
|
"report",
|
||||||
|
"feedback",
|
||||||
|
"suggestions",
|
||||||
|
"social",
|
||||||
|
"community",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://discord.gg/UexuTaE",
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "YouTube",
|
||||||
|
icon: youtubeIcon,
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["features", "tutorials", "howto", "help", "community"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
"https://youtube.com/@excalidraw",
|
||||||
|
"_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,
|
||||||
|
perform: () => {
|
||||||
|
setAppTheme(
|
||||||
|
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -7,8 +7,8 @@ import {
|
|||||||
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
|
||||||
import { t } from "../packages/excalidraw/i18n";
|
import { t } from "../packages/excalidraw/i18n";
|
||||||
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
|
||||||
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
|
||||||
import { UIAppState } from "../packages/excalidraw/types";
|
import type { UIAppState } from "../packages/excalidraw/types";
|
||||||
|
|
||||||
type StorageSizes = { scene: number; total: number };
|
type StorageSizes = { scene: number; total: number };
|
||||||
|
|
||||||
|
@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
LOCAL_STORAGE_ELEMENTS: "excalidraw",
|
||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
IDB_LIBRARY: "excalidraw-library",
|
||||||
|
|
||||||
|
// do not use apart from migrations
|
||||||
|
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const COOKIES = {
|
export const COOKIES = {
|
||||||
|
@ -1,22 +1,25 @@
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
SocketId,
|
SocketId,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
|
||||||
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
|
||||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
|
StoreAction,
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
restoreElements,
|
restoreElements,
|
||||||
zoomToFitBounds,
|
zoomToFitBounds,
|
||||||
} from "../../packages/excalidraw/index";
|
reconcileElements,
|
||||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw";
|
||||||
|
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
assertNever,
|
assertNever,
|
||||||
preventUnload,
|
preventUnload,
|
||||||
@ -33,12 +36,14 @@ import {
|
|||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
WS_EVENTS,
|
WS_EVENTS,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
|
import type {
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
generateCollaborationLinkData,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLink,
|
getCollaborationLink,
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
SocketUpdateDataSource,
|
|
||||||
SyncableExcalidrawElement,
|
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
@ -69,18 +74,19 @@ import {
|
|||||||
isInitializedImageElement,
|
isInitializedImageElement,
|
||||||
} from "../../packages/excalidraw/element/typeChecks";
|
} from "../../packages/excalidraw/element/typeChecks";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import {
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements as _reconcileElements,
|
|
||||||
} from "./reconciliation";
|
|
||||||
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
import { decryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
import { appJotaiStore } from "../app-jotai";
|
||||||
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
|
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||||
|
import type {
|
||||||
|
ReconciledExcalidrawElement,
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
@ -88,6 +94,8 @@ export const isOfflineAtom = atom(false);
|
|||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
/** errors related to saving */
|
||||||
|
dialogNotifiedErrors: Record<string, boolean>;
|
||||||
username: string;
|
username: string;
|
||||||
activeRoomLink: string | null;
|
activeRoomLink: string | null;
|
||||||
}
|
}
|
||||||
@ -107,7 +115,7 @@ export interface CollabAPI {
|
|||||||
setUsername: CollabInstance["setUsername"];
|
setUsername: CollabInstance["setUsername"];
|
||||||
getUsername: CollabInstance["getUsername"];
|
getUsername: CollabInstance["getUsername"];
|
||||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||||
setErrorMessage: CollabInstance["setErrorMessage"];
|
setCollabError: CollabInstance["setErrorDialog"];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollabProps {
|
interface CollabProps {
|
||||||
@ -129,6 +137,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
activeRoomLink: null,
|
activeRoomLink: null,
|
||||||
};
|
};
|
||||||
@ -197,7 +206,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
setUsername: this.setUsername,
|
setUsername: this.setUsername,
|
||||||
getUsername: this.getUsername,
|
getUsername: this.getUsername,
|
||||||
getActiveRoomLink: this.getActiveRoomLink,
|
getActiveRoomLink: this.getActiveRoomLink,
|
||||||
setErrorMessage: this.setErrorMessage,
|
setCollabError: this.setErrorDialog,
|
||||||
};
|
};
|
||||||
|
|
||||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||||
@ -270,24 +279,39 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const savedData = await saveToFirebase(
|
const storedElements = await saveToFirebase(
|
||||||
this.portal,
|
this.portal,
|
||||||
syncableElements,
|
syncableElements,
|
||||||
this.excalidrawAPI.getAppState(),
|
this.excalidrawAPI.getAppState(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
this.resetErrorIndicator();
|
||||||
this.handleRemoteSceneUpdate(
|
|
||||||
this.reconcileElements(savedData.reconciledElements),
|
if (this.isCollaborating() && storedElements) {
|
||||||
);
|
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.setState({
|
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||||
// firestore doesn't return a specific error code when size exceeded
|
? t("errors.collabSaveFailed_sizeExceeded")
|
||||||
errorMessage: /is longer than.*?bytes/.test(error.message)
|
: t("errors.collabSaveFailed");
|
||||||
? t("errors.collabSaveFailed_sizeExceeded")
|
|
||||||
: t("errors.collabSaveFailed"),
|
if (
|
||||||
});
|
!this.state.dialogNotifiedErrors[errorMessage] ||
|
||||||
|
!this.isCollaborating()
|
||||||
|
) {
|
||||||
|
this.setErrorDialog(errorMessage);
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {
|
||||||
|
...this.state.dialogNotifiedErrors,
|
||||||
|
[errorMessage]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCollaborating()) {
|
||||||
|
this.setErrorIndicator(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -296,6 +320,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToFirebase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
|
this.resetErrorIndicator(true);
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
@ -334,7 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -407,7 +432,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
startCollaboration = async (
|
startCollaboration = async (
|
||||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
): Promise<ImportedDataState | null> => {
|
) => {
|
||||||
if (!this.state.username) {
|
if (!this.state.username) {
|
||||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||||
const username = getRandomUsername();
|
const username = getRandomUsername();
|
||||||
@ -433,7 +458,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
// TODO: `ImportedDataState` type here seems abused
|
||||||
|
const scenePromise = resolvablePromise<
|
||||||
|
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
|
||||||
|
| null
|
||||||
|
>();
|
||||||
|
|
||||||
this.setIsCollaborating(true);
|
this.setIsCollaborating(true);
|
||||||
LocalData.pauseSave("collaboration");
|
LocalData.pauseSave("collaboration");
|
||||||
@ -464,7 +493,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
this.portal.socket.once("connect_error", fallbackInitializationHandler);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setErrorDialog(error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -475,14 +504,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
});
|
});
|
||||||
// remove deleted elements from elements array & history to ensure we don't
|
// remove deleted elements from elements array to ensure we don't
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
// existing elements (or clears scene), which would otherwise be persisted
|
// existing elements (or clears scene), which would otherwise be persisted
|
||||||
// to database even if deleted before creating the room.
|
// to database even if deleted before creating the room.
|
||||||
this.excalidrawAPI.history.clear();
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
@ -516,10 +544,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
this.initializeRoom({ fetchScene: false });
|
this.initializeRoom({ fetchScene: false });
|
||||||
const remoteElements = decryptedData.payload.elements;
|
const remoteElements = decryptedData.payload.elements;
|
||||||
const reconciledElements = this.reconcileElements(remoteElements);
|
const reconciledElements =
|
||||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
this._reconcileElements(remoteElements);
|
||||||
init: true,
|
this.handleRemoteSceneUpdate(reconciledElements);
|
||||||
});
|
|
||||||
// noop if already resolved via init from firebase
|
// noop if already resolved via init from firebase
|
||||||
scenePromise.resolve({
|
scenePromise.resolve({
|
||||||
elements: reconciledElements,
|
elements: reconciledElements,
|
||||||
@ -530,7 +557,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}
|
}
|
||||||
case WS_SUBTYPES.UPDATE:
|
case WS_SUBTYPES.UPDATE:
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(
|
||||||
this.reconcileElements(decryptedData.payload.elements),
|
this._reconcileElements(decryptedData.payload.elements),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||||
@ -678,17 +705,15 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
private reconcileElements = (
|
private _reconcileElements = (
|
||||||
remoteElements: readonly ExcalidrawElement[],
|
remoteElements: readonly ExcalidrawElement[],
|
||||||
): ReconciledElements => {
|
): ReconciledExcalidrawElement[] => {
|
||||||
const localElements = this.getSceneElementsIncludingDeleted();
|
const localElements = this.getSceneElementsIncludingDeleted();
|
||||||
const appState = this.excalidrawAPI.getAppState();
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||||
remoteElements = restoreElements(remoteElements, null);
|
const reconciledElements = reconcileElements(
|
||||||
|
|
||||||
const reconciledElements = _reconcileElements(
|
|
||||||
localElements,
|
localElements,
|
||||||
remoteElements,
|
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||||
appState,
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -719,20 +744,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}, LOAD_IMAGES_TIMEOUT);
|
}, LOAD_IMAGES_TIMEOUT);
|
||||||
|
|
||||||
private handleRemoteSceneUpdate = (
|
private handleRemoteSceneUpdate = (
|
||||||
elements: ReconciledElements,
|
elements: ReconciledExcalidrawElement[],
|
||||||
{ init = false }: { init?: boolean } = {},
|
|
||||||
) => {
|
) => {
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: !!init,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
|
||||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
|
||||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
|
||||||
// right now we think this is the right tradeoff.
|
|
||||||
this.excalidrawAPI.history.clear();
|
|
||||||
|
|
||||||
this.loadImageFiles();
|
this.loadImageFiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -865,7 +883,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
|
||||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
if (
|
if (
|
||||||
getSceneVersion(elements) >
|
getSceneVersion(elements) >
|
||||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||||
@ -876,7 +894,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
this.broadcastElements(elements);
|
this.broadcastElements(elements);
|
||||||
this.queueSaveToFirebase();
|
this.queueSaveToFirebase();
|
||||||
};
|
};
|
||||||
@ -923,8 +941,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
getActiveRoomLink = () => this.state.activeRoomLink;
|
getActiveRoomLink = () => this.state.activeRoomLink;
|
||||||
|
|
||||||
setErrorMessage = (errorMessage: string | null) => {
|
setErrorIndicator = (errorMessage: string | null) => {
|
||||||
this.setState({ errorMessage });
|
appJotaiStore.set(collabErrorIndicatorAtom, {
|
||||||
|
message: errorMessage,
|
||||||
|
nonce: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
|
||||||
|
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
|
||||||
|
if (resetDialogNotifiedErrors) {
|
||||||
|
this.setState({
|
||||||
|
dialogNotifiedErrors: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setErrorDialog = (errorMessage: string | null) => {
|
||||||
|
this.setState({
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -933,7 +969,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{errorMessage != null && (
|
{errorMessage != null && (
|
||||||
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
|
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</ErrorDialog>
|
</ErrorDialog>
|
||||||
)}
|
)}
|
||||||
|
35
excalidraw-app/collab/CollabError.scss
Normal file
35
excalidraw-app/collab/CollabError.scss
Normal file
@ -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(0deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
excalidraw-app/collab/CollabError.tsx
Normal file
54
excalidraw-app/collab/CollabError.tsx
Normal file
@ -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;
|
@ -1,14 +1,15 @@
|
|||||||
import {
|
import type {
|
||||||
isSyncableElement,
|
|
||||||
SocketUpdateData,
|
SocketUpdateData,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
import { isSyncableElement } from "../data";
|
||||||
|
|
||||||
import { TCollabClass } from "./Collab";
|
import type { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||||
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
|
||||||
import {
|
import type {
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
@ -16,10 +17,9 @@ import {
|
|||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { BroadcastedExcalidrawElement } from "./reconciliation";
|
|
||||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import { StoreAction } from "../../packages/excalidraw";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: TCollabClass;
|
collab: TCollabClass;
|
||||||
@ -128,12 +128,13 @@ class Portal {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
broadcastScene = async (
|
broadcastScene = async (
|
||||||
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
|
||||||
allElements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
syncAll: boolean,
|
syncAll: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
|
||||||
@ -143,25 +144,17 @@ class Portal {
|
|||||||
// sync out only the elements we think we need to to save bandwidth.
|
// sync out only the elements we think we need to to save bandwidth.
|
||||||
// periodically we'll resync the whole thing to make sure no one diverges
|
// periodically we'll resync the whole thing to make sure no one diverges
|
||||||
// due to a dropped message (server goes down etc).
|
// due to a dropped message (server goes down etc).
|
||||||
const syncableElements = allElements.reduce(
|
const syncableElements = elements.reduce((acc, element) => {
|
||||||
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
|
if (
|
||||||
if (
|
(syncAll ||
|
||||||
(syncAll ||
|
!this.broadcastedElementVersions.has(element.id) ||
|
||||||
!this.broadcastedElementVersions.has(element.id) ||
|
element.version > this.broadcastedElementVersions.get(element.id)!) &&
|
||||||
element.version >
|
isSyncableElement(element)
|
||||||
this.broadcastedElementVersions.get(element.id)!) &&
|
) {
|
||||||
isSyncableElement(element)
|
acc.push(element);
|
||||||
) {
|
}
|
||||||
acc.push({
|
return acc;
|
||||||
...element,
|
}, [] as SyncableExcalidrawElement[]);
|
||||||
// z-index info for the reconciler
|
|
||||||
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[] as BroadcastedExcalidrawElement[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||||
type: updateType,
|
type: updateType,
|
||||||
|
@ -65,19 +65,18 @@ export const RoomModal = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
@ -1,154 +0,0 @@
|
|||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
export type ReconciledElements = readonly ExcalidrawElement[] & {
|
|
||||||
_brand: "reconciledElements";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldDiscardRemoteElement = (
|
|
||||||
localAppState: AppState,
|
|
||||||
local: ExcalidrawElement | undefined,
|
|
||||||
remote: BroadcastedExcalidrawElement,
|
|
||||||
): boolean => {
|
|
||||||
if (
|
|
||||||
local &&
|
|
||||||
// local element is being edited
|
|
||||||
(local.id === localAppState.editingElement?.id ||
|
|
||||||
local.id === localAppState.resizingElement?.id ||
|
|
||||||
local.id === localAppState.draggingElement?.id ||
|
|
||||||
// local element is newer
|
|
||||||
local.version > remote.version ||
|
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
|
||||||
// the lowest versionNonce
|
|
||||||
(local.version === remote.version &&
|
|
||||||
local.versionNonce < remote.versionNonce))
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reconcileElements = (
|
|
||||||
localElements: readonly ExcalidrawElement[],
|
|
||||||
remoteElements: readonly BroadcastedExcalidrawElement[],
|
|
||||||
localAppState: AppState,
|
|
||||||
): ReconciledElements => {
|
|
||||||
const localElementsData =
|
|
||||||
arrayToMapWithIndex<ExcalidrawElement>(localElements);
|
|
||||||
|
|
||||||
const reconciledElements: ExcalidrawElement[] = localElements.slice();
|
|
||||||
|
|
||||||
const duplicates = new WeakMap<ExcalidrawElement, true>();
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
let remoteElementIdx = -1;
|
|
||||||
for (const remoteElement of remoteElements) {
|
|
||||||
remoteElementIdx++;
|
|
||||||
|
|
||||||
const local = localElementsData.get(remoteElement.id);
|
|
||||||
|
|
||||||
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
|
|
||||||
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
|
||||||
if (local) {
|
|
||||||
// Unless the remote and local elements are the same element in which case
|
|
||||||
// we need to keep it as we'd otherwise discard it from the resulting
|
|
||||||
// array.
|
|
||||||
if (local[0] === remoteElement) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
duplicates.set(local[0], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// parent may not be defined in case the remote client is running an older
|
|
||||||
// excalidraw version
|
|
||||||
const parent =
|
|
||||||
remoteElement[PRECEDING_ELEMENT_KEY] ||
|
|
||||||
remoteElements[remoteElementIdx - 1]?.id ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
if (parent != null) {
|
|
||||||
delete remoteElement[PRECEDING_ELEMENT_KEY];
|
|
||||||
|
|
||||||
// ^ indicates the element is the first in elements array
|
|
||||||
if (parent === "^") {
|
|
||||||
offset++;
|
|
||||||
if (cursor === 0) {
|
|
||||||
reconciledElements.unshift(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor - offset,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let idx = localElementsData.has(parent)
|
|
||||||
? localElementsData.get(parent)![1]
|
|
||||||
: null;
|
|
||||||
if (idx != null) {
|
|
||||||
idx += offset;
|
|
||||||
}
|
|
||||||
if (idx != null && idx >= cursor) {
|
|
||||||
reconciledElements.splice(idx + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
idx + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor = idx + 1;
|
|
||||||
} else if (idx != null) {
|
|
||||||
reconciledElements.splice(cursor + 1, 0, remoteElement);
|
|
||||||
offset++;
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
cursor + 1 - offset,
|
|
||||||
]);
|
|
||||||
cursor++;
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// no parent z-index information, local element exists → replace in place
|
|
||||||
} else if (local) {
|
|
||||||
reconciledElements[local[1]] = remoteElement;
|
|
||||||
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
|
|
||||||
// otherwise push to the end
|
|
||||||
} else {
|
|
||||||
reconciledElements.push(remoteElement);
|
|
||||||
localElementsData.set(remoteElement.id, [
|
|
||||||
remoteElement,
|
|
||||||
reconciledElements.length - 1 - offset,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
|
|
||||||
(element) => !duplicates.has(element),
|
|
||||||
);
|
|
||||||
|
|
||||||
return ret as ReconciledElements;
|
|
||||||
};
|
|
@ -1,12 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
import {
|
||||||
|
loginIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { LanguageList } from "./LanguageList";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
|
theme: Theme | "system";
|
||||||
|
setTheme: (theme: Theme | "system") => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@ -20,22 +27,35 @@ export const AppMainMenu: React.FC<{
|
|||||||
onSelect={() => props.onCollabDialogOpen()}
|
onSelect={() => props.onCollabDialogOpen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={PlusPromoIcon}
|
icon={ExcalLogo}
|
||||||
href={`${
|
href={`${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
className="ExcalidrawPlus"
|
className=""
|
||||||
>
|
>
|
||||||
Excalidraw+
|
Excalidraw+
|
||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
|
<MainMenu.ItemLink
|
||||||
|
icon={loginIcon}
|
||||||
|
href={`${import.meta.env.VITE_APP_PLUS_APP}${
|
||||||
|
isExcalidrawPlusSignedUser ? "" : "/sign-up"
|
||||||
|
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||||
|
className="highlighted"
|
||||||
|
>
|
||||||
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme />
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
|
allowSystemTheme
|
||||||
|
theme={props.theme}
|
||||||
|
onSelect={props.setTheme}
|
||||||
|
/>
|
||||||
<MainMenu.ItemCustom>
|
<MainMenu.ItemCustom>
|
||||||
<LanguageList style={{ width: "100%" }} />
|
<LanguageList style={{ width: "100%" }} />
|
||||||
</MainMenu.ItemCustom>
|
</MainMenu.ItemCustom>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
|
import { loginIcon } from "../../packages/excalidraw/components/icons";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
|
|||||||
import.meta.env.VITE_APP_PLUS_LP
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
||||||
shortcut={null}
|
shortcut={null}
|
||||||
icon={PlusPromoIcon}
|
icon={loginIcon}
|
||||||
>
|
>
|
||||||
Try Excalidraw Plus!
|
Sign up
|
||||||
</WelcomeScreen.Center.MenuItemLink>
|
</WelcomeScreen.Center.MenuItemLink>
|
||||||
)}
|
)}
|
||||||
</WelcomeScreen.Center.Menu>
|
</WelcomeScreen.Center.Menu>
|
||||||
|
@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
|
|||||||
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||||
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||||
import {
|
import type {
|
||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Partial<AppState>,
|
appState: Partial<AppState>,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
|
name: string,
|
||||||
) => {
|
) => {
|
||||||
const firebase = await loadFirebaseStorage();
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
|
|||||||
.ref(`/migrations/scenes/${id}`)
|
.ref(`/migrations/scenes/${id}`)
|
||||||
.put(blob, {
|
.put(blob, {
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
data: JSON.stringify({ version: 2, name: appState.name }),
|
data: JSON.stringify({ version: 2, name }),
|
||||||
created: Date.now().toString(),
|
created: Date.now().toString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
appState: Partial<AppState>;
|
appState: Partial<AppState>;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
|
name: string;
|
||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}> = ({ elements, appState, files, onError, onSuccess }) => {
|
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<Card color="primary">
|
<Card color="primary">
|
||||||
@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||||
await exportToExcalidrawPlus(elements, appState, files);
|
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { THEME } from "../../packages/excalidraw/constants";
|
import { THEME } from "../../packages/excalidraw/constants";
|
||||||
import { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
|
@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
|
|||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
|
import { StoreAction } from "../../packages/excalidraw";
|
||||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
FileId,
|
FileId,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import {
|
import type {
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFileMetadata,
|
BinaryFileMetadata,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -10,18 +10,29 @@
|
|||||||
* (localStorage, indexedDB).
|
* (localStorage, indexedDB).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
|
|
||||||
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
|
||||||
import {
|
import {
|
||||||
|
createStore,
|
||||||
|
entries,
|
||||||
|
del,
|
||||||
|
getMany,
|
||||||
|
set,
|
||||||
|
setMany,
|
||||||
|
get,
|
||||||
|
} from "idb-keyval";
|
||||||
|
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
|
||||||
|
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
|
||||||
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
|
||||||
import { debounce } from "../../packages/excalidraw/utils";
|
import { debounce } from "../../packages/excalidraw/utils";
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
@ -183,3 +194,52 @@ export class LocalData {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export class LibraryIndexedDBAdapter {
|
||||||
|
/** IndexedDB database and store name */
|
||||||
|
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
|
||||||
|
/** library data store key */
|
||||||
|
private static key = "libraryData";
|
||||||
|
|
||||||
|
private static store = createStore(
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-db`,
|
||||||
|
`${LibraryIndexedDBAdapter.idb_name}-store`,
|
||||||
|
);
|
||||||
|
|
||||||
|
static async load() {
|
||||||
|
const IDBData = await get<LibraryPersistedData>(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
|
||||||
|
return IDBData || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static save(data: LibraryPersistedData): MaybePromise<void> {
|
||||||
|
return set(
|
||||||
|
LibraryIndexedDBAdapter.key,
|
||||||
|
data,
|
||||||
|
LibraryIndexedDBAdapter.store,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LS Adapter used only for migrating LS library data
|
||||||
|
* to indexedDB */
|
||||||
|
export class LibraryLocalStorageMigrationAdapter {
|
||||||
|
static load() {
|
||||||
|
const LSData = localStorage.getItem(
|
||||||
|
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
|
||||||
|
);
|
||||||
|
if (LSData != null) {
|
||||||
|
const libraryItems: ImportedDataState["libraryItems"] =
|
||||||
|
JSON.parse(LSData);
|
||||||
|
if (libraryItems) {
|
||||||
|
return { libraryItems };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
static clear() {
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import {
|
import { reconcileElements } from "../../packages/excalidraw";
|
||||||
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { getSceneVersion } from "../../packages/excalidraw/element";
|
import { getSceneVersion } from "../../packages/excalidraw/element";
|
||||||
import Portal from "../collab/Portal";
|
import type Portal from "../collab/Portal";
|
||||||
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
import { restoreElements } from "../../packages/excalidraw/data/restore";
|
||||||
import {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFileMetadata,
|
BinaryFileMetadata,
|
||||||
@ -18,10 +20,11 @@ import {
|
|||||||
decryptData,
|
decryptData,
|
||||||
} from "../../packages/excalidraw/data/encryption";
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
import { reconcileElements } from "../collab/reconciliation";
|
import type { SyncableExcalidrawElement } from ".";
|
||||||
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
import { getSyncableElements } from ".";
|
||||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@ -230,7 +233,7 @@ export const saveToFirebase = async (
|
|||||||
!socket ||
|
!socket ||
|
||||||
isSavedToFirebase(portal, elements)
|
isSavedToFirebase(portal, elements)
|
||||||
) {
|
) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
@ -238,56 +241,59 @@ export const saveToFirebase = async (
|
|||||||
|
|
||||||
const docRef = firestore.collection("scenes").doc(roomId);
|
const docRef = firestore.collection("scenes").doc(roomId);
|
||||||
|
|
||||||
const savedData = await firestore.runTransaction(async (transaction) => {
|
const storedScene = await firestore.runTransaction(async (transaction) => {
|
||||||
const snapshot = await transaction.get(docRef);
|
const snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
if (!snapshot.exists) {
|
if (!snapshot.exists) {
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
elements,
|
elements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.set(docRef, sceneDocument);
|
transaction.set(docRef, storedScene);
|
||||||
|
|
||||||
return {
|
return storedScene;
|
||||||
elements,
|
|
||||||
reconciledElements: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
|
||||||
const prevElements = getSyncableElements(
|
const prevStoredElements = getSyncableElements(
|
||||||
await decryptElements(prevDocData, roomKey),
|
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reconciledElements = getSyncableElements(
|
const reconciledElements = getSyncableElements(
|
||||||
reconcileElements(elements, prevElements, appState),
|
reconcileElements(
|
||||||
|
elements,
|
||||||
|
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
|
||||||
|
appState,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sceneDocument = await createFirebaseSceneDocument(
|
const storedScene = await createFirebaseSceneDocument(
|
||||||
firebase,
|
firebase,
|
||||||
reconciledElements,
|
reconciledElements,
|
||||||
roomKey,
|
roomKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
transaction.update(docRef, sceneDocument);
|
transaction.update(docRef, storedScene);
|
||||||
return {
|
|
||||||
elements,
|
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
|
||||||
reconciledElements,
|
return storedScene;
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
FirebaseSceneVersionCache.set(socket, savedData.elements);
|
const storedElements = getSyncableElements(
|
||||||
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
|
);
|
||||||
|
|
||||||
return { reconciledElements: savedData.reconciledElements };
|
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||||
|
|
||||||
|
return storedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly SyncableExcalidrawElement[] | null> => {
|
||||||
const firebase = await loadFirestore();
|
const firebase = await loadFirestore();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
|
|
||||||
@ -298,14 +304,14 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
await decryptElements(storedScene, roomKey),
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
FirebaseSceneVersionCache.set(socket, elements);
|
FirebaseSceneVersionCache.set(socket, elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElements(elements, null);
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFilesFromFirebase = async (
|
export const loadFilesFromFirebase = async (
|
||||||
|
@ -9,38 +9,39 @@ import {
|
|||||||
} from "../../packages/excalidraw/data/encryption";
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||||
import { restore } from "../../packages/excalidraw/data/restore";
|
import { restore } from "../../packages/excalidraw/data/restore";
|
||||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||||
|
import type { WS_SUBTYPES } from "../app_constants";
|
||||||
import {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
FILE_UPLOAD_MAX_BYTES,
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
ROOM_ID_BYTES,
|
ROOM_ID_BYTES,
|
||||||
WS_SUBTYPES,
|
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
import { encodeFilesForUpload } from "./FileManager";
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
import { saveFilesToFirebase } from "./firebase";
|
import { saveFilesToFirebase } from "./firebase";
|
||||||
|
|
||||||
export type SyncableExcalidrawElement = ExcalidrawElement & {
|
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||||
_brand: "SyncableExcalidrawElement";
|
MakeBrand<"SyncableExcalidrawElement">;
|
||||||
};
|
|
||||||
|
|
||||||
export const isSyncableElement = (
|
export const isSyncableElement = (
|
||||||
element: ExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
): element is SyncableExcalidrawElement => {
|
): element is SyncableExcalidrawElement => {
|
||||||
if (element.isDeleted) {
|
if (element.isDeleted) {
|
||||||
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
|
||||||
@ -51,7 +52,9 @@ export const isSyncableElement = (
|
|||||||
return !isInvisiblySmallElement(element);
|
return !isInvisiblySmallElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
export const getSyncableElements = (
|
||||||
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
|
) =>
|
||||||
elements.filter((element) =>
|
elements.filter((element) =>
|
||||||
isSyncableElement(element),
|
isSyncableElement(element),
|
||||||
) as SyncableExcalidrawElement[];
|
) as SyncableExcalidrawElement[];
|
||||||
@ -266,7 +269,6 @@ export const loadScene = async (
|
|||||||
// in the scene database/localStorage, and instead fetch them async
|
// in the scene database/localStorage, and instead fetch them async
|
||||||
// from a different database
|
// from a different database
|
||||||
files: data.files,
|
files: data.files,
|
||||||
commitToHistory: false,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
import type { AppState } from "../../packages/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
clearAppStateForLocalStorage,
|
clearAppStateForLocalStorage,
|
||||||
getDefaultAppState,
|
getDefaultAppState,
|
||||||
} from "../../packages/excalidraw/appState";
|
} from "../../packages/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
try {
|
try {
|
||||||
@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
|
|||||||
try {
|
try {
|
||||||
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
|
||||||
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||||
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
||||||
|
|
||||||
const appStateSize = appState?.length || 0;
|
const appStateSize = appState?.length || 0;
|
||||||
const collabSize = collab?.length || 0;
|
const collabSize = collab?.length || 0;
|
||||||
const librarySize = library?.length || 0;
|
|
||||||
|
|
||||||
return appStateSize + collabSize + librarySize + getElementsStorageSize();
|
return appStateSize + collabSize + getElementsStorageSize();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLibraryItemsFromStorage = () => {
|
|
||||||
try {
|
|
||||||
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
|
||||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
|
||||||
);
|
|
||||||
|
|
||||||
return libraryItems || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -64,12 +64,30 @@
|
|||||||
<!-- to minimize white flash on load when user has dark mode enabled -->
|
<!-- to minimize white flash on load when user has dark mode enabled -->
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
//
|
function setTheme(theme) {
|
||||||
const theme = window.localStorage.getItem("excalidraw-theme");
|
if (theme === "dark") {
|
||||||
if (theme === "dark") {
|
document.documentElement.classList.add("dark");
|
||||||
document.documentElement.classList.add("dark");
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
|
function getTheme() {
|
||||||
|
const theme = window.localStorage.getItem("excalidraw-theme");
|
||||||
|
|
||||||
|
if (theme && theme === "system") {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
} else {
|
||||||
|
return theme || "light";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(getTheme());
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error setting dark mode", e);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
html.dark {
|
html.dark {
|
||||||
@ -78,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!------------------------------------------------------------------------->
|
<!------------------------------------------------------------------------->
|
||||||
<% if ("%PROD%" === "true") { %>
|
<% if (typeof PROD != 'undefined' && PROD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
// Redirect Excalidraw+ users which have auto-redirect enabled.
|
||||||
//
|
//
|
||||||
@ -122,7 +140,8 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
|
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
||||||
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
|
||||||
<script>
|
<script>
|
||||||
{
|
{
|
||||||
const _WebSocket = window.WebSocket;
|
const _WebSocket = window.WebSocket;
|
||||||
@ -196,7 +215,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="index.tsx"></script>
|
<script type="module" src="index.tsx"></script>
|
||||||
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
|
|
||||||
<!-- 100% privacy friendly analytics -->
|
<!-- 100% privacy friendly analytics -->
|
||||||
<script>
|
<script>
|
||||||
// need to load this script dynamically bcs. of iframe embed tracking
|
// need to load this script dynamically bcs. of iframe embed tracking
|
||||||
@ -229,6 +247,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
<% } %>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -4,6 +4,13 @@
|
|||||||
&.theme--dark {
|
&.theme--dark {
|
||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-right-ui {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@ -31,8 +38,12 @@
|
|||||||
background-color: #ecfdf5;
|
background-color: #ecfdf5;
|
||||||
color: #064e3c;
|
color: #064e3c;
|
||||||
}
|
}
|
||||||
&.ExcalidrawPlus {
|
&.highlighted {
|
||||||
color: var(--color-promo);
|
color: var(--color-promo);
|
||||||
|
font-weight: 700;
|
||||||
|
.dropdown-menu-item__icon g {
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,9 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"vite-plugin-html": "3.2.2"
|
||||||
|
},
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
|
||||||
import { trackEvent } from "../../packages/excalidraw/analytics";
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
@ -18,10 +18,12 @@ import {
|
|||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import { TextField } from "../../packages/excalidraw/components/TextField";
|
import { TextField } from "../../packages/excalidraw/components/TextField";
|
||||||
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
|
||||||
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
|
import type { CollabAPI } from "../collab/Collab";
|
||||||
|
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||||
import { atom, useAtom, useAtomValue } from "jotai";
|
import { atom, useAtom, useAtomValue } from "jotai";
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
@ -69,20 +71,20 @@ const ActiveRoomDialog = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
|
} catch (e) {
|
||||||
setJustCopied(true);
|
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
|
||||||
if (timerRef.current) {
|
|
||||||
window.clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
setJustCopied(false);
|
|
||||||
}, 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
collabAPI.setErrorMessage(error.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setJustCopied(true);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setJustCopied(false);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
ref.current?.select();
|
ref.current?.select();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -275,6 +277,14 @@ export const ShareDialog = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||||
|
|
||||||
|
const { openDialog } = useUIAppState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openDialog) {
|
||||||
|
setShareDialogState({ isOpen: false });
|
||||||
|
}
|
||||||
|
}, [openDialog, setShareDialogState]);
|
||||||
|
|
||||||
if (!shareDialogState.isOpen) {
|
if (!shareDialogState.isOpen) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -285,6 +295,6 @@ export const ShareDialog = (props: {
|
|||||||
collabAPI={props.collabAPI}
|
collabAPI={props.collabAPI}
|
||||||
onExportToBackend={props.onExportToBackend}
|
onExportToBackend={props.onExportToBackend}
|
||||||
type={shareDialogState.type}
|
type={shareDialogState.type}
|
||||||
></ShareDialogInner>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
<rect
|
<path
|
||||||
height="4"
|
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
|
||||||
rx="1"
|
|
||||||
width="18"
|
|
||||||
x="3"
|
|
||||||
y="8"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="12"
|
|
||||||
x2="12"
|
|
||||||
y1="8"
|
|
||||||
y2="21"
|
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
|
d="M21 12h-13l3 -3"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
|
d="M11 15l-3 -3"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<div
|
<div
|
||||||
class="welcome-screen-menu-item__text"
|
class="welcome-screen-menu-item__text"
|
||||||
>
|
>
|
||||||
Try Excalidraw Plus!
|
Sign up
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,12 +1,19 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
render,
|
render,
|
||||||
updateSceneData,
|
updateSceneData,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "../../packages/excalidraw/tests/test-utils";
|
} from "../../packages/excalidraw/tests/test-utils";
|
||||||
import ExcalidrawApp from "../App";
|
import ExcalidrawApp from "../App";
|
||||||
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
||||||
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
|
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
|
||||||
|
import {
|
||||||
|
createRedoAction,
|
||||||
|
createUndoAction,
|
||||||
|
} from "../../packages/excalidraw/actions/actionHistory";
|
||||||
|
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
Object.defineProperty(window, "crypto", {
|
Object.defineProperty(window, "crypto", {
|
||||||
@ -56,39 +63,190 @@ vi.mock("socket.io-client", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
|
||||||
|
* while having access to both scenes, appstates stores, histories and etc.
|
||||||
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
it("creating room should reset deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
// To update the scene with deleted elements before starting collab
|
const rect1Props = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "A",
|
||||||
|
height: 200,
|
||||||
|
width: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect2Props = {
|
||||||
|
type: "rectangle",
|
||||||
|
id: "B",
|
||||||
|
width: 100,
|
||||||
|
height: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rect1 = API.createElement({ ...rect1Props });
|
||||||
|
const rect2 = API.createElement({ ...rect2Props });
|
||||||
|
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: [
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
API.createElement({ type: "rectangle", id: "A" }),
|
storeAction: StoreAction.CAPTURE,
|
||||||
API.createElement({
|
});
|
||||||
type: "rectangle",
|
|
||||||
id: "B",
|
updateSceneData({
|
||||||
isDeleted: true,
|
elements: syncInvalidIndices([
|
||||||
}),
|
rect1,
|
||||||
],
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
});
|
]),
|
||||||
await waitFor(() => {
|
storeAction: StoreAction.CAPTURE,
|
||||||
expect(h.elements).toEqual([
|
|
||||||
expect.objectContaining({ id: "A" }),
|
|
||||||
expect.objectContaining({ id: "B", isDeleted: true }),
|
|
||||||
]);
|
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
|
||||||
});
|
|
||||||
window.collab.startCollaboration(null);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const undoAction = createUndoAction(h.history);
|
|
||||||
// noop
|
|
||||||
h.app.actionManager.executeAction(undoAction);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getStateHistory().length).toBe(1);
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
|
||||||
|
window.collab.startCollaboration(null);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
// we never delete from the local snapshot as it is used for correct diff calculation
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const undoAction = createUndoAction(h.history, h.store);
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate force deleting the element remotely
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const redoAction = createRedoAction(h.history, h.store);
|
||||||
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// simulate local update
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([
|
||||||
|
h.elements[0],
|
||||||
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
|
]),
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
|
// we expect to iterate the stack to the first visible change
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate force deleting the element remotely
|
||||||
|
updateSceneData({
|
||||||
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// snapshot was correctly updated and marked the element as deleted
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining(rect1Props),
|
||||||
|
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||||
|
]);
|
||||||
|
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
|
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSnapshot()).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
|
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||||
|
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,421 +0,0 @@
|
|||||||
import { expect } from "chai";
|
|
||||||
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
|
||||||
import {
|
|
||||||
BroadcastedExcalidrawElement,
|
|
||||||
ReconciledElements,
|
|
||||||
reconcileElements,
|
|
||||||
} from "../../excalidraw-app/collab/reconciliation";
|
|
||||||
import { randomInteger } from "../../packages/excalidraw/random";
|
|
||||||
import { AppState } from "../../packages/excalidraw/types";
|
|
||||||
import { cloneJSON } from "../../packages/excalidraw/utils";
|
|
||||||
|
|
||||||
type Id = string;
|
|
||||||
type ElementLike = {
|
|
||||||
id: string;
|
|
||||||
version: number;
|
|
||||||
versionNonce: number;
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Cache = Record<string, ExcalidrawElement | undefined>;
|
|
||||||
|
|
||||||
const createElement = (opts: { uid: string } | ElementLike) => {
|
|
||||||
let uid: string;
|
|
||||||
let id: string;
|
|
||||||
let version: number | null;
|
|
||||||
let parent: string | null = null;
|
|
||||||
let versionNonce: number | null = null;
|
|
||||||
if ("uid" in opts) {
|
|
||||||
const match = opts.uid.match(
|
|
||||||
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
|
|
||||||
)!;
|
|
||||||
parent = match[1];
|
|
||||||
id = match[2];
|
|
||||||
version = match[3] ? parseInt(match[3]) : null;
|
|
||||||
uid = version ? `${id}:${version}` : id;
|
|
||||||
} else {
|
|
||||||
({ id, version, versionNonce } = opts);
|
|
||||||
parent = parent || null;
|
|
||||||
uid = id;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
versionNonce: versionNonce || randomInteger(),
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent || null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const idsToElements = (
|
|
||||||
ids: (Id | ElementLike)[],
|
|
||||||
cache: Cache = {},
|
|
||||||
): readonly ExcalidrawElement[] => {
|
|
||||||
return ids.reduce((acc, _uid, idx) => {
|
|
||||||
const {
|
|
||||||
uid,
|
|
||||||
id,
|
|
||||||
version,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
versionNonce,
|
|
||||||
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
|
|
||||||
const cached = cache[uid];
|
|
||||||
const elem = {
|
|
||||||
id,
|
|
||||||
version: version ?? 0,
|
|
||||||
versionNonce,
|
|
||||||
...cached,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: parent,
|
|
||||||
} as BroadcastedExcalidrawElement;
|
|
||||||
// @ts-ignore
|
|
||||||
cache[uid] = elem;
|
|
||||||
acc.push(elem);
|
|
||||||
return acc;
|
|
||||||
}, [] as ExcalidrawElement[]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
|
|
||||||
return elements.map((el, idx, els) => {
|
|
||||||
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanElements = (elements: ReconciledElements) => {
|
|
||||||
return elements.map((el) => {
|
|
||||||
// @ts-ignore
|
|
||||||
delete el[PRECEDING_ELEMENT_KEY];
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.next;
|
|
||||||
// @ts-ignore
|
|
||||||
delete el.prev;
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const test = <U extends `${string}:${"L" | "R"}`>(
|
|
||||||
local: (Id | ElementLike)[],
|
|
||||||
remote: (Id | ElementLike)[],
|
|
||||||
target: U[],
|
|
||||||
bidirectional = true,
|
|
||||||
) => {
|
|
||||||
const cache: Cache = {};
|
|
||||||
const _local = idsToElements(local, cache);
|
|
||||||
const _remote = idsToElements(remote, cache);
|
|
||||||
const _target = target.map((uid) => {
|
|
||||||
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
|
|
||||||
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
|
|
||||||
}) as any as ReconciledElements;
|
|
||||||
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
|
|
||||||
expect(target.length).equal(remoteReconciled.length);
|
|
||||||
expect(cleanElements(remoteReconciled)).deep.equal(
|
|
||||||
cleanElements(_target),
|
|
||||||
"remote reconciliation",
|
|
||||||
);
|
|
||||||
|
|
||||||
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
|
|
||||||
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
|
|
||||||
if (bidirectional) {
|
|
||||||
try {
|
|
||||||
expect(
|
|
||||||
cleanElements(
|
|
||||||
reconcileElements(
|
|
||||||
cloneJSON(__local),
|
|
||||||
cloneJSON(__remote),
|
|
||||||
{} as AppState,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("local original", __local);
|
|
||||||
console.error("remote reconciled", __remote);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findIndex = <T>(
|
|
||||||
array: readonly T[],
|
|
||||||
cb: (element: T, index: number, array: readonly T[]) => boolean,
|
|
||||||
fromIndex: number = 0,
|
|
||||||
) => {
|
|
||||||
if (fromIndex < 0) {
|
|
||||||
fromIndex = array.length + fromIndex;
|
|
||||||
}
|
|
||||||
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
|
|
||||||
let index = fromIndex - 1;
|
|
||||||
while (++index < array.length) {
|
|
||||||
if (cb(array[index], index, array)) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("elements reconciliation", () => {
|
|
||||||
it("reconcileElements()", () => {
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// in following tests, we pass:
|
|
||||||
// (1) an array of local elements and their version (:1, :2...)
|
|
||||||
// (2) an array of remote elements and their version (:1, :2...)
|
|
||||||
// (3) expected reconciled elements
|
|
||||||
//
|
|
||||||
// in the reconciled array:
|
|
||||||
// :L means local element was resolved
|
|
||||||
// :R means remote element was resolved
|
|
||||||
//
|
|
||||||
// if a remote element is prefixed with parentheses, the enclosed string:
|
|
||||||
// (^) means the element is the first element in the array
|
|
||||||
// (<id>) means the element is preceded by <id> element
|
|
||||||
//
|
|
||||||
// if versions are missing, it defaults to version 0
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// non-annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// usually when we sync elements they should always be annotated with
|
|
||||||
// their (preceding elements) parents, but let's test a couple of cases when
|
|
||||||
// they're not for whatever reason (remote clients are on older version...),
|
|
||||||
// in which case the first synced element either replaces existing element
|
|
||||||
// or is pushed at the end of the array
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
|
|
||||||
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
|
|
||||||
test(["A"], ["A", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
|
|
||||||
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
|
|
||||||
test(["A"], ["A:1"], ["A:R"]);
|
|
||||||
|
|
||||||
// C isn't added to the end because it follows B (even if B was resolved
|
|
||||||
// to local version)
|
|
||||||
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
|
|
||||||
|
|
||||||
// some of the following tests are kinda arbitrary and they're less
|
|
||||||
// likely to happen in real-world cases
|
|
||||||
|
|
||||||
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:1", "A:3"],
|
|
||||||
["B:L", "A:R", "C:L", "D:R"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A:2", "B:2", "C"],
|
|
||||||
["D", "B:2", "A:3", "C"],
|
|
||||||
["D:R", "B:L", "A:R", "C:L"],
|
|
||||||
);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E", "F"],
|
|
||||||
["A", "B:2", "X", "E:2", "F", "Y"],
|
|
||||||
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// annotated elements
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(B)X", "(A)Y", "(Y)Z"],
|
|
||||||
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
|
|
||||||
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B"],
|
|
||||||
["(A)C", "(^)D", "F"],
|
|
||||||
["A:L", "C:R", "D:R", "F:R", "B:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(B)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["B", "A", "C"],
|
|
||||||
["(^)X", "(A)Y", "(B)Z"],
|
|
||||||
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(A)X", "(C)Y", "(D)Z"],
|
|
||||||
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["X", "Y", "Z"],
|
|
||||||
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
|
|
||||||
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D", "E"],
|
|
||||||
["(C)X", "(A)Y", "(D)E:1"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["A", "B", "C:1", "D:1"],
|
|
||||||
["A:R", "B:L", "C:L", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "(C)B", "(B)D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["C:1", "B", "D:1"],
|
|
||||||
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
|
|
||||||
["A:R", "B:L", "C:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(C)X", "(B)Y", "(A)Z"],
|
|
||||||
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
|
|
||||||
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
|
|
||||||
test(
|
|
||||||
["A", "B", "C", "D"],
|
|
||||||
["(A)C:1", "B", "D:1"],
|
|
||||||
["A:L", "C:R", "B:L", "D:R"],
|
|
||||||
);
|
|
||||||
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
|
|
||||||
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
|
|
||||||
|
|
||||||
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
|
|
||||||
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
|
|
||||||
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("test identical elements reconciliation", () => {
|
|
||||||
const testIdentical = (
|
|
||||||
local: ElementLike[],
|
|
||||||
remote: ElementLike[],
|
|
||||||
expected: Id[],
|
|
||||||
) => {
|
|
||||||
const ret = reconcileElements(
|
|
||||||
local as any as ExcalidrawElement[],
|
|
||||||
remote as any as ExcalidrawElement[],
|
|
||||||
{} as AppState,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
|
|
||||||
throw new Error("reconcileElements: duplicate elements found");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(ret.map((x) => x.id)).to.deep.equal(expected);
|
|
||||||
};
|
|
||||||
|
|
||||||
// identical id/version/versionNonce
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
testIdentical(
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
[{ id: "A", version: 1, versionNonce: 1 }],
|
|
||||||
["A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
testIdentical(
|
|
||||||
[
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ id: "B", version: 1, versionNonce: 1 },
|
|
||||||
{ id: "A", version: 1, versionNonce: 1 },
|
|
||||||
],
|
|
||||||
["B", "A"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// actually identical (arrays and element objects)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const elements1 = [
|
|
||||||
{
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testIdentical(elements1, elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1, elements1.slice(), ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1, ["A", "B"]);
|
|
||||||
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
|
|
||||||
|
|
||||||
const el1 = {
|
|
||||||
id: "A",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
const el2 = {
|
|
||||||
id: "B",
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
[PRECEDING_ELEMENT_KEY]: null,
|
|
||||||
};
|
|
||||||
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
|
|
||||||
});
|
|
||||||
});
|
|
70
excalidraw-app/useHandleAppTheme.ts
Normal file
70
excalidraw-app/useHandleAppTheme.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
|
import { THEME } from "../packages/excalidraw";
|
||||||
|
import { EVENT } from "../packages/excalidraw/constants";
|
||||||
|
import type { Theme } from "../packages/excalidraw/element/types";
|
||||||
|
import { CODES, KEYS } from "../packages/excalidraw/keys";
|
||||||
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
|
export const appThemeAtom = atom<Theme | "system">(
|
||||||
|
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
|
||||||
|
| Theme
|
||||||
|
| "system"
|
||||||
|
| null) || THEME.LIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
|
window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
export const useHandleAppTheme = () => {
|
||||||
|
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
||||||
|
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = getDarkThemeMediaQuery();
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (appTheme === "system") {
|
||||||
|
mediaQuery?.addEventListener("change", handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.altKey &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.code === CODES.D
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery?.removeEventListener("change", handleChange);
|
||||||
|
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [appTheme, editorTheme, setAppTheme]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
|
||||||
|
|
||||||
|
if (appTheme === "system") {
|
||||||
|
setEditorTheme(
|
||||||
|
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setEditorTheme(appTheme);
|
||||||
|
}
|
||||||
|
}, [appTheme]);
|
||||||
|
|
||||||
|
return { editorTheme };
|
||||||
|
};
|
@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr";
|
|||||||
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
import { ViteEjsPlugin } from "vite-plugin-ejs";
|
||||||
import { VitePWA } from "vite-plugin-pwa";
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import checker from "vite-plugin-checker";
|
import checker from "vite-plugin-checker";
|
||||||
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
|
||||||
// To load .env.local variables
|
// To load .env.local variables
|
||||||
const envVars = loadEnv("", `../`);
|
const envVars = loadEnv("", `../`);
|
||||||
@ -189,6 +190,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
createHtmlPlugin({
|
||||||
|
minify: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
publicDir: "../public",
|
publicDir: "../public",
|
||||||
});
|
});
|
||||||
|
12
package.json
12
package.json
@ -28,8 +28,8 @@
|
|||||||
"@types/chai": "4.3.0",
|
"@types/chai": "4.3.0",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/lodash.throttle": "4.1.7",
|
"@types/lodash.throttle": "4.1.7",
|
||||||
"@types/react": "18.0.15",
|
"@types/react": "18.2.0",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.2.0",
|
||||||
"@types/socket.io-client": "3.0.0",
|
"@types/socket.io-client": "3.0.0",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "3.1.0",
|
||||||
"@vitest/coverage-v8": "0.33.0",
|
"@vitest/coverage-v8": "0.33.0",
|
||||||
@ -52,7 +52,7 @@
|
|||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
"vite-plugin-pwa": "0.17.4",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
"vite-plugin-svgr": "2.4.0",
|
"vite-plugin-svgr": "2.4.0",
|
||||||
"vitest": "1.0.1",
|
"vitest": "1.5.3",
|
||||||
"vitest-canvas-mock": "0.3.2"
|
"vitest-canvas-mock": "0.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -62,9 +62,9 @@
|
|||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
|
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:version": "node ./scripts/build-version.js",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn --cwd ./excalidraw-app build",
|
||||||
"fix:code": "yarn test:code --fix",
|
"fix:code": "yarn test:code --fix",
|
||||||
"fix:other": "yarn prettier --write",
|
"fix:other": "yarn prettier --write",
|
||||||
|
@ -15,6 +15,16 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||||
|
|
||||||
|
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||||
|
|
||||||
|
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
|
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
|
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||||
|
|
||||||
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
|
||||||
|
|
||||||
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
|
||||||
@ -25,8 +35,18 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||||
|
|
||||||
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||||
|
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||||
|
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
|
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
||||||
|
|
||||||
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
|
||||||
|
|
||||||
#### Bundler
|
#### Bundler
|
||||||
@ -85,8 +105,6 @@ define: {
|
|||||||
|
|
||||||
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0.17.0 (2023-11-14)
|
## 0.17.0 (2023-11-14)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
|
|||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
|
|||||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||||
if (selectedElements.some((element) => element.type === type)) {
|
if (selectedElements.some((element) => element.type === type)) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||||
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.addedToLibrary") },
|
toast: { message: t("toast.addedToLibrary") },
|
||||||
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.addToLibrary",
|
label: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { alignElements, Alignment } from "../align";
|
import type { Alignment } from "../align";
|
||||||
|
import { alignElements } from "../align";
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
@ -10,18 +11,19 @@ import {
|
|||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { StoreAction } from "../store";
|
||||||
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const alignActionsPredicate = (
|
const alignActionsPredicate = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: UIAppState,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
@ -59,6 +61,8 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
|
label: "labels.alignTop",
|
||||||
|
icon: AlignTopIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -68,7 +72,7 @@ export const actionAlignTop = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -90,6 +94,8 @@ export const actionAlignTop = register({
|
|||||||
|
|
||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
|
label: "labels.alignBottom",
|
||||||
|
icon: AlignBottomIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -99,7 +105,7 @@ export const actionAlignBottom = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -121,6 +127,8 @@ export const actionAlignBottom = register({
|
|||||||
|
|
||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
|
label: "labels.alignLeft",
|
||||||
|
icon: AlignLeftIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -130,7 +138,7 @@ export const actionAlignLeft = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -152,6 +160,8 @@ export const actionAlignLeft = register({
|
|||||||
|
|
||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
|
label: "labels.alignRight",
|
||||||
|
icon: AlignRightIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -161,7 +171,7 @@ export const actionAlignRight = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -183,6 +193,8 @@ export const actionAlignRight = register({
|
|||||||
|
|
||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
|
label: "labels.centerVertically",
|
||||||
|
icon: CenterVerticallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -192,7 +204,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
position: "center",
|
position: "center",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
@ -210,6 +222,8 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
|
|
||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
|
label: "labels.centerHorizontally",
|
||||||
|
icon: CenterHorizontallyIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: alignActionsPredicate,
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -219,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
position: "center",
|
position: "center",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||||
|
@ -23,19 +23,22 @@ import {
|
|||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionUnbindText = register({
|
export const actionUnbindText = register({
|
||||||
name: "unbindText",
|
name: "unbindText",
|
||||||
contextItemLabel: "labels.unbindText",
|
label: "labels.unbindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@ -48,22 +51,22 @@ export const actionUnbindText = register({
|
|||||||
selectedElements.forEach((element) => {
|
selectedElements.forEach((element) => {
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const { width, height, baseline } = measureTextElement(
|
const { width, height } = measureTextElement(boundTextElement, {
|
||||||
boundTextElement,
|
text: boundTextElement.originalText,
|
||||||
{
|
});
|
||||||
text: boundTextElement.originalText,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||||
element.id,
|
element.id,
|
||||||
);
|
);
|
||||||
resetOriginalContainerCache(element.id);
|
resetOriginalContainerCache(element.id);
|
||||||
const { x, y } = computeBoundTextPosition(element, boundTextElement);
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||||
containerId: null,
|
containerId: null,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
baseline,
|
|
||||||
text: boundTextElement.originalText,
|
text: boundTextElement.originalText,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@ -81,14 +84,14 @@ export const actionUnbindText = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionBindText = register({
|
export const actionBindText = register({
|
||||||
name: "bindText",
|
name: "bindText",
|
||||||
contextItemLabel: "labels.bindText",
|
label: "labels.bindText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@ -145,7 +148,11 @@ export const actionBindText = register({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const originalContainerHeight = container.height;
|
const originalContainerHeight = container.height;
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
// overwritting the cache with original container height so
|
// overwritting the cache with original container height so
|
||||||
// it can be restored when unbind
|
// it can be restored when unbind
|
||||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||||
@ -153,7 +160,7 @@ export const actionBindText = register({
|
|||||||
return {
|
return {
|
||||||
elements: pushTextAboveContainer(elements, container, textElement),
|
elements: pushTextAboveContainer(elements, container, textElement),
|
||||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -173,6 +180,8 @@ const pushTextAboveContainer = (
|
|||||||
(ele) => ele.id === container.id,
|
(ele) => ele.id === container.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -191,12 +200,14 @@ const pushContainerBelowText = (
|
|||||||
(ele) => ele.id === textElement.id,
|
(ele) => ele.id === textElement.id,
|
||||||
);
|
);
|
||||||
updatedElements.splice(textElementIndex, 0, container);
|
updatedElements.splice(textElementIndex, 0, container);
|
||||||
|
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
|
||||||
|
|
||||||
return updatedElements;
|
return updatedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionWrapTextInContainer = register({
|
export const actionWrapTextInContainer = register({
|
||||||
name: "wrapTextInContainer",
|
name: "wrapTextInContainer",
|
||||||
contextItemLabel: "labels.createContainerFromText",
|
label: "labels.createContainerFromText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@ -286,13 +297,18 @@ export const actionWrapTextInContainer = register({
|
|||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
redrawTextBoundingBox(textElement, container);
|
redrawTextBoundingBox(
|
||||||
|
textElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
updatedElements = pushContainerBelowText(
|
updatedElements = pushContainerBelowText(
|
||||||
[...updatedElements, container],
|
[...updatedElements, container],
|
||||||
container,
|
container,
|
||||||
textElement,
|
textElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
containerIds[container.id] = true;
|
containerIds[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -303,7 +319,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: containerIds,
|
selectedElementIds: containerIds,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,15 +1,30 @@
|
|||||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
import {
|
||||||
|
handIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SunIcon,
|
||||||
|
TrashIcon,
|
||||||
|
zoomAreaIcon,
|
||||||
|
ZoomInIcon,
|
||||||
|
ZoomOutIcon,
|
||||||
|
ZoomResetIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
import {
|
||||||
|
CURSOR_TYPE,
|
||||||
|
MAX_ZOOM,
|
||||||
|
MIN_ZOOM,
|
||||||
|
THEME,
|
||||||
|
ZOOM_STEP,
|
||||||
|
} from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import type { AppState, NormalizedZoomValue } from "../types";
|
||||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@ -20,11 +35,14 @@ import {
|
|||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import { SceneBounds } from "../element/bounds";
|
import type { SceneBounds } from "../element/bounds";
|
||||||
import { setCursor } from "../cursor";
|
import { setCursor } from "../cursor";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
|
label: "labels.canvasBackground",
|
||||||
|
paletteName: "Change canvas background color",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, ...value },
|
appState: { ...appState, ...value },
|
||||||
commitToHistory: !!value.viewBackgroundColor,
|
storeAction: !!value.viewBackgroundColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||||
@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
|
label: "labels.clearCanvas",
|
||||||
|
paletteName: "Clear canvas",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@ -88,14 +111,16 @@ export const actionClearCanvas = register({
|
|||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
|
label: "buttons.zoomIn",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
|
icon: ZoomInIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@ -111,16 +136,17 @@ export const actionZoomIn = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="zoom-in-button zoom-button"
|
className="zoom-in-button zoom-button"
|
||||||
icon={ZoomInIcon}
|
icon={ZoomInIcon}
|
||||||
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
|
||||||
aria-label={t("buttons.zoomIn")}
|
aria-label={t("buttons.zoomIn")}
|
||||||
|
disabled={appState.zoom.value >= MAX_ZOOM}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
@ -133,6 +159,8 @@ export const actionZoomIn = register({
|
|||||||
|
|
||||||
export const actionZoomOut = register({
|
export const actionZoomOut = register({
|
||||||
name: "zoomOut",
|
name: "zoomOut",
|
||||||
|
label: "buttons.zoomOut",
|
||||||
|
icon: ZoomOutIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@ -149,16 +177,17 @@ export const actionZoomOut = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
className="zoom-out-button zoom-button"
|
className="zoom-out-button zoom-button"
|
||||||
icon={ZoomOutIcon}
|
icon={ZoomOutIcon}
|
||||||
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
|
||||||
aria-label={t("buttons.zoomOut")}
|
aria-label={t("buttons.zoomOut")}
|
||||||
|
disabled={appState.zoom.value <= MIN_ZOOM}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
@ -171,6 +200,8 @@ export const actionZoomOut = register({
|
|||||||
|
|
||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
|
label: "buttons.resetZoom",
|
||||||
|
icon: ZoomResetIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
@ -187,7 +218,7 @@ export const actionResetZoom = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
@ -262,8 +293,8 @@ export const zoomToFitBounds = ({
|
|||||||
|
|
||||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||||
newZoomValue = Math.min(
|
newZoomValue = Math.min(
|
||||||
Math.max(newZoomValue, 0.1),
|
Math.max(newZoomValue, MIN_ZOOM),
|
||||||
30.0,
|
MAX_ZOOM,
|
||||||
) as NormalizedZoomValue;
|
) as NormalizedZoomValue;
|
||||||
|
|
||||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||||
@ -294,7 +325,7 @@ export const zoomToFitBounds = ({
|
|||||||
scrollY,
|
scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -326,6 +357,8 @@ export const zoomToFit = ({
|
|||||||
// size, it won't be zoomed in.
|
// size, it won't be zoomed in.
|
||||||
export const actionZoomToFitSelectionInViewport = register({
|
export const actionZoomToFitSelectionInViewport = register({
|
||||||
name: "zoomToFitSelectionInViewport",
|
name: "zoomToFitSelectionInViewport",
|
||||||
|
label: "labels.zoomToFitViewport",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@ -349,6 +382,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
|
|
||||||
export const actionZoomToFitSelection = register({
|
export const actionZoomToFitSelection = register({
|
||||||
name: "zoomToFitSelection",
|
name: "zoomToFitSelection",
|
||||||
|
label: "helpDialog.zoomToSelection",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
@ -371,6 +406,8 @@ export const actionZoomToFitSelection = register({
|
|||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
|
label: "helpDialog.zoomToFit",
|
||||||
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
@ -391,6 +428,13 @@ export const actionZoomToFit = register({
|
|||||||
|
|
||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
|
label: (_, appState) => {
|
||||||
|
return appState.theme === THEME.DARK
|
||||||
|
? "buttons.lightMode"
|
||||||
|
: "buttons.darkMode";
|
||||||
|
},
|
||||||
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
|
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
@ -400,7 +444,7 @@ export const actionToggleTheme = register({
|
|||||||
theme:
|
theme:
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||||
@ -411,6 +455,7 @@ export const actionToggleTheme = register({
|
|||||||
|
|
||||||
export const actionToggleEraserTool = register({
|
export const actionToggleEraserTool = register({
|
||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
@ -437,7 +482,7 @@ export const actionToggleEraserTool = register({
|
|||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.E,
|
keyTest: (event) => event.key === KEYS.E,
|
||||||
@ -445,7 +490,11 @@ export const actionToggleEraserTool = register({
|
|||||||
|
|
||||||
export const actionToggleHandTool = register({
|
export const actionToggleHandTool = register({
|
||||||
name: "toggleHandTool",
|
name: "toggleHandTool",
|
||||||
|
label: "toolBar.hand",
|
||||||
|
paletteName: "Toggle hand tool",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: handIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
@ -472,7 +521,7 @@ export const actionToggleHandTool = register({
|
|||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
|
|||||||
import { isTextElement } from "../element";
|
import { isTextElement } from "../element";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isFirefox } from "../constants";
|
import { isFirefox } from "../constants";
|
||||||
|
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
|
label: "labels.copy",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
const elementsToCopy = app.scene.getSelectedElements({
|
const elementsToCopy = app.scene.getSelectedElements({
|
||||||
@ -28,7 +32,7 @@ export const actionCopy = register({
|
|||||||
await copyToClipboard(elementsToCopy, app.files, event);
|
await copyToClipboard(elementsToCopy, app.files, event);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
@ -37,16 +41,16 @@ export const actionCopy = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copy",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPaste = register({
|
export const actionPaste = register({
|
||||||
name: "paste",
|
name: "paste",
|
||||||
|
label: "labels.paste",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, data, app) => {
|
perform: async (elements, appState, data, app) => {
|
||||||
let types;
|
let types;
|
||||||
@ -63,7 +67,7 @@ export const actionPaste = register({
|
|||||||
|
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("hints.firefox_clipboard_write"),
|
errorMessage: t("hints.firefox_clipboard_write"),
|
||||||
@ -72,7 +76,7 @@ export const actionPaste = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
errorMessage: t("errors.asyncPasteFailedOnRead"),
|
||||||
@ -85,7 +89,7 @@ export const actionPaste = register({
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
errorMessage: t("errors.asyncPasteFailedOnParse"),
|
||||||
@ -94,32 +98,34 @@ export const actionPaste = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.paste",
|
|
||||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||||
keyTest: undefined,
|
keyTest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCut = register({
|
export const actionCut = register({
|
||||||
name: "cut",
|
name: "cut",
|
||||||
|
label: "labels.cut",
|
||||||
|
icon: cutIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
perform: (elements, appState, event: ClipboardEvent | null, app) => {
|
||||||
actionCopy.perform(elements, appState, event, app);
|
actionCopy.perform(elements, appState, event, app);
|
||||||
return actionDeleteSelected.perform(elements, appState);
|
return actionDeleteSelected.perform(elements, appState, null, app);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.cut",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsSvg = register({
|
export const actionCopyAsSvg = register({
|
||||||
name: "copyAsSvg",
|
name: "copyAsSvg",
|
||||||
|
label: "labels.copyAsSvg",
|
||||||
|
icon: svgIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsSvg",
|
keywords: ["svg", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionCopyAsPng = register({
|
export const actionCopyAsPng = register({
|
||||||
name: "copyAsPng",
|
name: "copyAsPng",
|
||||||
|
label: "labels.copyAsPng",
|
||||||
|
icon: pngIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: async (elements, appState, _data, app) => {
|
perform: async (elements, appState, _data, app) => {
|
||||||
if (!app.canvas) {
|
if (!app.canvas) {
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@ -184,6 +193,7 @@ export const actionCopyAsPng = register({
|
|||||||
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
await exportCanvas("clipboard", exportedElements, appState, app.files, {
|
||||||
...appState,
|
...appState,
|
||||||
exportingFrame,
|
exportingFrame,
|
||||||
|
name: app.getName(),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@ -199,7 +209,7 @@ export const actionCopyAsPng = register({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -208,19 +218,20 @@ export const actionCopyAsPng = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
predicate: (elements) => {
|
predicate: (elements) => {
|
||||||
return probablySupportsClipboardBlob && elements.length > 0;
|
return probablySupportsClipboardBlob && elements.length > 0;
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyAsPng",
|
|
||||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
|
keywords: ["png", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const copyText = register({
|
export const copyText = register({
|
||||||
name: "copyText",
|
name: "copyText",
|
||||||
|
label: "labels.copyText",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@ -236,9 +247,13 @@ export const copyText = register({
|
|||||||
return acc;
|
return acc;
|
||||||
}, [])
|
}, [])
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
copyTextToSystemClipboard(text);
|
try {
|
||||||
|
copyTextToSystemClipboard(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
@ -252,5 +267,5 @@ export const copyText = register({
|
|||||||
.some(isTextElement)
|
.some(isTextElement)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyText",
|
keywords: ["text", "clipboard", "copy"],
|
||||||
});
|
});
|
||||||
|
@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { getElementsInGroup } from "../groups";
|
import { getElementsInGroup } from "../groups";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
|
|||||||
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { TrashIcon } from "../components/icons";
|
import { TrashIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const deleteSelectedElements = (
|
const deleteSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -72,8 +73,10 @@ const handleGroupEditingState = (
|
|||||||
|
|
||||||
export const actionDeleteSelected = register({
|
export const actionDeleteSelected = register({
|
||||||
name: "deleteSelectedElements",
|
name: "deleteSelectedElements",
|
||||||
|
label: "labels.delete",
|
||||||
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
@ -81,7 +84,8 @@ export const actionDeleteSelected = register({
|
|||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -109,7 +113,7 @@ export const actionDeleteSelected = register({
|
|||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +145,7 @@ export const actionDeleteSelected = register({
|
|||||||
: [0],
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
@ -161,13 +165,14 @@ export const actionDeleteSelected = register({
|
|||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
},
|
},
|
||||||
commitToHistory: isSomeElementSelected(
|
storeAction: isSomeElementSelected(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
),
|
)
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
|
||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
|
||||||
!event[KEYS.CTRL_OR_CMD],
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
@ -3,15 +3,17 @@ import {
|
|||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { distributeElements, Distribution } from "../distribute";
|
import type { Distribution } from "../distribute";
|
||||||
|
import { distributeElements } from "../distribute";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { StoreAction } from "../store";
|
||||||
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -49,6 +51,7 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
|
label: "labels.distributeHorizontally",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@ -57,7 +60,7 @@ export const distributeHorizontally = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -79,6 +82,7 @@ export const distributeHorizontally = register({
|
|||||||
|
|
||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
|
label: "labels.distributeVertically",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@ -87,7 +91,7 @@ export const distributeVertically = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
@ -12,9 +12,9 @@ import {
|
|||||||
getSelectedGroupForElement,
|
getSelectedGroupForElement,
|
||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
} from "../groups";
|
} from "../groups";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { fixBindingsAfterDuplication } from "../element/binding";
|
import { fixBindingsAfterDuplication } from "../element/binding";
|
||||||
import { ActionResult } from "./types";
|
import type { ActionResult } from "./types";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import {
|
import {
|
||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
@ -31,14 +31,22 @@ import {
|
|||||||
excludeElementsInFramesFromSelection,
|
excludeElementsInFramesFromSelection,
|
||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
} from "../scene/selection";
|
} from "../scene/selection";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
|
label: "labels.duplicateSelection",
|
||||||
|
icon: DuplicateIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
|
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
if (!ret) {
|
if (!ret) {
|
||||||
return false;
|
return false;
|
||||||
@ -47,16 +55,15 @@ export const actionDuplicateSelection = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState: ret.appState,
|
appState: ret.appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...duplicateElements(elements, appState),
|
...duplicateElements(elements, appState),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.duplicateSelection",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@ -85,6 +92,7 @@ const duplicateElements = (
|
|||||||
const newElements: ExcalidrawElement[] = [];
|
const newElements: ExcalidrawElement[] = [];
|
||||||
const oldElements: ExcalidrawElement[] = [];
|
const oldElements: ExcalidrawElement[] = [];
|
||||||
const oldIdToDuplicatedId = new Map();
|
const oldIdToDuplicatedId = new Map();
|
||||||
|
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||||
|
|
||||||
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
|
||||||
const newElement = duplicateElement(
|
const newElement = duplicateElement(
|
||||||
@ -96,6 +104,7 @@ const duplicateElements = (
|
|||||||
y: element.y + GRID_SIZE / 2,
|
y: element.y + GRID_SIZE / 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
duplicatedElementsMap.set(newElement.id, newElement);
|
||||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||||
oldElements.push(element);
|
oldElements.push(element);
|
||||||
newElements.push(newElement);
|
newElements.push(newElement);
|
||||||
@ -233,8 +242,10 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// step (3)
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
const finalElements = finalElementsReversed.reverse();
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
|
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
|
|
||||||
export const actionToggleElementLock = register({
|
export const actionToggleElementLock = register({
|
||||||
name: "toggleElementLock",
|
name: "toggleElementLock",
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selected = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||||
|
return selected[0].locked
|
||||||
|
? "labels.elementLock.unlock"
|
||||||
|
: "labels.elementLock.lock";
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldLock(selected)
|
||||||
|
? "labels.elementLock.lockAll"
|
||||||
|
: "labels.elementLock.unlockAll";
|
||||||
|
},
|
||||||
|
icon: (appState, elements) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
|
||||||
|
},
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return !selectedElements.some(
|
return (
|
||||||
(element) => element.locked && element.frameId,
|
selectedElements.length > 0 &&
|
||||||
|
!selectedElements.some((element) => element.locked && element.frameId)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
@ -44,24 +67,9 @@ export const actionToggleElementLock = register({
|
|||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState, app) => {
|
|
||||||
const selected = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: false,
|
|
||||||
});
|
|
||||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
|
||||||
return selected[0].locked
|
|
||||||
? "labels.elementLock.unlock"
|
|
||||||
: "labels.elementLock.lock";
|
|
||||||
}
|
|
||||||
|
|
||||||
return shouldLock(selected)
|
|
||||||
? "labels.elementLock.lockAll"
|
|
||||||
: "labels.elementLock.unlockAll";
|
|
||||||
},
|
|
||||||
keyTest: (event, appState, elements, app) => {
|
keyTest: (event, appState, elements, app) => {
|
||||||
return (
|
return (
|
||||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||||
@ -77,10 +85,16 @@ export const actionToggleElementLock = register({
|
|||||||
|
|
||||||
export const actionUnlockAllElements = register({
|
export const actionUnlockAllElements = register({
|
||||||
name: "unlockAllElements",
|
name: "unlockAllElements",
|
||||||
|
paletteName: "Unlock all elements",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
predicate: (elements) => {
|
icon: UnlockedIcon,
|
||||||
return elements.some((element) => element.locked);
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return (
|
||||||
|
selectedElements.length === 0 &&
|
||||||
|
elements.some((element) => element.locked)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const lockedElements = elements.filter((el) => el.locked);
|
const lockedElements = elements.filter((el) => el.locked);
|
||||||
@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({
|
|||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.elementLock.unlockAll",
|
label: "labels.elementLock.unlockAll",
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { questionCircle, saveAs } from "../components/icons";
|
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { Tooltip } from "../components/Tooltip";
|
import { Tooltip } from "../components/Tooltip";
|
||||||
@ -16,24 +16,26 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { isImageFileHandle } from "../data/blob";
|
import { isImageFileHandle } from "../data/blob";
|
||||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||||
import { Theme } from "../element/types";
|
import type { Theme } from "../element/types";
|
||||||
|
|
||||||
import "../components/ToolIcon.scss";
|
import "../components/ToolIcon.scss";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
|
label: "labels.fileTitle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return {
|
||||||
|
appState: { ...appState, name: value },
|
||||||
|
storeAction: StoreAction.NONE,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData, appProps, data }) => (
|
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
label={t("labels.fileTitle")}
|
label={t("labels.fileTitle")}
|
||||||
value={appState.name || "Unnamed"}
|
value={app.getName()}
|
||||||
onChange={(name: string) => updateData(name)}
|
onChange={(name: string) => updateData(name)}
|
||||||
isNameEditable={
|
|
||||||
typeof appProps.name === "undefined" && !appState.viewModeEnabled
|
|
||||||
}
|
|
||||||
ignoreFocus={data?.ignoreFocus ?? false}
|
ignoreFocus={data?.ignoreFocus ?? false}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -41,11 +43,12 @@ export const actionChangeProjectName = register({
|
|||||||
|
|
||||||
export const actionChangeExportScale = register({
|
export const actionChangeExportScale = register({
|
||||||
name: "changeExportScale",
|
name: "changeExportScale",
|
||||||
|
label: "imageExportDialog.scale",
|
||||||
trackEvent: { category: "export", action: "scale" },
|
trackEvent: { category: "export", action: "scale" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportScale: value },
|
appState: { ...appState, exportScale: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
PanelComponent: ({ elements: allElements, appState, updateData }) => {
|
||||||
@ -90,11 +93,12 @@ export const actionChangeExportScale = register({
|
|||||||
|
|
||||||
export const actionChangeExportBackground = register({
|
export const actionChangeExportBackground = register({
|
||||||
name: "changeExportBackground",
|
name: "changeExportBackground",
|
||||||
|
label: "imageExportDialog.label.withBackground",
|
||||||
trackEvent: { category: "export", action: "toggleBackground" },
|
trackEvent: { category: "export", action: "toggleBackground" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportBackground: value },
|
appState: { ...appState, exportBackground: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({
|
|||||||
|
|
||||||
export const actionChangeExportEmbedScene = register({
|
export const actionChangeExportEmbedScene = register({
|
||||||
name: "changeExportEmbedScene",
|
name: "changeExportEmbedScene",
|
||||||
|
label: "imageExportDialog.tooltip.embedScene",
|
||||||
trackEvent: { category: "export", action: "embedScene" },
|
trackEvent: { category: "export", action: "embedScene" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportEmbedScene: value },
|
appState: { ...appState, exportEmbedScene: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
|
|
||||||
export const actionSaveToActiveFile = register({
|
export const actionSaveToActiveFile = register({
|
||||||
name: "saveToActiveFile",
|
name: "saveToActiveFile",
|
||||||
|
label: "buttons.save",
|
||||||
|
icon: ExportIcon,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
const { fileHandle } = isImageFileHandle(appState.fileHandle)
|
||||||
? await resaveAsImageWithScene(elements, appState, app.files)
|
? await resaveAsImageWithScene(
|
||||||
: await saveAsJSON(elements, appState, app.files);
|
elements,
|
||||||
|
appState,
|
||||||
|
app.files,
|
||||||
|
app.getName(),
|
||||||
|
)
|
||||||
|
: await saveAsJSON(elements, appState, app.files, app.getName());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
fileHandle,
|
fileHandle,
|
||||||
@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({
|
|||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({
|
|||||||
|
|
||||||
export const actionSaveFileToDisk = register({
|
export const actionSaveFileToDisk = register({
|
||||||
name: "saveFileToDisk",
|
name: "saveFileToDisk",
|
||||||
|
label: "exportDialog.disk_title",
|
||||||
|
icon: ExportIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
perform: async (elements, appState, value, app) => {
|
perform: async (elements, appState, value, app) => {
|
||||||
@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({
|
|||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
},
|
},
|
||||||
app.files,
|
app.files,
|
||||||
|
app.getName(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
} else {
|
} else {
|
||||||
console.warn(error);
|
console.warn(error);
|
||||||
}
|
}
|
||||||
return { commitToHistory: false };
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
|
label: "buttons.load",
|
||||||
trackEvent: { category: "export" },
|
trackEvent: { category: "export" },
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return (
|
return (
|
||||||
@ -244,7 +260,7 @@ export const actionLoadScene = register({
|
|||||||
elements: loadedElements,
|
elements: loadedElements,
|
||||||
appState: loadedAppState,
|
appState: loadedAppState,
|
||||||
files,
|
files,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.name === "AbortError") {
|
if (error?.name === "AbortError") {
|
||||||
@ -255,7 +271,7 @@ export const actionLoadScene = register({
|
|||||||
elements,
|
elements,
|
||||||
appState: { ...appState, errorMessage: error.message },
|
appState: { ...appState, errorMessage: error.message },
|
||||||
files: app.files,
|
files: app.files,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -264,11 +280,12 @@ export const actionLoadScene = register({
|
|||||||
|
|
||||||
export const actionExportWithDarkMode = register({
|
export const actionExportWithDarkMode = register({
|
||||||
name: "exportWithDarkMode",
|
name: "exportWithDarkMode",
|
||||||
|
label: "imageExportDialog.label.darkMode",
|
||||||
trackEvent: { category: "export", action: "toggleTheme" },
|
trackEvent: { category: "export", action: "toggleTheme" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, exportWithDarkMode: value },
|
appState: { ...appState, exportWithDarkMode: value },
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { isInvisiblySmallElement } from "../element";
|
import { isInvisiblySmallElement } from "../element";
|
||||||
import { updateActiveTool } from "../utils";
|
import { arrayToMap, updateActiveTool } from "../utils";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@ -8,28 +8,28 @@ import { register } from "./register";
|
|||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
import { isPathALoop } from "../math";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (elements, appState, _, app) => {
|
||||||
elements,
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
appState,
|
|
||||||
_,
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
{ interactiveCanvas, focusContainer, scene },
|
|
||||||
) => {
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
if (isBindingElement(element)) {
|
if (isBindingElement(element)) {
|
||||||
@ -37,6 +37,7 @@ export const actionFinalize = register({
|
|||||||
element,
|
element,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -48,8 +49,9 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +92,9 @@ export const actionFinalize = register({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInvisiblySmallElement(multiPointElement)) {
|
if (isInvisiblySmallElement(multiPointElement)) {
|
||||||
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter(
|
newElements = newElements.filter(
|
||||||
(el) => el.id !== multiPointElement.id,
|
(el) => el.id !== multiPointElement.id,
|
||||||
);
|
);
|
||||||
@ -125,13 +129,9 @@ export const actionFinalize = register({
|
|||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
multiPointElement,
|
multiPointElement,
|
||||||
-1,
|
-1,
|
||||||
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
Scene.getScene(multiPointElement)!,
|
|
||||||
{ x, y },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,11 +186,12 @@ export const actionFinalize = register({
|
|||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
multiPointElement && isLinearElement(multiPointElement)
|
||||||
? new LinearElementEditor(multiPointElement, scene)
|
? new LinearElementEditor(multiPointElement)
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
},
|
},
|
||||||
commitToHistory: appState.activeTool.type === "freedraw",
|
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
|
@ -1,26 +1,29 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedElementsMap,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
import { AppState } from "../types";
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindSelectedElements,
|
bindOrUnbindLinearElements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
unbindLinearElements,
|
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
|
label: "labels.flipHorizontal",
|
||||||
|
icon: flipHorizontal,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@ -30,20 +33,22 @@ export const actionFlipHorizontal = register({
|
|||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
appState,
|
appState,
|
||||||
"horizontal",
|
"horizontal",
|
||||||
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
keyTest: (event) => event.shiftKey && event.code === CODES.H,
|
||||||
contextItemLabel: "labels.flipHorizontal",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionFlipVertical = register({
|
export const actionFlipVertical = register({
|
||||||
name: "flipVertical",
|
name: "flipVertical",
|
||||||
|
label: "labels.flipVertical",
|
||||||
|
icon: flipVertical,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
@ -53,24 +58,25 @@ export const actionFlipVertical = register({
|
|||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
appState,
|
appState,
|
||||||
"vertical",
|
"vertical",
|
||||||
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
app,
|
app,
|
||||||
),
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
|
||||||
contextItemLabel: "labels.flipVertical",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const flipSelectedElements = (
|
const flipSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
) => {
|
) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
@ -86,6 +92,7 @@ const flipSelectedElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
@ -97,9 +104,10 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
@ -109,13 +117,17 @@ const flipElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
"nw",
|
"nw",
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
flipDirection === "horizontal" ? maxX : minX,
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
);
|
);
|
||||||
|
|
||||||
(isBindingEnabled(appState)
|
bindOrUnbindLinearElements(
|
||||||
? bindOrUnbindSelectedElements
|
selectedElements.filter(isLinearElement),
|
||||||
: unbindLinearElements)(selectedElements);
|
app,
|
||||||
|
isBindingEnabled(appState),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,20 @@
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { removeAllElementsFromFrame } from "../frame";
|
import { removeAllElementsFromFrame } from "../frame";
|
||||||
import { getFrameChildren } from "../frame";
|
import { getFrameChildren } from "../frame";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { updateActiveTool } from "../utils";
|
import { updateActiveTool } from "../utils";
|
||||||
import { setCursorForShape } from "../cursor";
|
import { setCursorForShape } from "../cursor";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { isFrameLikeElement } from "../element/typeChecks";
|
import { isFrameLikeElement } from "../element/typeChecks";
|
||||||
|
import { frameToolIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
const isSingleFrameSelected = (
|
||||||
|
appState: UIAppState,
|
||||||
|
app: AppClassProperties,
|
||||||
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
|||||||
|
|
||||||
export const actionSelectAllElementsInFrame = register({
|
export const actionSelectAllElementsInFrame = register({
|
||||||
name: "selectAllElementsInFrame",
|
name: "selectAllElementsInFrame",
|
||||||
|
label: "labels.selectAllElementsInFrame",
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionRemoveAllElementsFromFrame = register({
|
export const actionRemoveAllElementsFromFrame = register({
|
||||||
name: "removeAllElementsFromFrame",
|
name: "removeAllElementsFromFrame",
|
||||||
|
label: "labels.removeAllElementsFromFrame",
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElement =
|
const selectedElement =
|
||||||
@ -70,23 +76,23 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||||||
[selectedElement.id]: true,
|
[selectedElement.id]: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
isSingleFrameSelected(appState, app),
|
isSingleFrameSelected(appState, app),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionupdateFrameRendering = register({
|
export const actionupdateFrameRendering = register({
|
||||||
name: "updateFrameRendering",
|
name: "updateFrameRendering",
|
||||||
|
label: "labels.updateFrameRendering",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
@ -99,16 +105,18 @@ export const actionupdateFrameRendering = register({
|
|||||||
enabled: !appState.frameRendering.enabled,
|
enabled: !appState.frameRendering.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.updateFrameRendering",
|
|
||||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionSetFrameAsActiveTool = register({
|
export const actionSetFrameAsActiveTool = register({
|
||||||
name: "setFrameAsActiveTool",
|
name: "setFrameAsActiveTool",
|
||||||
|
label: "toolBar.frame",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
icon: frameToolIcon,
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
type: "frame",
|
type: "frame",
|
||||||
@ -127,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
type: "frame",
|
type: "frame",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
@ -17,8 +17,12 @@ import {
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import type {
|
||||||
import { AppClassProperties, AppState } from "../types";
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
getElementsInResizingFrame,
|
getElementsInResizingFrame,
|
||||||
@ -27,6 +31,8 @@ import {
|
|||||||
removeElementsFromFrame,
|
removeElementsFromFrame,
|
||||||
replaceAllElementsInFrame,
|
replaceAllElementsInFrame,
|
||||||
} from "../frame";
|
} from "../frame";
|
||||||
|
import { syncMovedIndices } from "../fractionalIndex";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@ -61,6 +67,8 @@ const enableActionGroup = (
|
|||||||
|
|
||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
|
label: "labels.group",
|
||||||
|
icon: (appState) => <GroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements({
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
@ -69,7 +77,7 @@ export const actionGroup = register({
|
|||||||
});
|
});
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
// if everything is already grouped into 1 group, there is nothing to do
|
// if everything is already grouped into 1 group, there is nothing to do
|
||||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
@ -89,7 +97,7 @@ export const actionGroup = register({
|
|||||||
]);
|
]);
|
||||||
if (combinedSet.size === elementIdsInGroup.size) {
|
if (combinedSet.size === elementIdsInGroup.size) {
|
||||||
// no incremental ids in the selected ids
|
// no incremental ids in the selected ids
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,18 +139,19 @@ export const actionGroup = register({
|
|||||||
// to the z order of the highest element in the layer stack
|
// to the z order of the highest element in the layer stack
|
||||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
const lastGroupElementIndex = nextElements.lastIndexOf(
|
||||||
|
lastElementInGroup as OrderedExcalidrawElement,
|
||||||
|
);
|
||||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsBeforeGroup = nextElements
|
const elementsBeforeGroup = nextElements
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
.filter(
|
.filter(
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||||
);
|
);
|
||||||
nextElements = [
|
const reorderedElements = syncMovedIndices(
|
||||||
...elementsBeforeGroup,
|
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
|
||||||
...elementsInGroup,
|
arrayToMap(elementsInGroup),
|
||||||
...elementsAfterGroup,
|
);
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@ -153,11 +162,10 @@ export const actionGroup = register({
|
|||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
elements: nextElements,
|
elements: reorderedElements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.group",
|
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
enableActionGroup(elements, appState, app),
|
enableActionGroup(elements, appState, app),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@ -177,11 +185,15 @@ export const actionGroup = register({
|
|||||||
|
|
||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
|
label: "labels.ungroup",
|
||||||
|
icon: (appState) => <UngroupIcon theme={appState.theme} />,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
|
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
return { appState, elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextElements = [...elements];
|
let nextElements = [...elements];
|
||||||
@ -226,7 +238,12 @@ export const actionUngroup = register({
|
|||||||
if (frame) {
|
if (frame) {
|
||||||
nextElements = replaceAllElementsInFrame(
|
nextElements = replaceAllElementsInFrame(
|
||||||
nextElements,
|
nextElements,
|
||||||
getElementsInResizingFrame(nextElements, frame, appState),
|
getElementsInResizingFrame(
|
||||||
|
nextElements,
|
||||||
|
frame,
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
frame,
|
frame,
|
||||||
app,
|
app,
|
||||||
);
|
);
|
||||||
@ -249,14 +266,13 @@ export const actionUngroup = register({
|
|||||||
return {
|
return {
|
||||||
appState: { ...appState, ...updateAppState },
|
appState: { ...appState, ...updateAppState },
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key === KEYS.G.toUpperCase(),
|
event.key === KEYS.G.toUpperCase(),
|
||||||
contextItemLabel: "labels.ungroup",
|
|
||||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||||
|
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
|
@ -1,105 +1,120 @@
|
|||||||
import { Action, ActionResult } from "./types";
|
import type { Action, ActionResult } from "./types";
|
||||||
import { UndoIcon, RedoIcon } from "../components/icons";
|
import { UndoIcon, RedoIcon } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import History, { HistoryEntry } from "../history";
|
import type { History } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { HistoryChangedEvent } from "../history";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
|
import type { SceneElementsMap } from "../element/types";
|
||||||
|
import type { Store } from "../store";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
prevElements: readonly ExcalidrawElement[],
|
appState: Readonly<AppState>,
|
||||||
appState: AppState,
|
updater: () => [SceneElementsMap, AppState] | void,
|
||||||
updater: () => HistoryEntry | null,
|
|
||||||
): ActionResult => {
|
): ActionResult => {
|
||||||
const commitToHistory = false;
|
|
||||||
if (
|
if (
|
||||||
!appState.multiElement &&
|
!appState.multiElement &&
|
||||||
!appState.resizingElement &&
|
!appState.resizingElement &&
|
||||||
!appState.editingElement &&
|
!appState.editingElement &&
|
||||||
!appState.draggingElement
|
!appState.draggingElement
|
||||||
) {
|
) {
|
||||||
const data = updater();
|
const result = updater();
|
||||||
if (data === null) {
|
|
||||||
return { commitToHistory };
|
if (!result) {
|
||||||
|
return { storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevElementMap = arrayToMap(prevElements);
|
const [nextElementsMap, nextAppState] = result;
|
||||||
const nextElements = data.elements;
|
const nextElements = Array.from(nextElementsMap.values());
|
||||||
const nextElementMap = arrayToMap(nextElements);
|
|
||||||
|
|
||||||
const deletedElements = prevElements.filter(
|
|
||||||
(prevElement) => !nextElementMap.has(prevElement.id),
|
|
||||||
);
|
|
||||||
const elements = nextElements
|
|
||||||
.map((nextElement) =>
|
|
||||||
newElementWith(
|
|
||||||
prevElementMap.get(nextElement.id) || nextElement,
|
|
||||||
nextElement,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.concat(
|
|
||||||
deletedElements.map((prevElement) =>
|
|
||||||
newElementWith(prevElement, { isDeleted: true }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixBindingsAfterDeletion(elements, deletedElements);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements,
|
appState: nextAppState,
|
||||||
appState: { ...appState, ...data.appState },
|
elements: nextElements,
|
||||||
commitToHistory,
|
storeAction: StoreAction.UPDATE,
|
||||||
syncHistory: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { commitToHistory };
|
|
||||||
|
return { storeAction: StoreAction.NONE };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History) => Action;
|
type ActionCreator = (history: History, store: Store) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
|
label: "buttons.undo",
|
||||||
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(appState, () =>
|
||||||
|
history.undo(
|
||||||
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
|
appState,
|
||||||
|
store.snapshot,
|
||||||
|
),
|
||||||
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.key.toLowerCase() === KEYS.Z &&
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
!event.shiftKey,
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData, data }) => {
|
||||||
<ToolButton
|
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||||
type="button"
|
history.onHistoryChangedEmitter,
|
||||||
icon={UndoIcon}
|
new HistoryChangedEvent(),
|
||||||
aria-label={t("buttons.undo")}
|
);
|
||||||
onClick={updateData}
|
|
||||||
size={data?.size || "medium"}
|
return (
|
||||||
/>
|
<ToolButton
|
||||||
),
|
type="button"
|
||||||
commitToHistory: () => false,
|
icon={UndoIcon}
|
||||||
|
aria-label={t("buttons.undo")}
|
||||||
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
|
disabled={isUndoStackEmpty}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createRedoAction: ActionCreator = (history) => ({
|
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||||
name: "redo",
|
name: "redo",
|
||||||
|
label: "buttons.redo",
|
||||||
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(appState, () =>
|
||||||
|
history.redo(
|
||||||
|
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||||
|
appState,
|
||||||
|
store.snapshot,
|
||||||
|
),
|
||||||
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
(event[KEYS.CTRL_OR_CMD] &&
|
(event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
event.key.toLowerCase() === KEYS.Z) ||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData, data }) => (
|
PanelComponent: ({ updateData, data }) => {
|
||||||
<ToolButton
|
const { isRedoStackEmpty } = useEmitter(
|
||||||
type="button"
|
history.onHistoryChangedEmitter,
|
||||||
icon={RedoIcon}
|
new HistoryChangedEvent(),
|
||||||
aria-label={t("buttons.redo")}
|
);
|
||||||
onClick={updateData}
|
|
||||||
size={data?.size || "medium"}
|
return (
|
||||||
/>
|
<ToolButton
|
||||||
),
|
type="button"
|
||||||
commitToHistory: () => false,
|
icon={RedoIcon}
|
||||||
|
aria-label={t("buttons.redo")}
|
||||||
|
onClick={updateData}
|
||||||
|
size={data?.size || "medium"}
|
||||||
|
disabled={isRedoStackEmpty}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
|
||||||
import { register } from "./register";
|
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
|
||||||
name: "toggleLinearEditor",
|
|
||||||
trackEvent: {
|
|
||||||
category: "element",
|
|
||||||
},
|
|
||||||
predicate: (elements, appState, _, app) => {
|
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
|
||||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
perform(elements, appState, _, app) {
|
|
||||||
const selectedElement = app.scene.getSelectedElements({
|
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
includeBoundTextElement: true,
|
|
||||||
})[0] as ExcalidrawLinearElement;
|
|
||||||
|
|
||||||
const editingLinearElement =
|
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
|
||||||
? null
|
|
||||||
: new LinearElementEditor(selectedElement, app.scene);
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
editingLinearElement,
|
|
||||||
},
|
|
||||||
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";
|
|
||||||
},
|
|
||||||
});
|
|
76
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
76
packages/excalidraw/actions/actionLinearEditor.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
import type { ExcalidrawLinearElement } from "../element/types";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { lineEditorIcon } from "../components/icons";
|
||||||
|
|
||||||
|
export const actionToggleLinearEditor = register({
|
||||||
|
name: "toggleLinearEditor",
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selectedElement = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
})[0] as ExcalidrawLinearElement | undefined;
|
||||||
|
|
||||||
|
return selectedElement?.type === "arrow"
|
||||||
|
? "labels.lineEditor.editArrow"
|
||||||
|
: "labels.lineEditor.edit";
|
||||||
|
},
|
||||||
|
keywords: ["line"],
|
||||||
|
trackEvent: {
|
||||||
|
category: "element",
|
||||||
|
},
|
||||||
|
predicate: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
if (
|
||||||
|
!appState.editingLinearElement &&
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
isLinearElement(selectedElements[0])
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
perform(elements, appState, _, app) {
|
||||||
|
const selectedElement = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const editingLinearElement =
|
||||||
|
appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
|
? null
|
||||||
|
: new LinearElementEditor(selectedElement);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
editingLinearElement,
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData, app }) => {
|
||||||
|
const selectedElement = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
const label = t(
|
||||||
|
selectedElement.type === "arrow"
|
||||||
|
? "labels.lineEditor.editArrow"
|
||||||
|
: "labels.lineEditor.edit",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ToolButton
|
||||||
|
type="button"
|
||||||
|
icon={lineEditorIcon}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
55
packages/excalidraw/actions/actionLink.tsx
Normal file
55
packages/excalidraw/actions/actionLink.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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 { StoreAction } from "../store";
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
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,19 +1,21 @@
|
|||||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
|
label: "buttons.menu",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_, appState) => ({
|
perform: (_, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
|
|||||||
|
|
||||||
export const actionToggleEditMenu = register({
|
export const actionToggleEditMenu = register({
|
||||||
name: "toggleEditMenu",
|
name: "toggleEditMenu",
|
||||||
|
label: "buttons.edit",
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform: (_elements, appState) => ({
|
perform: (_elements, appState) => ({
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
}),
|
}),
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
|
|||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
|
label: "welcomeScreen.defaults.helpHint",
|
||||||
|
icon: HelpIconThin,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||||
perform: (_elements, appState, _, { focusContainer }) => {
|
perform: (_elements, appState, _, { focusContainer }) => {
|
||||||
@ -69,7 +74,7 @@ export const actionShortcuts = register({
|
|||||||
name: "help",
|
name: "help",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
import type { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||||
import { eyeIcon } from "../components/icons";
|
import {
|
||||||
|
eyeIcon,
|
||||||
|
microphoneIcon,
|
||||||
|
microphoneMutedIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { Collaborator } from "../types";
|
import { StoreAction } from "../store";
|
||||||
|
import type { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
|
label: "Go to a collaborator",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "collab" },
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, collaborator: Collaborator) => {
|
perform: (_elements, appState, collaborator: Collaborator) => {
|
||||||
@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({
|
|||||||
...appState,
|
...appState,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({
|
|||||||
// Close mobile menu
|
// Close mobile menu
|
||||||
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data, appState }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||||
data as GoToCollaboratorComponentProps;
|
data as GoToCollaboratorComponentProps;
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
const statusClassNames = clsx({
|
||||||
|
"is-followed": isBeingFollowed,
|
||||||
|
"is-current-user": collaborator.isCurrentUser === true,
|
||||||
|
"is-speaking": collaborator.isSpeaking,
|
||||||
|
"is-in-call": collaborator.isInCall,
|
||||||
|
"is-muted": collaborator.isMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIconJSX = collaborator.isInCall ? (
|
||||||
|
collaborator.isSpeaking ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||||
|
title={t("userList.hint.isSpeaking")}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
) : collaborator.isMuted ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-microphone-muted"
|
||||||
|
title={t("userList.hint.micMuted")}
|
||||||
|
>
|
||||||
|
{microphoneMutedIcon}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return withName ? (
|
return withName ? (
|
||||||
<div
|
<div
|
||||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||||
|
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||||
onClick={() => updateData<Collaborator>(collaborator)}
|
onClick={() => updateData<Collaborator>(collaborator)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@ -54,32 +92,42 @@ export const actionGoToCollaborator = register({
|
|||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
isBeingFollowed={isBeingFollowed}
|
className={statusClassNames}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
|
||||||
/>
|
/>
|
||||||
<div className="UserList__collaborator-name">
|
<div className="UserList__collaborator-name">
|
||||||
{collaborator.username}
|
{collaborator.username}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||||
className="UserList__collaborator-follow-status-icon"
|
{isBeingFollowed && (
|
||||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
<div
|
||||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
className="UserList__collaborator-status-icon-is-followed"
|
||||||
aria-hidden
|
title={t("userList.hint.followStatus")}
|
||||||
>
|
>
|
||||||
{eyeIcon}
|
{eyeIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{statusIconJSX}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Avatar
|
<div
|
||||||
color={background}
|
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||||
onClick={() => {
|
>
|
||||||
updateData(collaborator);
|
<Avatar
|
||||||
}}
|
color={background}
|
||||||
name={collaborator.username || ""}
|
onClick={() => {
|
||||||
src={collaborator.avatarUrl}
|
updateData(collaborator);
|
||||||
isBeingFollowed={isBeingFollowed}
|
}}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
name={collaborator.username || ""}
|
||||||
/>
|
src={collaborator.avatarUrl}
|
||||||
|
className={statusClassNames}
|
||||||
|
/>
|
||||||
|
{statusIconJSX && (
|
||||||
|
<div className="UserList__collaborator-status-icon">
|
||||||
|
{statusIconJSX}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
@ -49,6 +49,7 @@ import {
|
|||||||
ArrowheadCircleOutlineIcon,
|
ArrowheadCircleOutlineIcon,
|
||||||
ArrowheadDiamondIcon,
|
ArrowheadDiamondIcon,
|
||||||
ArrowheadDiamondOutlineIcon,
|
ArrowheadDiamondOutlineIcon,
|
||||||
|
fontSizeIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
@ -73,7 +74,7 @@ import {
|
|||||||
isLinearElement,
|
isLinearElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import {
|
import type {
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -95,6 +96,7 @@ import {
|
|||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@ -209,6 +211,7 @@ const changeFontSize = (
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
|
|
||||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||||
@ -229,7 +232,7 @@ const changeFontSize = (
|
|||||||
? [...newFontSizes][0]
|
? [...newFontSizes][0]
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -237,6 +240,7 @@ const changeFontSize = (
|
|||||||
|
|
||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
|
label: "labels.stroke",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -258,7 +262,9 @@ export const actionChangeStrokeColor = register({
|
|||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
commitToHistory: !!value.currentItemStrokeColor,
|
storeAction: !!value.currentItemStrokeColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
@ -287,6 +293,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
|
|
||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
|
label: "labels.changeBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -301,7 +308,9 @@ export const actionChangeBackgroundColor = register({
|
|||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
commitToHistory: !!value.currentItemBackgroundColor,
|
storeAction: !!value.currentItemBackgroundColor
|
||||||
|
? StoreAction.CAPTURE
|
||||||
|
: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||||
@ -330,6 +339,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
|
|
||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
|
label: "labels.fill",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
trackEvent(
|
trackEvent(
|
||||||
@ -344,7 +354,7 @@ export const actionChangeFillStyle = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemFillStyle: value },
|
appState: { ...appState, currentItemFillStyle: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
@ -407,6 +417,7 @@ export const actionChangeFillStyle = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
|
label: "labels.strokeWidth",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -416,7 +427,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeWidth: value },
|
appState: { ...appState, currentItemStrokeWidth: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@ -460,6 +471,7 @@ export const actionChangeStrokeWidth = register({
|
|||||||
|
|
||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
|
label: "labels.sloppiness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -470,7 +482,7 @@ export const actionChangeSloppiness = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemRoughness: value },
|
appState: { ...appState, currentItemRoughness: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@ -511,6 +523,7 @@ export const actionChangeSloppiness = register({
|
|||||||
|
|
||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
|
label: "labels.strokeStyle",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -520,7 +533,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemStrokeStyle: value },
|
appState: { ...appState, currentItemStrokeStyle: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@ -561,6 +574,7 @@ export const actionChangeStrokeStyle = register({
|
|||||||
|
|
||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
|
label: "labels.opacity",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -574,7 +588,7 @@ export const actionChangeOpacity = register({
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
appState: { ...appState, currentItemOpacity: value },
|
appState: { ...appState, currentItemOpacity: value },
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
@ -602,6 +616,7 @@ export const actionChangeOpacity = register({
|
|||||||
|
|
||||||
export const actionChangeFontSize = register({
|
export const actionChangeFontSize = register({
|
||||||
name: "changeFontSize",
|
name: "changeFontSize",
|
||||||
|
label: "labels.fontSize",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
@ -672,6 +687,8 @@ export const actionChangeFontSize = register({
|
|||||||
|
|
||||||
export const actionDecreaseFontSize = register({
|
export const actionDecreaseFontSize = register({
|
||||||
name: "decreaseFontSize",
|
name: "decreaseFontSize",
|
||||||
|
label: "labels.decreaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
@ -694,6 +711,8 @@ export const actionDecreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionIncreaseFontSize = register({
|
export const actionIncreaseFontSize = register({
|
||||||
name: "increaseFontSize",
|
name: "increaseFontSize",
|
||||||
|
label: "labels.increaseFontSize",
|
||||||
|
icon: fontSizeIcon,
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, (element) =>
|
return changeFontSize(elements, appState, app, (element) =>
|
||||||
@ -712,6 +731,7 @@ export const actionIncreaseFontSize = register({
|
|||||||
|
|
||||||
export const actionChangeFontFamily = register({
|
export const actionChangeFontFamily = register({
|
||||||
name: "changeFontFamily",
|
name: "changeFontFamily",
|
||||||
|
label: "labels.fontFamily",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@ -730,6 +750,7 @@ export const actionChangeFontFamily = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@ -742,7 +763,7 @@ export const actionChangeFontFamily = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemFontFamily: value,
|
currentItemFontFamily: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
@ -814,6 +835,7 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
export const actionChangeTextAlign = register({
|
export const actionChangeTextAlign = register({
|
||||||
name: "changeTextAlign",
|
name: "changeTextAlign",
|
||||||
|
label: "Change text alignment",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@ -829,6 +851,7 @@ export const actionChangeTextAlign = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@ -841,7 +864,7 @@ export const actionChangeTextAlign = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemTextAlign: value,
|
currentItemTextAlign: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
@ -902,6 +925,7 @@ export const actionChangeTextAlign = register({
|
|||||||
|
|
||||||
export const actionChangeVerticalAlign = register({
|
export const actionChangeVerticalAlign = register({
|
||||||
name: "changeVerticalAlign",
|
name: "changeVerticalAlign",
|
||||||
|
label: "Change vertical alignment",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return {
|
return {
|
||||||
@ -918,6 +942,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
redrawTextBoundingBox(
|
redrawTextBoundingBox(
|
||||||
newElement,
|
newElement,
|
||||||
app.scene.getContainerElement(oldElement),
|
app.scene.getContainerElement(oldElement),
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
return newElement;
|
return newElement;
|
||||||
}
|
}
|
||||||
@ -929,7 +954,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||||
@ -990,6 +1015,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
|
|
||||||
export const actionChangeRoundness = register({
|
export const actionChangeRoundness = register({
|
||||||
name: "changeRoundness",
|
name: "changeRoundness",
|
||||||
|
label: "Change edge roundness",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
return {
|
return {
|
||||||
@ -1009,7 +1035,7 @@ export const actionChangeRoundness = register({
|
|||||||
...appState,
|
...appState,
|
||||||
currentItemRoundness: value,
|
currentItemRoundness: value,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
@ -1128,6 +1154,7 @@ const getArrowheadOptions = (flip: boolean) => {
|
|||||||
|
|
||||||
export const actionChangeArrowhead = register({
|
export const actionChangeArrowhead = register({
|
||||||
name: "changeArrowhead",
|
name: "changeArrowhead",
|
||||||
|
label: "Change arrowheads",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (
|
||||||
elements,
|
elements,
|
||||||
@ -1160,7 +1187,7 @@ export const actionChangeArrowhead = register({
|
|||||||
? "currentItemStartArrowhead"
|
? "currentItemStartArrowhead"
|
||||||
: "currentItemEndArrowhead"]: value.type,
|
: "currentItemEndArrowhead"]: value.type,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => {
|
PanelComponent: ({ elements, appState, updateData }) => {
|
||||||
|
@ -2,14 +2,19 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { selectGroupsForSelectedElements } from "../groups";
|
import { selectGroupsForSelectedElements } from "../groups";
|
||||||
import { getNonDeletedElements, isTextElement } from "../element";
|
import { getNonDeletedElements, isTextElement } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type { ExcalidrawElement } from "../element/types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
|
import { selectAllIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
|
label: "labels.selectAll",
|
||||||
|
icon: selectAllIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
@ -43,12 +48,11 @@ export const actionSelectAll = register({
|
|||||||
// single linear element selected
|
// single linear element selected
|
||||||
Object.keys(selectedElementIds).length === 1 &&
|
Object.keys(selectedElementIds).length === 1 &&
|
||||||
isLinearElement(elements[0])
|
isLinearElement(elements[0])
|
||||||
? new LinearElementEditor(elements[0], app.scene)
|
? new LinearElementEditor(elements[0])
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.selectAll",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
});
|
});
|
||||||
|
@ -24,13 +24,17 @@ import {
|
|||||||
isArrowElement,
|
isArrowElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
import type { ExcalidrawTextElement } from "../element/types";
|
||||||
|
import { paintIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
export let copiedStyles: string = "{}";
|
export let copiedStyles: string = "{}";
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
|
label: "labels.copyStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = [];
|
const elementsCopied = [];
|
||||||
@ -51,23 +55,24 @@ export const actionCopyStyles = register({
|
|||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.copyStyles") },
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
|
label: "labels.pasteStyles",
|
||||||
|
icon: paintIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
const elementsCopied = JSON.parse(copiedStyles);
|
const elementsCopied = JSON.parse(copiedStyles);
|
||||||
const pastedElement = elementsCopied[0];
|
const pastedElement = elementsCopied[0];
|
||||||
const boundTextElement = elementsCopied[1];
|
const boundTextElement = elementsCopied[1];
|
||||||
if (!isExcalidrawElement(pastedElement)) {
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, storeAction: StoreAction.NONE };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, {
|
const selectedElements = getSelectedElements(elements, appState, {
|
||||||
@ -128,7 +133,11 @@ export const actionPasteStyles = register({
|
|||||||
element.id === newElement.containerId,
|
element.id === newElement.containerId,
|
||||||
) || null;
|
) || null;
|
||||||
}
|
}
|
||||||
redrawTextBoundingBox(newElement, container);
|
redrawTextBoundingBox(
|
||||||
|
newElement,
|
||||||
|
container,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -152,10 +161,9 @@ export const actionPasteStyles = register({
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.pasteStyles",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { GRID_SIZE } from "../constants";
|
import { GRID_SIZE } from "../constants";
|
||||||
import { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
|
import { gridIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
|
icon: gridIcon,
|
||||||
|
keywords: ["snap"],
|
||||||
|
label: "labels.toggleGrid",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@ -17,13 +22,12 @@ export const actionToggleGridMode = register({
|
|||||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||||
objectsSnapModeEnabled: false,
|
objectsSnapModeEnabled: false,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.gridSize !== null,
|
checked: (appState: AppState) => appState.gridSize !== null,
|
||||||
predicate: (element, appState, props) => {
|
predicate: (element, appState, props) => {
|
||||||
return typeof props.gridModeEnabled === "undefined";
|
return typeof props.gridModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.showGrid",
|
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
|
import { magnetIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleObjectsSnapMode = register({
|
export const actionToggleObjectsSnapMode = register({
|
||||||
name: "objectsSnapMode",
|
name: "objectsSnapMode",
|
||||||
viewMode: true,
|
label: "buttons.objectsSnapMode",
|
||||||
|
icon: magnetIcon,
|
||||||
|
viewMode: false,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
predicate: (appState) => !appState.objectsSnapModeEnabled,
|
||||||
@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({
|
|||||||
objectsSnapModeEnabled: !this.checked!(appState),
|
objectsSnapModeEnabled: !this.checked!(appState),
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.objectsSnapModeEnabled,
|
checked: (appState) => appState.objectsSnapModeEnabled,
|
||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
return typeof appProps.objectsSnapModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "buttons.objectsSnapMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { abacusIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
|
label: "stats.title",
|
||||||
|
icon: abacusIcon,
|
||||||
|
paletteName: "Toggle stats",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "menu" },
|
trackEvent: { category: "menu" },
|
||||||
perform(elements, appState) {
|
perform(elements, appState) {
|
||||||
@ -11,11 +16,10 @@ export const actionToggleStats = register({
|
|||||||
...appState,
|
...appState,
|
||||||
showStats: !this.checked!(appState),
|
showStats: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.showStats,
|
checked: (appState) => appState.showStats,
|
||||||
contextItemLabel: "stats.title",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
|
import { eyeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleViewMode = register({
|
export const actionToggleViewMode = register({
|
||||||
name: "viewMode",
|
name: "viewMode",
|
||||||
|
label: "labels.viewMode",
|
||||||
|
paletteName: "Toggle view mode",
|
||||||
|
icon: eyeIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@ -14,14 +19,13 @@ export const actionToggleViewMode = register({
|
|||||||
...appState,
|
...appState,
|
||||||
viewModeEnabled: !this.checked!(appState),
|
viewModeEnabled: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.viewModeEnabled,
|
checked: (appState) => appState.viewModeEnabled,
|
||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.viewModeEnabled === "undefined";
|
return typeof appProps.viewModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.viewMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
|
import { coffeeIcon } from "../components/icons";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleZenMode = register({
|
export const actionToggleZenMode = register({
|
||||||
name: "zenMode",
|
name: "zenMode",
|
||||||
|
label: "buttons.zenMode",
|
||||||
|
icon: coffeeIcon,
|
||||||
|
paletteName: "Toggle zen mode",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@ -14,14 +19,13 @@ export const actionToggleZenMode = register({
|
|||||||
...appState,
|
...appState,
|
||||||
zenModeEnabled: !this.checked!(appState),
|
zenModeEnabled: !this.checked!(appState),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState) => appState.zenModeEnabled,
|
checked: (appState) => appState.zenModeEnabled,
|
||||||
predicate: (elements, appState, appProps) => {
|
predicate: (elements, appState, appProps) => {
|
||||||
return typeof appProps.zenModeEnabled === "undefined";
|
return typeof appProps.zenModeEnabled === "undefined";
|
||||||
},
|
},
|
||||||
contextItemLabel: "buttons.zenMode",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
moveOneRight,
|
moveOneRight,
|
||||||
@ -16,18 +15,21 @@ import {
|
|||||||
SendToBackIcon,
|
SendToBackIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { isDarwin } from "../constants";
|
import { isDarwin } from "../constants";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionSendBackward = register({
|
export const actionSendBackward = register({
|
||||||
name: "sendBackward",
|
name: "sendBackward",
|
||||||
|
label: "labels.sendBackward",
|
||||||
|
keywords: ["move down", "zindex", "layer"],
|
||||||
|
icon: SendBackwardIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendBackward",
|
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@ -47,15 +49,17 @@ export const actionSendBackward = register({
|
|||||||
|
|
||||||
export const actionBringForward = register({
|
export const actionBringForward = register({
|
||||||
name: "bringForward",
|
name: "bringForward",
|
||||||
|
label: "labels.bringForward",
|
||||||
|
keywords: ["move up", "zindex", "layer"],
|
||||||
|
icon: BringForwardIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringForward",
|
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
@ -75,15 +79,17 @@ export const actionBringForward = register({
|
|||||||
|
|
||||||
export const actionSendToBack = register({
|
export const actionSendToBack = register({
|
||||||
name: "sendToBack",
|
name: "sendToBack",
|
||||||
|
label: "labels.sendToBack",
|
||||||
|
keywords: ["move down", "zindex", "layer"],
|
||||||
|
icon: SendToBackIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.sendToBack",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
@ -110,16 +116,18 @@ export const actionSendToBack = register({
|
|||||||
|
|
||||||
export const actionBringToFront = register({
|
export const actionBringToFront = register({
|
||||||
name: "bringToFront",
|
name: "bringToFront",
|
||||||
|
label: "labels.bringToFront",
|
||||||
|
keywords: ["move up", "zindex", "layer"],
|
||||||
|
icon: BringToFrontIcon,
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.bringToFront",
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
isDarwin
|
isDarwin
|
||||||
? event[KEYS.CTRL_OR_CMD] &&
|
? event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
|
|||||||
|
|
||||||
export { actionToggleStats } from "./actionToggleStats";
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||||
export { actionLink } from "../element/Hyperlink";
|
export { actionLink } from "./actionLink";
|
||||||
export { actionToggleElementLock } from "./actionElementLock";
|
export { actionToggleElementLock } from "./actionElementLock";
|
||||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import type {
|
||||||
Action,
|
Action,
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionName,
|
ActionName,
|
||||||
@ -8,8 +8,11 @@ import {
|
|||||||
ActionSource,
|
ActionSource,
|
||||||
ActionPredicateFn,
|
ActionPredicateFn,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type {
|
||||||
import { AppClassProperties, AppState } from "../types";
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import type { AppClassProperties, AppState } from "../types";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { isPromiseLike } from "../utils";
|
import { isPromiseLike } from "../utils";
|
||||||
|
|
||||||
@ -48,13 +51,13 @@ export class ActionManager {
|
|||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Action } from "./types";
|
import type { Action } from "./types";
|
||||||
|
|
||||||
export let actions: readonly Action[] = [];
|
export let actions: readonly Action[] = [];
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { isDarwin } from "../constants";
|
import { isDarwin } from "../constants";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { SubtypeOf } from "../utility-types";
|
import type { SubtypeOf } from "../utility-types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { ActionName, CustomActionName } from "./types";
|
import type { ActionName, CustomActionName } from "./types";
|
||||||
|
|
||||||
export type ShortcutName =
|
export type ShortcutName =
|
||||||
| SubtypeOf<
|
| SubtypeOf<
|
||||||
@ -37,9 +37,22 @@ export type ShortcutName =
|
|||||||
| "flipVertical"
|
| "flipVertical"
|
||||||
| "hyperlink"
|
| "hyperlink"
|
||||||
| "toggleElementLock"
|
| "toggleElementLock"
|
||||||
|
| "resetZoom"
|
||||||
|
| "zoomOut"
|
||||||
|
| "zoomIn"
|
||||||
|
| "zoomToFit"
|
||||||
|
| "zoomToFitSelectionInViewport"
|
||||||
|
| "zoomToFitSelection"
|
||||||
|
| "toggleEraserTool"
|
||||||
|
| "toggleHandTool"
|
||||||
|
| "setFrameAsActiveTool"
|
||||||
|
| "saveFileToDisk"
|
||||||
|
| "saveToActiveFile"
|
||||||
|
| "toggleShortcuts"
|
||||||
>
|
>
|
||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport";
|
| "imageExport"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
export const registerCustomShortcuts = (
|
export const registerCustomShortcuts = (
|
||||||
shortcuts: Record<CustomActionName, string[]>,
|
shortcuts: Record<CustomActionName, string[]>,
|
||||||
@ -56,6 +69,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
loadScene: [getShortcutKey("CtrlOrCmd+O")],
|
||||||
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
|
||||||
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
|
||||||
|
commandPalette: [
|
||||||
|
getShortcutKey("CtrlOrCmd+/"),
|
||||||
|
getShortcutKey("CtrlOrCmd+Shift+P"),
|
||||||
|
],
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
copy: [getShortcutKey("CtrlOrCmd+C")],
|
copy: [getShortcutKey("CtrlOrCmd+C")],
|
||||||
paste: [getShortcutKey("CtrlOrCmd+V")],
|
paste: [getShortcutKey("CtrlOrCmd+V")],
|
||||||
@ -93,10 +110,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
viewMode: [getShortcutKey("Alt+R")],
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||||
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||||
|
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
|
||||||
|
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
|
||||||
|
zoomIn: [getShortcutKey("CtrlOrCmd++")],
|
||||||
|
zoomToFitSelection: [getShortcutKey("Shift+3")],
|
||||||
|
zoomToFit: [getShortcutKey("Shift+1")],
|
||||||
|
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
|
||||||
|
toggleEraserTool: [getShortcutKey("E")],
|
||||||
|
toggleHandTool: [getShortcutKey("H")],
|
||||||
|
setFrameAsActiveTool: [getShortcutKey("F")],
|
||||||
|
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
|
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||||
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
const shortcuts = shortcutMap[name];
|
const shortcuts = shortcutMap[name];
|
||||||
// if multiple shortcuts available, take the first one
|
// if multiple shortcuts available, take the first one
|
||||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
return shortcuts && shortcuts.length > 0
|
||||||
|
? shortcuts[idx] || shortcuts[0]
|
||||||
|
: "";
|
||||||
};
|
};
|
||||||
|
@ -1,14 +1,24 @@
|
|||||||
import React from "react";
|
import type React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import type {
|
||||||
import {
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
ExcalidrawProps,
|
ExcalidrawProps,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import type { MarkOptional } from "../utility-types";
|
||||||
|
import type { StoreActionType } from "../store";
|
||||||
|
|
||||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
export type ActionSource =
|
||||||
|
| "ui"
|
||||||
|
| "keyboard"
|
||||||
|
| "contextMenu"
|
||||||
|
| "api"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
/** if false, the action should be prevented */
|
/** if false, the action should be prevented */
|
||||||
export type ActionResult =
|
export type ActionResult =
|
||||||
@ -19,14 +29,13 @@ export type ActionResult =
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
files?: BinaryFiles | null;
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
storeAction: StoreActionType;
|
||||||
syncHistory?: boolean;
|
|
||||||
replaceFiles?: boolean;
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|
||||||
type ActionFn = (
|
type ActionFn = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
@ -138,7 +147,8 @@ export type ActionName =
|
|||||||
| "setFrameAsActiveTool"
|
| "setFrameAsActiveTool"
|
||||||
| "setEmbeddableAsActiveTool"
|
| "setEmbeddableAsActiveTool"
|
||||||
| "createContainerFromText"
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer";
|
| "wrapTextInContainer"
|
||||||
|
| "commandPalette";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -151,6 +161,20 @@ export type PanelComponentProps = {
|
|||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
|
label:
|
||||||
|
| string
|
||||||
|
| ((
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
app: AppClassProperties,
|
||||||
|
) => string);
|
||||||
|
keywords?: string[];
|
||||||
|
icon?:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((
|
||||||
|
appState: UIAppState,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
) => React.ReactNode);
|
||||||
PanelComponent?: React.FC<PanelComponentProps>;
|
PanelComponent?: React.FC<PanelComponentProps>;
|
||||||
perform: ActionFn;
|
perform: ActionFn;
|
||||||
keyPriority?: number;
|
keyPriority?: number;
|
||||||
@ -160,13 +184,6 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?:
|
|
||||||
| string
|
|
||||||
| ((
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: Readonly<AppState>,
|
|
||||||
app: AppClassProperties,
|
|
||||||
) => string);
|
|
||||||
predicate?: (
|
predicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ElementsMap, ExcalidrawElement } from "./element/types";
|
import type { ElementsMap, ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
import type { BoundingBox } from "./element/bounds";
|
||||||
|
import { getCommonBoundingBox } from "./element/bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
|
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
|
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
|
||||||
import { AnimationFrameHandler } from "./animation-frame-handler";
|
import { LaserPointer } from "@excalidraw/laser-pointer";
|
||||||
import { AppState } from "./types";
|
import type { AnimationFrameHandler } from "./animation-frame-handler";
|
||||||
|
import type { AppState } from "./types";
|
||||||
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
|
||||||
import type App from "./components/App";
|
import type App from "./components/App";
|
||||||
import { SVG_NS } from "./constants";
|
import { SVG_NS } from "./constants";
|
||||||
|
@ -7,9 +7,7 @@ import {
|
|||||||
EXPORT_SCALES,
|
EXPORT_SCALES,
|
||||||
THEME,
|
THEME,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { AppState, NormalizedZoomValue } from "./types";
|
|
||||||
import { getDateTime } from "./utils";
|
|
||||||
|
|
||||||
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
|
||||||
? devicePixelRatio
|
? devicePixelRatio
|
||||||
@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
isRotating: false,
|
isRotating: false,
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
name: null,
|
||||||
contextMenu: null,
|
contextMenu: null,
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
openPopup: null,
|
openPopup: null,
|
||||||
|
1525
packages/excalidraw/change.ts
Normal file
1525
packages/excalidraw/change.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,5 @@
|
|||||||
import {
|
import type { Spreadsheet } from "./charts";
|
||||||
Spreadsheet,
|
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
|
||||||
tryParseCells,
|
|
||||||
tryParseNumber,
|
|
||||||
VALID_SPREADSHEET,
|
|
||||||
} from "./charts";
|
|
||||||
|
|
||||||
describe("charts", () => {
|
describe("charts", () => {
|
||||||
describe("tryParseNumber", () => {
|
describe("tryParseNumber", () => {
|
||||||
|
@ -9,9 +9,9 @@ import {
|
|||||||
VERTICAL_ALIGN,
|
VERTICAL_ALIGN,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
import type { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
import { AppState } from "./types";
|
import type { AppState } from "./types";
|
||||||
import { selectSubtype } from "./element/subtypes";
|
import { selectSubtype } from "./element/subtypes";
|
||||||
|
|
||||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||||
|
@ -1,3 +1,18 @@
|
|||||||
|
import {
|
||||||
|
COLOR_CHARCOAL_BLACK,
|
||||||
|
COLOR_VOICE_CALL,
|
||||||
|
COLOR_WHITE,
|
||||||
|
THEME,
|
||||||
|
} from "./constants";
|
||||||
|
import { roundRect } from "./renderer/roundRect";
|
||||||
|
import type { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||||
|
import type {
|
||||||
|
Collaborator,
|
||||||
|
InteractiveCanvasAppState,
|
||||||
|
SocketId,
|
||||||
|
} from "./types";
|
||||||
|
import { UserIdleState } from "./types";
|
||||||
|
|
||||||
function hashToInteger(id: string) {
|
function hashToInteger(id: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
if (id.length === 0) {
|
if (id.length === 0) {
|
||||||
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getClientColor = (
|
export const getClientColor = (
|
||||||
/**
|
socketId: SocketId,
|
||||||
* any uniquely identifying key, such as user id or socket id
|
collaborator: Collaborator | undefined,
|
||||||
*/
|
|
||||||
id: string,
|
|
||||||
) => {
|
) => {
|
||||||
// to get more even distribution in case `id` is not uniformly distributed to
|
// to get more even distribution in case `id` is not uniformly distributed to
|
||||||
// begin with, we hash it
|
// begin with, we hash it
|
||||||
const hash = Math.abs(hashToInteger(id));
|
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
|
||||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||||
// words a hue value of step size 10). There are 37 such values including 0.
|
// words a hue value of step size 10). There are 37 such values including 0.
|
||||||
const hue = (hash % 37) * 10;
|
const hue = (hash % 37) * 10;
|
||||||
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
|||||||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const renderRemoteCursors = ({
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
}: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
renderConfig: InteractiveCanvasRenderConfig;
|
||||||
|
appState: InteractiveCanvasAppState;
|
||||||
|
normalizedWidth: number;
|
||||||
|
normalizedHeight: number;
|
||||||
|
}) => {
|
||||||
|
// Paint remote pointers
|
||||||
|
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
|
||||||
|
let { x, y } = pointer;
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(socketId);
|
||||||
|
|
||||||
|
x -= appState.offsetLeft;
|
||||||
|
y -= appState.offsetTop;
|
||||||
|
|
||||||
|
const width = 11;
|
||||||
|
const height = 14;
|
||||||
|
|
||||||
|
const isOutOfBounds =
|
||||||
|
x < 0 ||
|
||||||
|
x > normalizedWidth - width ||
|
||||||
|
y < 0 ||
|
||||||
|
y > normalizedHeight - height;
|
||||||
|
|
||||||
|
x = Math.max(x, 0);
|
||||||
|
x = Math.min(x, normalizedWidth - width);
|
||||||
|
y = Math.max(y, 0);
|
||||||
|
y = Math.min(y, normalizedHeight - height);
|
||||||
|
|
||||||
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.fillStyle = background;
|
||||||
|
|
||||||
|
const userState = renderConfig.remotePointerUserStates.get(socketId);
|
||||||
|
const isInactive =
|
||||||
|
isOutOfBounds ||
|
||||||
|
userState === UserIdleState.IDLE ||
|
||||||
|
userState === UserIdleState.AWAY;
|
||||||
|
|
||||||
|
if (isInactive) {
|
||||||
|
context.globalAlpha = 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderConfig.remotePointerButton.get(socketId) === "down") {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.strokeStyle = "#ffffff88";
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||||
|
const IS_SPEAKING_COLOR =
|
||||||
|
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||||
|
|
||||||
|
const isSpeaking = collaborator?.isSpeaking;
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
// cursor outline for currently speaking user
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.lineWidth = 10;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background (white outline) for arrow
|
||||||
|
context.fillStyle = COLOR_WHITE;
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.lineWidth = 6;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
if (isInactive) {
|
||||||
|
context.moveTo(x - 1, y - 1);
|
||||||
|
context.lineTo(x - 1, y + 15);
|
||||||
|
context.lineTo(x + 5, y + 10);
|
||||||
|
context.lineTo(x + 12, y + 9);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
} else {
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
|
||||||
|
|
||||||
|
if (!isOutOfBounds && username) {
|
||||||
|
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||||
|
|
||||||
|
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
|
||||||
|
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
|
||||||
|
const paddingHorizontal = 5;
|
||||||
|
const paddingVertical = 3;
|
||||||
|
const measure = context.measureText(username);
|
||||||
|
const measureHeight =
|
||||||
|
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||||
|
const finalHeight = Math.max(measureHeight, 12);
|
||||||
|
|
||||||
|
const boxX = offsetX - 1;
|
||||||
|
const boxY = offsetY - 1;
|
||||||
|
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||||
|
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||||
|
if (context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
|
||||||
|
}
|
||||||
|
context.fillStyle = COLOR_CHARCOAL_BLACK;
|
||||||
|
|
||||||
|
context.fillText(
|
||||||
|
username,
|
||||||
|
offsetX + paddingHorizontal + 1,
|
||||||
|
offsetY +
|
||||||
|
paddingVertical +
|
||||||
|
measure.actualBoundingBoxAscent +
|
||||||
|
Math.floor((finalHeight - measureHeight) / 2) +
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// draw three vertical bars signalling someone is speaking
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
const barheight = 8;
|
||||||
|
const margin = 8;
|
||||||
|
const gap = 5;
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap,
|
||||||
|
boxY + (boxHeight / 2 - (barheight * 2) / 2),
|
||||||
|
2,
|
||||||
|
barheight * 2,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap * 2,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { AppState, BinaryFiles } from "./types";
|
import type { AppState, BinaryFiles } from "./types";
|
||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import {
|
import {
|
||||||
ALLOWED_PASTE_MIME_TYPES,
|
ALLOWED_PASTE_MIME_TYPES,
|
||||||
EXPORT_DATA_TYPES,
|
EXPORT_DATA_TYPES,
|
||||||
@ -16,8 +17,7 @@ import {
|
|||||||
import { deepCopyElement } from "./element/newElement";
|
import { deepCopyElement } from "./element/newElement";
|
||||||
import { mutateElement } from "./element/mutateElement";
|
import { mutateElement } from "./element/mutateElement";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { isMemberOf, isPromiseLike } from "./utils";
|
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
|
||||||
import { t } from "./i18n";
|
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||||
@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
}) => {
|
}) => {
|
||||||
|
const elementsMap = arrayToMap(elements);
|
||||||
const framesToCopy = new Set(
|
const framesToCopy = new Set(
|
||||||
elements.filter((element) => isFrameLikeElement(element)),
|
elements.filter((element) => isFrameLikeElement(element)),
|
||||||
);
|
);
|
||||||
@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({
|
|||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
getContainingFrame(element) &&
|
getContainingFrame(element, elementsMap) &&
|
||||||
!framesToCopy.has(getContainingFrame(element)!)
|
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
|
||||||
) {
|
) {
|
||||||
const copiedElement = deepCopyElement(element);
|
const copiedElement = deepCopyElement(element);
|
||||||
mutateElement(copiedElement, {
|
mutateElement(copiedElement, {
|
||||||
@ -439,7 +440,7 @@ export const copyTextToSystemClipboard = async (
|
|||||||
|
|
||||||
// (3) if that fails, use document.execCommand
|
// (3) if that fails, use document.execCommand
|
||||||
if (!copyTextViaExecCommand(text)) {
|
if (!copyTextViaExecCommand(text)) {
|
||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error("Error copying to clipboard.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { Merge } from "./utility-types";
|
import type { Merge } from "./utility-types";
|
||||||
|
|
||||||
// FIXME can't put to utils.ts rn because of circular dependency
|
// FIXME can't put to utils.ts rn because of circular dependency
|
||||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
width: var(--lg-button-size);
|
width: var(--lg-button-size);
|
||||||
height: var(--lg-button-size);
|
height: var(--lg-button-size);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: var(--lg-icon-size) !important;
|
width: var(--lg-icon-size) !important;
|
||||||
height: var(--lg-icon-size) !important;
|
height: var(--lg-icon-size) !important;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import type { ActionManager } from "../actions/manager";
|
||||||
import {
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@ -16,14 +17,18 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||||
import { capitalizeString, isTransparent } from "../utils";
|
import { capitalizeString, isTransparent } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { SubtypeShapeActions } from "./Subtypes";
|
import { SubtypeShapeActions } from "./Subtypes";
|
||||||
import { hasStrokeColor } from "../scene/comparisons";
|
import { hasStrokeColor } from "../scene/comparisons";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
|
import {
|
||||||
|
hasBoundTextElement,
|
||||||
|
isLinearElement,
|
||||||
|
isTextElement,
|
||||||
|
} from "../element/typeChecks";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { actionToggleZenMode } from "../actions";
|
import { actionToggleZenMode } from "../actions";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
@ -46,6 +51,40 @@ import {
|
|||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
|
||||||
|
export const canChangeStrokeColor = (
|
||||||
|
appState: UIAppState,
|
||||||
|
targetElements: ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
let commonSelectedType: ExcalidrawElementType | null =
|
||||||
|
targetElements[0]?.type || null;
|
||||||
|
|
||||||
|
for (const element of targetElements) {
|
||||||
|
if (element.type !== commonSelectedType) {
|
||||||
|
commonSelectedType = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(hasStrokeColor(appState.activeTool.type) &&
|
||||||
|
appState.activeTool.type !== "image" &&
|
||||||
|
commonSelectedType !== "image" &&
|
||||||
|
commonSelectedType !== "frame" &&
|
||||||
|
commonSelectedType !== "magicframe") ||
|
||||||
|
targetElements.some((element) => hasStrokeColor(element.type))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canChangeBackgroundColor = (
|
||||||
|
appState: UIAppState,
|
||||||
|
targetElements: ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
hasBackground(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasBackground(element.type))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -76,35 +115,22 @@ export const SelectedShapeActions = ({
|
|||||||
(element) =>
|
(element) =>
|
||||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
);
|
);
|
||||||
const showChangeBackgroundIcons =
|
|
||||||
hasBackground(appState.activeTool.type) ||
|
|
||||||
targetElements.some((element) => hasBackground(element.type));
|
|
||||||
|
|
||||||
const showLinkIcon =
|
const showLinkIcon =
|
||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
let commonSelectedType: ExcalidrawElementType | null =
|
const showLineEditorAction =
|
||||||
targetElements[0]?.type || null;
|
!appState.editingLinearElement &&
|
||||||
|
targetElements.length === 1 &&
|
||||||
for (const element of targetElements) {
|
isLinearElement(targetElements[0]);
|
||||||
if (element.type !== commonSelectedType) {
|
|
||||||
commonSelectedType = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<div>
|
<div>
|
||||||
{((hasStrokeColor(appState.activeTool.type) &&
|
{canChangeStrokeColor(appState, targetElements) &&
|
||||||
appState.activeTool.type !== "image" &&
|
|
||||||
commonSelectedType !== "image" &&
|
|
||||||
commonSelectedType !== "frame" &&
|
|
||||||
commonSelectedType !== "magicframe") ||
|
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
|
||||||
renderAction("changeStrokeColor")}
|
renderAction("changeStrokeColor")}
|
||||||
</div>
|
</div>
|
||||||
{showChangeBackgroundIcons && (
|
{canChangeBackgroundColor(appState, targetElements) && (
|
||||||
<div>{renderAction("changeBackgroundColor")}</div>
|
<div>{renderAction("changeBackgroundColor")}</div>
|
||||||
)}
|
)}
|
||||||
<SubtypeShapeActions elements={targetElements} />
|
<SubtypeShapeActions elements={targetElements} />
|
||||||
@ -158,8 +184,8 @@ export const SelectedShapeActions = ({
|
|||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{renderAction("sendToBack")}
|
{renderAction("sendToBack")}
|
||||||
{renderAction("sendBackward")}
|
{renderAction("sendBackward")}
|
||||||
{renderAction("bringToFront")}
|
|
||||||
{renderAction("bringForward")}
|
{renderAction("bringForward")}
|
||||||
|
{renderAction("bringToFront")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@ -214,6 +240,7 @@ export const SelectedShapeActions = ({
|
|||||||
{renderAction("group")}
|
{renderAction("group")}
|
||||||
{renderAction("ungroup")}
|
{renderAction("ungroup")}
|
||||||
{showLinkIcon && renderAction("hyperlink")}
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
|
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
@ -308,6 +335,25 @@ export const ShapesSwitcher = ({
|
|||||||
title={t("toolBar.extraTools")}
|
title={t("toolBar.extraTools")}
|
||||||
>
|
>
|
||||||
{extraToolsIcon}
|
{extraToolsIcon}
|
||||||
|
{app.props.aiEnabled !== false && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
marginLeft: "auto",
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 8,
|
||||||
|
fontFamily: "Cascadia, monospace",
|
||||||
|
position: "absolute",
|
||||||
|
background: "var(--color-promo)",
|
||||||
|
color: "var(--color-surface-lowest)",
|
||||||
|
bottom: 3,
|
||||||
|
right: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -9,8 +9,7 @@ type AvatarProps = {
|
|||||||
color: string;
|
color: string;
|
||||||
name: string;
|
name: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
isBeingFollowed?: boolean;
|
className?: string;
|
||||||
isCurrentUser: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = ({
|
||||||
@ -18,22 +17,14 @@ export const Avatar = ({
|
|||||||
onClick,
|
onClick,
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
isBeingFollowed,
|
className,
|
||||||
isCurrentUser,
|
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const shortName = getNameInitial(name);
|
const shortName = getNameInitial(name);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const loadImg = !error && src;
|
const loadImg = !error && src;
|
||||||
const style = loadImg ? undefined : { background: color };
|
const style = loadImg ? undefined : { background: color };
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||||
className={clsx("Avatar", {
|
|
||||||
"Avatar--is-followed": isBeingFollowed,
|
|
||||||
"Avatar--is-current-user": isCurrentUser,
|
|
||||||
})}
|
|
||||||
style={style}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{loadImg ? (
|
{loadImg ? (
|
||||||
<img
|
<img
|
||||||
className="Avatar-img"
|
className="Avatar-img"
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getColor } from "./ColorPicker";
|
import { getColor } from "./ColorPicker";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
ColorPickerType,
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
activeColorPickerSectionAtom,
|
|
||||||
} from "./colorPickerUtils";
|
|
||||||
import { eyeDropperIcon } from "../icons";
|
import { eyeDropperIcon } from "../icons";
|
||||||
import { jotaiScope } from "../../jotai";
|
import { jotaiScope } from "../../jotai";
|
||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { TopPicks } from "./TopPicks";
|
import { TopPicks } from "./TopPicks";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
activeColorPickerSectionAtom,
|
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||||
ColorPickerType,
|
|
||||||
} from "./colorPickerUtils";
|
|
||||||
import { useDevice, useExcalidrawContainer } from "../App";
|
import { useDevice, useExcalidrawContainer } from "../App";
|
||||||
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
|
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||||
|
import { COLOR_PALETTE } from "../../colors";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { ShadeList } from "./ShadeList";
|
import { ShadeList } from "./ShadeList";
|
||||||
|
|
||||||
import PickerColorList from "./PickerColorList";
|
import PickerColorList from "./PickerColorList";
|
||||||
@ -9,15 +9,15 @@ import { useAtom } from "jotai";
|
|||||||
import { CustomColorList } from "./CustomColorList";
|
import { CustomColorList } from "./CustomColorList";
|
||||||
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
|
||||||
import PickerHeading from "./PickerHeading";
|
import PickerHeading from "./PickerHeading";
|
||||||
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import {
|
import {
|
||||||
ColorPickerType,
|
|
||||||
activeColorPickerSectionAtom,
|
activeColorPickerSectionAtom,
|
||||||
getColorNameAndShadeFromColor,
|
getColorNameAndShadeFromColor,
|
||||||
getMostUsedCustomColors,
|
getMostUsedCustomColors,
|
||||||
isCustomColor,
|
isCustomColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
|
import type { ColorPaletteCustom } from "../../colors";
|
||||||
import {
|
import {
|
||||||
ColorPaletteCustom,
|
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||||
} from "../../colors";
|
} from "../../colors";
|
||||||
|
@ -7,8 +7,9 @@ import {
|
|||||||
getColorNameAndShadeFromColor,
|
getColorNameAndShadeFromColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
import { ColorPaletteCustom } from "../../colors";
|
import type { ColorPaletteCustom } from "../../colors";
|
||||||
import { TranslationKeys, t } from "../../i18n";
|
import type { TranslationKeys } from "../../i18n";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
|
||||||
interface PickerColorListProps {
|
interface PickerColorListProps {
|
||||||
palette: ColorPaletteCustom;
|
palette: ColorPaletteCustom;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
const PickerHeading = ({ children }: { children: ReactNode }) => (
|
||||||
<div className="color-picker__heading">{children}</div>
|
<div className="color-picker__heading">{children}</div>
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
import HotkeyLabel from "./HotkeyLabel";
|
import HotkeyLabel from "./HotkeyLabel";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { ColorPaletteCustom } from "../../colors";
|
import type { ColorPaletteCustom } from "../../colors";
|
||||||
|
|
||||||
interface ShadeListProps {
|
interface ShadeListProps {
|
||||||
hex: string;
|
hex: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ColorPickerType } from "./colorPickerUtils";
|
import type { ColorPickerType } from "./colorPickerUtils";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
DEFAULT_CANVAS_BACKGROUND_PICKS,
|
||||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import {
|
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
|
||||||
ColorPickerColor,
|
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
|
||||||
ColorPaletteCustom,
|
|
||||||
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
|
|
||||||
} from "../../colors";
|
|
||||||
|
|
||||||
export const getColorNameAndShadeFromColor = ({
|
export const getColorNameAndShadeFromColor = ({
|
||||||
palette,
|
palette,
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import {
|
import type {
|
||||||
ColorPickerColor,
|
ColorPickerColor,
|
||||||
ColorPalette,
|
ColorPalette,
|
||||||
ColorPaletteCustom,
|
ColorPaletteCustom,
|
||||||
COLORS_PER_ROW,
|
|
||||||
COLOR_PALETTE,
|
|
||||||
} from "../../colors";
|
} from "../../colors";
|
||||||
import { ValueOf } from "../../utility-types";
|
import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors";
|
||||||
|
import type { ValueOf } from "../../utility-types";
|
||||||
|
import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
|
||||||
import {
|
import {
|
||||||
ActiveColorPickerSectionAtomType,
|
|
||||||
colorPickerHotkeyBindings,
|
colorPickerHotkeyBindings,
|
||||||
getColorNameAndShadeFromColor,
|
getColorNameAndShadeFromColor,
|
||||||
} from "./colorPickerUtils";
|
} from "./colorPickerUtils";
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
934
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
934
packages/excalidraw/components/CommandPalette/CommandPalette.tsx
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
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 type { Action } from "../../actions/types";
|
||||||
|
import type { TranslationKeys } from "../../i18n";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { ShortcutName } from "../../actions/shortcuts";
|
||||||
|
import { 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 type { AppProps, AppState, UIAppState } from "../../types";
|
||||||
|
import {
|
||||||
|
capitalizeString,
|
||||||
|
getShortcutKey,
|
||||||
|
isWritableElement,
|
||||||
|
} from "../../utils";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { deburr } from "../../deburr";
|
||||||
|
import type { 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 type { CommandPaletteItem } from "./types";
|
||||||
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const nextState =
|
||||||
|
appState.openDialog?.name === "commandPalette"
|
||||||
|
? null
|
||||||
|
: ({ name: "commandPalette" } as const);
|
||||||
|
|
||||||
|
if (nextState) {
|
||||||
|
trackEvent("command_palette", "open", "shortcut");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openDialog: nextState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
|
||||||
|
const stableDeps = useStable({
|
||||||
|
uiAppState,
|
||||||
|
customCommandPaletteItems,
|
||||||
|
appProps,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// these props change often and we don't want them to re-run the effect
|
||||||
|
// which would renew `allCommands`, cascading down and resetting state.
|
||||||
|
//
|
||||||
|
// This means that the commands won't update on appState/appProps changes
|
||||||
|
// while the command palette is open
|
||||||
|
const { uiAppState, customCommandPaletteItems, appProps } = stableDeps;
|
||||||
|
|
||||||
|
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.bringToFront,
|
||||||
|
actionManager.actions.bringForward,
|
||||||
|
actionManager.actions.sendBackward,
|
||||||
|
actionManager.actions.sendToBack,
|
||||||
|
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.gridMode,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
stableDeps,
|
||||||
|
app,
|
||||||
|
actionManager,
|
||||||
|
setAllCommands,
|
||||||
|
lastUsed?.label,
|
||||||
|
setLastUsed,
|
||||||
|
setAppState,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 type { CommandPaletteItem } from "./types";
|
||||||
|
|
||||||
|
export const toggleTheme: CommandPaletteItem = {
|
||||||
|
...actionToggleTheme,
|
||||||
|
category: "App",
|
||||||
|
label: "Toggle theme",
|
||||||
|
perform: ({ actionManager }) => {
|
||||||
|
actionManager.executeAction(actionToggleTheme, "commandPalette");
|
||||||
|
},
|
||||||
|
};
|
26
packages/excalidraw/components/CommandPalette/types.ts
Normal file
26
packages/excalidraw/components/CommandPalette/types.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { ActionManager } from "../../actions/manager";
|
||||||
|
import type { Action } from "../../actions/types";
|
||||||
|
import type { 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;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user