Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77c4eb6db4 | |||
| 1ac2626e47 | |||
| 43d6a7e286 | |||
| 15b7d141c1 | |||
| 550e23a2ab | |||
| d4c6462ab1 | |||
| 530617be90 | |||
| 5211b003b8 | |||
| bbcca06b94 | |||
| f92f04c13c | |||
| 890ed9f31f | |||
| da2e507298 | |||
| f59b4f6af4 | |||
| afcde542f9 | |||
| 4689a6b300 | |||
| 0ae9b383d6 | |||
| f597bd3e01 | |||
| 4987cc53d0 | |||
| d917db438e | |||
| a33a400f01 | |||
| 8a162a4cb4 | |||
| c6a045d092 | |||
| cd50aa719f | |||
| 92bc08207c | |||
| 32df5502ae | |||
| bbdcd30a73 |
@@ -8,15 +8,15 @@
|
|||||||
import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
import { FONT_FAMILY } from "@excalidraw/excalidraw";
|
||||||
```
|
```
|
||||||
|
|
||||||
`FONT_FAMILY` contains all the font families used in `Excalidraw` as explained below
|
`FONT_FAMILY` contains all the font families used in `Excalidraw`. The default families are the following:
|
||||||
|
|
||||||
| Font Family | Description |
|
| Font Family | Description |
|
||||||
| ----------- | ---------------------- |
|
| ----------- | ---------------------- |
|
||||||
| `Virgil` | The `handwritten` font |
|
| `Excalifont` | The `Hand-drawn` font |
|
||||||
| `Helvetica` | The `Normal` Font |
|
| `Nunito` | The `Normal` Font |
|
||||||
| `Cascadia` | The `Code` Font |
|
| `Comic Shanns` | The `Code` Font |
|
||||||
|
|
||||||
Defaults to `FONT_FAMILY.Virgil` unless passed in `initialData.appState.currentItemFontFamily`.
|
Pre-selected family is `FONT_FAMILY.Excalifont`, unless it's overriden with `initialData.appState.currentItemFontFamily`.
|
||||||
|
|
||||||
### THEME
|
### THEME
|
||||||
|
|
||||||
|
|||||||
@@ -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`. |
|
| `storeAction` | [`StoreAction`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/store.ts#L40) | Parameter to control which updates should be captured by the `Store`. Captured updates are emmitted as increments and listened to by other components, such as `History` for undo / redo purposes. |
|
||||||
|
|
||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
@@ -105,6 +105,7 @@ function App() {
|
|||||||
appState: {
|
appState: {
|
||||||
viewBackgroundColor: "#edf2ff",
|
viewBackgroundColor: "#edf2ff",
|
||||||
},
|
},
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
excalidrawAPI.updateScene(sceneData);
|
excalidrawAPI.updateScene(sceneData);
|
||||||
};
|
};
|
||||||
@@ -121,6 +122,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### storeAction
|
||||||
|
|
||||||
|
You can use the `storeAction` to influence undo / redo behaviour.
|
||||||
|
|
||||||
|
> **NOTE**: Some updates are not observed by the store / history - i.e. updates to `collaborators` object or parts of `AppState` which are not observed (not `ObservedAppState`). Such updates will never make it to the undo / redo stacks, regardless of the passed `storeAction` value.
|
||||||
|
|
||||||
|
| | `storeAction` value | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| _Immediately undoable_ | `StoreAction.CAPTURE` | Use for updates which should be captured. Should be used for most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||||
|
| _Eventually undoable_ | `StoreAction.NONE` | Use for updates which should not be captured immediately - likely exceptions which are part of some async multi-step process. Otherwise, all such updates would end up being captured with the next `StoreAction.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_ | `StoreAction.UPDATE` | Use for updates which should never be recorded, such as remote updates or scene initialization. These updates will _never_ make it to the local undo / redo stacks. |
|
||||||
|
|
||||||
|
|
||||||
### updateLibrary
|
### updateLibrary
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ You can pass `null` / `undefined` if not applicable.
|
|||||||
restoreElements(
|
restoreElements(
|
||||||
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
elements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ImportedDataState["elements"]</a>,<br/>
|
||||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a>,<br/>
|
||||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||||
)
|
)
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
@@ -51,8 +51,9 @@ The extra optional parameter to configure restored elements. It has the followin
|
|||||||
|
|
||||||
| Prop | Type | Description|
|
| Prop | Type | Description|
|
||||||
| --- | --- | ------|
|
| --- | --- | ------|
|
||||||
| `refreshDimensions` | `boolean` | Indicates whether we should also `recalculate` text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
| `refreshDimensions` | `boolean` | Indicates whether we should also _recalculate_ text element dimensions. Since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration. |
|
||||||
| `repairBindings` |`boolean` | Indicates whether the `bindings` for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
| `repairBindings` |`boolean` | Indicates whether the _bindings_ for the elements should be repaired. This is to make sure there are no containers with non existent bound text element id and no bound text elements with non existent container id. |
|
||||||
|
| `normalizeIndices` |`boolean` | Indicates whether _fractional indices_ for the elements should be normalized. This is to prevent possible issues caused by using stale (too old, too long) indices. |
|
||||||
|
|
||||||
**_How to use_**
|
**_How to use_**
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ restore(
|
|||||||
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
data: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L34">ImportedDataState</a>,<br/>
|
||||||
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
localAppState: Partial<<a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a>> | null | undefined,<br/>
|
||||||
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
|
localElements: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/element/types.ts#L114">ExcalidrawElement[]</a> | null | undefined<br/>): <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L4">DataState</a><br/>
|
||||||
opts: { refreshDimensions?: boolean, repairBindings?: boolean }<br/>
|
opts: { refreshDimensions?: boolean, repairBindings?: boolean, normalizeIndices?: boolean }<br/>
|
||||||
|
|
||||||
)
|
)
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ yarn add react react-dom @excalidraw/excalidraw
|
|||||||
|
|
||||||
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
|
Excalidraw depends on assets such as localization files (if you opt to use them), fonts, and others.
|
||||||
|
|
||||||
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist), so you don't need to do anything on your end.
|
By default these assets are loaded from a public CDN [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/), so you don't need to do anything on your end.
|
||||||
|
|
||||||
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
|
However, if you want to host these files yourself, you can find them in your `node_modules/@excalidraw/excalidraw/dist` directory, in folders `excalidraw-assets` (for production) and `excalidraw-assets-dev` (for development).
|
||||||
|
|
||||||
@@ -34,6 +34,26 @@ Copy these folders to your static assets directory, and add a `window.EXCALIDRAW
|
|||||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or, if you serve your assets from the root of your CDN, you would do:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Vanilla
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = "https://my.cdn.com/assets/";
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
```
|
||||||
|
|
||||||
|
or, if you prefer the path to be dynamicly set based on the `location.origin`, you could do the following:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Next.js
|
||||||
|
<Script id="load-env-variables" strategy="beforeInteractive" >
|
||||||
|
{ `window["EXCALIDRAW_ASSET_PATH"] = location.origin;` } // or use just "/"!
|
||||||
|
</Script>
|
||||||
|
```
|
||||||
|
|
||||||
### Dimensions of Excalidraw
|
### Dimensions of Excalidraw
|
||||||
|
|
||||||
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
|
||||||
|
|||||||
+38
-27
@@ -14,10 +14,9 @@ import {
|
|||||||
} from "../packages/excalidraw/constants";
|
} from "../packages/excalidraw/constants";
|
||||||
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
||||||
import {
|
import {
|
||||||
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";
|
||||||
@@ -88,7 +87,6 @@ import {
|
|||||||
} from "./data/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,
|
||||||
@@ -108,6 +106,10 @@ import { OverwriteConfirmDialog } from "../packages/excalidraw/components/Overwr
|
|||||||
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 CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../packages/excalidraw/data/reconcile";
|
||||||
import {
|
import {
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
@@ -120,7 +122,9 @@ import {
|
|||||||
usersIcon,
|
usersIcon,
|
||||||
exportToPlus,
|
exportToPlus,
|
||||||
share,
|
share,
|
||||||
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
@@ -269,7 +273,7 @@ const initializeScene = async (opts: {
|
|||||||
},
|
},
|
||||||
elements: reconcileElements(
|
elements: reconcileElements(
|
||||||
scene?.elements || [],
|
scene?.elements || [],
|
||||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
|
||||||
excalidrawAPI.getAppState(),
|
excalidrawAPI.getAppState(),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -300,6 +304,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -431,7 +438,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,
|
commitToStore: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -563,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,
|
||||||
) => {
|
) => {
|
||||||
@@ -589,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()) {
|
||||||
@@ -795,7 +783,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
detectScroll={false}
|
detectScroll={false}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={theme}
|
theme={editorTheme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -817,6 +805,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}
|
||||||
@@ -1064,6 +1054,20 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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
|
...(isExcalidrawPlusSignedUser
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -1090,7 +1094,14 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CommandPalette.defaultItems.toggleTheme,
|
{
|
||||||
|
...CommandPalette.defaultItems.toggleTheme,
|
||||||
|
perform: () => {
|
||||||
|
setAppTheme(
|
||||||
|
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ImportedDataState } from "../../packages/excalidraw/data/types";
|
|||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
@@ -69,10 +70,6 @@ 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";
|
||||||
@@ -82,6 +79,11 @@ import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
|
|||||||
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
|
||||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||||
|
import {
|
||||||
|
ReconciledExcalidrawElement,
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} 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);
|
||||||
@@ -274,7 +276,7 @@ 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(),
|
||||||
@@ -282,10 +284,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
this.resetErrorIndicator();
|
this.resetErrorIndicator();
|
||||||
|
|
||||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
if (this.isCollaborating() && storedElements) {
|
||||||
this.handleRemoteSceneUpdate(
|
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
|
||||||
this.reconcileElements(savedData.reconciledElements),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
const errorMessage = /is longer than.*?bytes/.test(error.message)
|
||||||
@@ -356,7 +356,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -429,7 +428,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();
|
||||||
@@ -455,7 +454,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");
|
||||||
@@ -497,14 +500,12 @@ 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
@@ -538,10 +539,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,
|
||||||
@@ -552,7 +552,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: {
|
||||||
@@ -700,17 +700,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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -741,20 +739,12 @@ 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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -887,7 +877,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()
|
||||||
@@ -898,7 +888,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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
transform: rotate(10deg);
|
transform: rotate(10deg);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
transform: rotate(0eg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
75% {
|
75% {
|
||||||
transform: rotate(-10deg);
|
transform: rotate(-10deg);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import {
|
|||||||
isSyncableElement,
|
isSyncableElement,
|
||||||
SocketUpdateData,
|
SocketUpdateData,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
|
||||||
import { TCollabClass } from "./Collab";
|
import { TCollabClass } from "./Collab";
|
||||||
|
|
||||||
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
|
import { 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 {
|
||||||
OnUserFollowedPayload,
|
OnUserFollowedPayload,
|
||||||
@@ -16,9 +17,7 @@ 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";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
@@ -133,7 +132,7 @@ class Portal {
|
|||||||
|
|
||||||
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 +142,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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
arrowBarToLeftIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import { 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 />
|
<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={arrowBarToLeftIcon}
|
||||||
|
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 { arrowBarToLeftIcon } 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={arrowBarToLeftIcon}
|
||||||
>
|
>
|
||||||
Try Excalidraw Plus!
|
Sign up
|
||||||
</WelcomeScreen.Center.MenuItemLink>
|
</WelcomeScreen.Center.MenuItemLink>
|
||||||
)}
|
)}
|
||||||
</WelcomeScreen.Center.Menu>
|
</WelcomeScreen.Center.Menu>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
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 Portal from "../collab/Portal";
|
||||||
@@ -18,10 +19,13 @@ 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 { getSyncableElements, SyncableExcalidrawElement } from ".";
|
import { getSyncableElements, SyncableExcalidrawElement } from ".";
|
||||||
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
import { ResolutionType } from "../../packages/excalidraw/utility-types";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import {
|
||||||
|
RemoteExcalidrawElement,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -230,7 +234,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 +242,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 +305,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 (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { isInitializedImageElement } from "../../packages/excalidraw/element/typ
|
|||||||
import {
|
import {
|
||||||
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 {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { MakeBrand } from "../../packages/excalidraw/utility-types";
|
||||||
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
import { bytesToHexString } from "../../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
@@ -35,12 +37,11 @@ import {
|
|||||||
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,7 @@ 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,
|
commitToStore: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -197,8 +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 (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
|
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %>
|
|
||||||
<!-- 100% privacy friendly analytics -->
|
<!-- 100% privacy friendly analytics -->
|
||||||
<script>
|
<script>
|
||||||
// need to load this script dynamically bcs. of iframe embed tracking
|
// need to load this script dynamically bcs. of iframe embed tracking
|
||||||
@@ -231,6 +247,5 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<!-- end LEGACY GOOGLE ANALYTICS -->
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
<% } %>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
background-color: #ecfdf5;
|
background-color: #ecfdf5;
|
||||||
color: #064e3c;
|
color: #064e3c;
|
||||||
}
|
}
|
||||||
&.ExcalidrawPlus {
|
&.highlighted {
|
||||||
color: var(--color-promo);
|
color: var(--color-promo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,32 +216,23 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<g
|
<g>
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
d="M0 0h24v24H0z"
|
d="M0 0h24v24H0z"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="none"
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
<rect
|
<path
|
||||||
height="4"
|
d="M10 12l10 0"
|
||||||
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="M10 12l4 4"
|
||||||
/>
|
/>
|
||||||
<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="M10 12l4 -4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M4 4l0 16"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -249,7 +240,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 { newElementWith } from "../../packages/excalidraw";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
Object.defineProperty(window, "crypto", {
|
Object.defineProperty(window, "crypto", {
|
||||||
@@ -56,39 +63,188 @@ 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" }),
|
commitToStore: true,
|
||||||
API.createElement({
|
});
|
||||||
type: "rectangle",
|
|
||||||
id: "B",
|
updateSceneData({
|
||||||
isDeleted: true,
|
elements: syncInvalidIndices([
|
||||||
}),
|
rect1,
|
||||||
],
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
});
|
]),
|
||||||
await waitFor(() => {
|
commitToStore: true,
|
||||||
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]),
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }),
|
||||||
|
]),
|
||||||
|
commitToStore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
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]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 { 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 };
|
||||||
|
};
|
||||||
@@ -15,20 +15,32 @@ 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.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)
|
- 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)
|
- 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)
|
||||||
|
|
||||||
|
- Extended `window.EXCALIDRAW_ASSET_PATH` to accept array of paths `string[]` as a value, allowing to specify multiple base `URL` fallbacks. [#8286](https://github.com/excalidraw/excalidraw/pull/8286)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
|
- `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.
|
||||||
@@ -91,8 +103,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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ After installation you will see a folder `excalidraw-assets` and `excalidraw-ass
|
|||||||
|
|
||||||
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
Move the folder `excalidraw-assets` and `excalidraw-assets-dev` to the path where your assets are served.
|
||||||
|
|
||||||
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/`](https://unpkg.com/@excalidraw/excalidraw/dist)
|
By default it will try to load the files from [`https://unpkg.com/@excalidraw/excalidraw/dist/prod/`](https://unpkg.com/@excalidraw/excalidraw/dist/prod/)
|
||||||
|
|
||||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 { StoreAction } from "../store";
|
||||||
import { AppClassProperties, AppState, UIAppState } from "../types";
|
import { AppClassProperties, AppState, UIAppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -70,7 +71,7 @@ export const actionAlignTop = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -103,7 +104,7 @@ export const actionAlignBottom = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -136,7 +137,7 @@ export const actionAlignLeft = register({
|
|||||||
position: "start",
|
position: "start",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -169,7 +170,7 @@ export const actionAlignRight = register({
|
|||||||
position: "end",
|
position: "end",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -202,7 +203,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 }) => (
|
||||||
@@ -231,7 +232,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 }) => (
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import {
|
|||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
import { getFontString } from "../utils";
|
import { arrayToMap, getFontString } 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",
|
||||||
@@ -84,7 +86,7 @@ export const actionUnbindText = register({
|
|||||||
return {
|
return {
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -160,7 +162,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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -180,6 +182,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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,6 +202,8 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,6 +310,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
container,
|
container,
|
||||||
textElement,
|
textElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
containerIds[container.id] = true;
|
containerIds[container.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +321,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
...appState,
|
...appState,
|
||||||
selectedElementIds: containerIds,
|
selectedElementIds: containerIds,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ import {
|
|||||||
ZoomResetIcon,
|
ZoomResetIcon,
|
||||||
} from "../components/icons";
|
} 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 { ExcalidrawElement } from "../element/types";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -31,6 +37,7 @@ import {
|
|||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
import { SceneBounds } from "../element/bounds";
|
import { 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",
|
||||||
@@ -46,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 }) => {
|
||||||
@@ -102,7 +111,7 @@ export const actionClearCanvas = register({
|
|||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -127,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);
|
||||||
}}
|
}}
|
||||||
@@ -167,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);
|
||||||
}}
|
}}
|
||||||
@@ -207,7 +218,7 @@ export const actionResetZoom = register({
|
|||||||
),
|
),
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
@@ -282,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;
|
||||||
|
|
||||||
let appStateWidth = appState.width;
|
let appStateWidth = appState.width;
|
||||||
@@ -328,7 +339,7 @@ export const zoomToFitBounds = ({
|
|||||||
scrollY,
|
scrollY,
|
||||||
zoom: { value: newZoomValue },
|
zoom: { value: newZoomValue },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -432,7 +443,9 @@ export const actionZoomToFit = register({
|
|||||||
export const actionToggleTheme = register({
|
export const actionToggleTheme = register({
|
||||||
name: "toggleTheme",
|
name: "toggleTheme",
|
||||||
label: (_, appState) => {
|
label: (_, appState) => {
|
||||||
return appState.theme === "dark" ? "buttons.lightMode" : "buttons.darkMode";
|
return appState.theme === THEME.DARK
|
||||||
|
? "buttons.lightMode"
|
||||||
|
: "buttons.darkMode";
|
||||||
},
|
},
|
||||||
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
keywords: ["toggle", "dark", "light", "mode", "theme"],
|
||||||
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
|
||||||
@@ -445,7 +458,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,
|
||||||
@@ -483,7 +496,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,
|
||||||
@@ -522,7 +535,7 @@ export const actionToggleHandTool = register({
|
|||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
activeTool,
|
activeTool,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionCopy = register({
|
export const actionCopy = register({
|
||||||
name: "copy",
|
name: "copy",
|
||||||
@@ -31,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,
|
||||||
@@ -40,7 +41,7 @@ export const actionCopy = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// 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
|
||||||
@@ -66,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"),
|
||||||
@@ -75,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"),
|
||||||
@@ -88,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"),
|
||||||
@@ -97,7 +98,7 @@ export const actionPaste = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
// 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
|
||||||
@@ -124,7 +125,7 @@ export const actionCopyAsSvg = register({
|
|||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ export const actionCopyAsSvg = register({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -156,7 +157,7 @@ export const actionCopyAsSvg = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -174,7 +175,7 @@ export const actionCopyAsPng = register({
|
|||||||
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({
|
||||||
@@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -217,7 +218,7 @@ export const actionCopyAsPng = register({
|
|||||||
...appState,
|
...appState,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -252,7 +253,7 @@ export const copyText = register({
|
|||||||
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
throw new Error(t("errors.copyToSystemClipboardFailed"));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
|
|||||||
@@ -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[],
|
||||||
@@ -112,7 +113,7 @@ export const actionDeleteSelected = register({
|
|||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ export const actionDeleteSelected = register({
|
|||||||
: [0],
|
: [0],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let { elements: nextElements, appState: nextAppState } =
|
let { elements: nextElements, appState: nextAppState } =
|
||||||
@@ -164,10 +165,12 @@ 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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState, elements) =>
|
keyTest: (event, appState, elements) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { StoreAction } from "../store";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -58,7 +59,7 @@ export const distributeHorizontally = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "x",
|
axis: "x",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -89,7 +90,7 @@ export const distributeVertically = register({
|
|||||||
space: "between",
|
space: "between",
|
||||||
axis: "y",
|
axis: "y",
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ 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",
|
||||||
@@ -53,13 +55,13 @@ 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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
|
||||||
@@ -90,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(
|
||||||
@@ -101,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);
|
||||||
@@ -238,8 +242,10 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// step (3)
|
// step (3)
|
||||||
|
const finalElements = syncMovedIndices(
|
||||||
const finalElements = finalElementsReversed.reverse();
|
finalElementsReversed.reverse(),
|
||||||
|
arrayToMap(newElements),
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ export const actionToggleElementLock = register({
|
|||||||
? null
|
? null
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event, appState, elements, app) => {
|
keyTest: (event, appState, elements, app) => {
|
||||||
@@ -111,7 +112,7 @@ export const actionUnlockAllElements = register({
|
|||||||
lockedElements.map((el) => [el.id, true]),
|
lockedElements.map((el) => [el.id, true]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
label: "labels.elementLock.unlockAll",
|
label: "labels.elementLock.unlockAll",
|
||||||
|
|||||||
@@ -19,13 +19,17 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
|||||||
import { Theme } from "../element/types";
|
import { 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",
|
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, app }) => (
|
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
|
||||||
<ProjectName
|
<ProjectName
|
||||||
@@ -44,7 +48,7 @@ export const actionChangeExportScale = register({
|
|||||||
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 }) => {
|
||||||
@@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({
|
|||||||
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 }) => (
|
||||||
@@ -114,7 +118,7 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
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 }) => (
|
||||||
@@ -156,7 +160,7 @@ export const actionSaveToActiveFile = register({
|
|||||||
: await saveAsJSON(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,
|
||||||
@@ -178,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) =>
|
||||||
@@ -203,7 +207,7 @@ export const actionSaveFileToDisk = register({
|
|||||||
app.getName(),
|
app.getName(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
openDialog: null,
|
openDialog: null,
|
||||||
@@ -217,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) =>
|
||||||
@@ -256,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") {
|
||||||
@@ -267,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -281,7 +285,7 @@ export const actionExportWithDarkMode = register({
|
|||||||
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 }) => (
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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,
|
||||||
@@ -16,17 +15,15 @@ import {
|
|||||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||||
import { AppState } from "../types";
|
import { 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: "",
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (
|
perform: (elements, appState, _, app) => {
|
||||||
elements,
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
appState,
|
|
||||||
_,
|
|
||||||
{ interactiveCanvas, focusContainer, scene },
|
|
||||||
) => {
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
@@ -52,8 +49,9 @@ export const actionFinalize = register({
|
|||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,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,
|
||||||
);
|
);
|
||||||
@@ -131,13 +131,7 @@ export const actionFinalize = register({
|
|||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(
|
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||||
multiPointElement,
|
|
||||||
appState,
|
|
||||||
Scene.getScene(multiPointElement)!,
|
|
||||||
{ x, y },
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +190,8 @@ export const actionFinalize = register({
|
|||||||
: 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) =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { resizeMultipleElements } from "../element/resizeElements";
|
import { resizeMultipleElements } from "../element/resizeElements";
|
||||||
import { AppState } from "../types";
|
import { 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";
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -32,12 +33,13 @@ 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,
|
||||||
@@ -56,12 +58,13 @@ 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) =>
|
||||||
@@ -73,6 +76,7 @@ const flipSelectedElements = (
|
|||||||
elementsMap: 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),
|
||||||
@@ -89,6 +93,7 @@ const flipSelectedElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
|
app,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
@@ -104,6 +109,7 @@ const flipElements = (
|
|||||||
elementsMap: 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);
|
||||||
|
|
||||||
@@ -118,7 +124,7 @@ const flipElements = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
|
? bindOrUnbindSelectedElements(selectedElements, app)
|
||||||
: unbindLinearElements(selectedElements, elementsMap);
|
: unbindLinearElements(selectedElements, elementsMap);
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { frameToolIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const isSingleFrameSelected = (
|
const isSingleFrameSelected = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -44,14 +45,14 @@ 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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
@@ -75,14 +76,14 @@ 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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
@@ -104,7 +105,7 @@ export const actionupdateFrameRendering = register({
|
|||||||
enabled: !appState.frameRendering.enabled,
|
enabled: !appState.frameRendering.enabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||||
@@ -134,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
|
|||||||
type: "frame",
|
type: "frame",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ 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 {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
import {
|
import {
|
||||||
@@ -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) {
|
||||||
@@ -71,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);
|
||||||
@@ -91,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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,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: {
|
||||||
@@ -155,8 +162,8 @@ export const actionGroup = register({
|
|||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
elements: nextElements,
|
elements: reorderedElements,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
predicate: (elements, appState, _, app) =>
|
predicate: (elements, appState, _, app) =>
|
||||||
@@ -186,7 +193,7 @@ export const actionUngroup = register({
|
|||||||
const elementsMap = arrayToMap(elements);
|
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];
|
||||||
@@ -259,7 +266,7 @@ 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) =>
|
||||||
|
|||||||
@@ -2,110 +2,117 @@ import { 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 { History, HistoryChangedEvent } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
import { 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 { SceneElementsMap } from "../element/types";
|
||||||
|
import { IStore, 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: IStore) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
label: "buttons.undo",
|
label: "buttons.undo",
|
||||||
icon: UndoIcon,
|
icon: UndoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
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",
|
label: "buttons.redo",
|
||||||
icon: RedoIcon,
|
icon: RedoIcon,
|
||||||
trackEvent: { category: "history" },
|
trackEvent: { category: "history" },
|
||||||
viewMode: false,
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
|
|||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { ExcalidrawLinearElement } from "../element/types";
|
import { ExcalidrawLinearElement } from "../element/types";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
@@ -41,7 +42,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
...appState,
|
...appState,
|
||||||
editingLinearElement,
|
editingLinearElement,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export const actionLink = register({
|
|||||||
showHyperlinkPopup: "editor",
|
showHyperlinkPopup: "editor",
|
||||||
openMenu: null,
|
openMenu: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
trackEvent: { category: "hyperlink", action: "click" },
|
trackEvent: { category: "hyperlink", action: "click" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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",
|
||||||
@@ -14,7 +15,7 @@ export const actionToggleCanvasMenu = register({
|
|||||||
...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
|
||||||
@@ -36,7 +37,7 @@ export const actionToggleEditMenu = register({
|
|||||||
...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
|
||||||
@@ -73,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,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
microphoneMutedIcon,
|
microphoneMutedIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -27,7 +28,7 @@ export const actionGoToCollaborator = register({
|
|||||||
...appState,
|
...appState,
|
||||||
userToFollow: null,
|
userToFollow: null,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ 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 }) => {
|
||||||
|
|||||||
@@ -96,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;
|
||||||
|
|
||||||
@@ -231,7 +232,7 @@ const changeFontSize = (
|
|||||||
? [...newFontSizes][0]
|
? [...newFontSizes][0]
|
||||||
: fallbackValue ?? appState.currentItemFontSize,
|
: fallbackValue ?? appState.currentItemFontSize,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -261,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 }) => (
|
||||||
@@ -305,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 }) => (
|
||||||
@@ -349,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 }) => {
|
||||||
@@ -422,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 }) => (
|
||||||
@@ -477,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 }) => (
|
||||||
@@ -528,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 }) => (
|
||||||
@@ -583,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 }) => (
|
||||||
@@ -758,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 }) => {
|
||||||
@@ -859,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 }) => {
|
||||||
@@ -949,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 }) => {
|
||||||
@@ -1030,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 }) => {
|
||||||
@@ -1182,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 }) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { selectAllIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@@ -50,7 +51,7 @@ export const actionSelectAll = register({
|
|||||||
? new LinearElementEditor(elements[0])
|
? new LinearElementEditor(elements[0])
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { ExcalidrawTextElement } from "../element/types";
|
import { ExcalidrawTextElement } from "../element/types";
|
||||||
import { paintIcon } from "../components/icons";
|
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 = "{}";
|
||||||
@@ -54,7 +55,7 @@ export const actionCopyStyles = register({
|
|||||||
...appState,
|
...appState,
|
||||||
toast: { message: t("toast.copyStyles") },
|
toast: { message: t("toast.copyStyles") },
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -71,7 +72,7 @@ export const actionPasteStyles = register({
|
|||||||
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, {
|
||||||
@@ -160,7 +161,7 @@ export const actionPasteStyles = register({
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ 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 { AppState } from "../types";
|
||||||
|
import { gridIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleGridMode = register({
|
export const actionToggleGridMode = register({
|
||||||
name: "gridMode",
|
name: "gridMode",
|
||||||
label: "labels.showGrid",
|
icon: gridIcon,
|
||||||
|
keywords: ["snap"],
|
||||||
|
label: "labels.toggleGrid",
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: {
|
trackEvent: {
|
||||||
category: "canvas",
|
category: "canvas",
|
||||||
@@ -18,7 +22,7 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { magnetIcon } from "../components/icons";
|
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({
|
||||||
@@ -18,7 +19,7 @@ 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,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { abacusIcon } from "../components/icons";
|
import { abacusIcon } from "../components/icons";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export const actionToggleStats = register({
|
export const actionToggleStats = register({
|
||||||
name: "stats",
|
name: "stats",
|
||||||
@@ -15,7 +16,7 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { eyeIcon } from "../components/icons";
|
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({
|
||||||
@@ -18,7 +19,7 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { coffeeIcon } from "../components/icons";
|
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({
|
||||||
@@ -18,7 +19,7 @@ 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,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
moveOneLeft,
|
moveOneLeft,
|
||||||
moveOneRight,
|
moveOneRight,
|
||||||
@@ -16,6 +15,7 @@ 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",
|
||||||
@@ -26,7 +26,7 @@ export const actionSendBackward = register({
|
|||||||
return {
|
return {
|
||||||
elements: moveOneLeft(elements, appState),
|
elements: moveOneLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
@@ -55,7 +55,7 @@ export const actionBringForward = register({
|
|||||||
return {
|
return {
|
||||||
elements: moveOneRight(elements, appState),
|
elements: moveOneRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyPriority: 40,
|
keyPriority: 40,
|
||||||
@@ -84,7 +84,7 @@ export const actionSendToBack = register({
|
|||||||
return {
|
return {
|
||||||
elements: moveAllLeft(elements, appState),
|
elements: moveAllLeft(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
@@ -121,7 +121,7 @@ export const actionBringToFront = register({
|
|||||||
return {
|
return {
|
||||||
elements: moveAllRight(elements, appState),
|
elements: moveAllRight(elements, appState),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
PanelComponentProps,
|
PanelComponentProps,
|
||||||
ActionSource,
|
ActionSource,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
|
||||||
import { AppClassProperties, AppState } from "../types";
|
import { AppClassProperties, AppState } from "../types";
|
||||||
import { trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { isPromiseLike } from "../utils";
|
import { isPromiseLike } from "../utils";
|
||||||
@@ -46,13 +46,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,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
|
||||||
import {
|
import {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
AppState,
|
AppState,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
UIAppState,
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
export type ActionSource =
|
export type ActionSource =
|
||||||
| "ui"
|
| "ui"
|
||||||
@@ -25,14 +26,13 @@ export type ActionResult =
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
files?: BinaryFiles | null;
|
files?: BinaryFiles | null;
|
||||||
commitToHistory: boolean;
|
storeAction: keyof typeof StoreAction;
|
||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,8 @@ import { jotaiStore } from "../../jotai";
|
|||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { CommandPaletteItem } from "./types";
|
import { CommandPaletteItem } from "./types";
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
|
|
||||||
@@ -130,12 +132,20 @@ export const CommandPalette = Object.assign(
|
|||||||
if (isCommandPaletteToggleShortcut(event)) {
|
if (isCommandPaletteToggleShortcut(event)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setAppState((appState) => ({
|
setAppState((appState) => {
|
||||||
openDialog:
|
const nextState =
|
||||||
appState.openDialog?.name === "commandPalette"
|
appState.openDialog?.name === "commandPalette"
|
||||||
? null
|
? null
|
||||||
: { name: "commandPalette" },
|
: ({ name: "commandPalette" } as const);
|
||||||
}));
|
|
||||||
|
if (nextState) {
|
||||||
|
trackEvent("command_palette", "open", "shortcut");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openDialog: nextState,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
|
||||||
@@ -174,10 +184,20 @@ function CommandPaletteInner({
|
|||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const stableDeps = useStable({
|
||||||
|
uiAppState,
|
||||||
|
customCommandPaletteItems,
|
||||||
|
appProps,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uiAppState || !app.scene || !actionManager) {
|
// these props change often and we don't want them to re-run the effect
|
||||||
return;
|
// 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) => {
|
const getActionLabel = (action: Action) => {
|
||||||
let label = "";
|
let label = "";
|
||||||
if (action.label) {
|
if (action.label) {
|
||||||
@@ -289,6 +309,7 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.zoomToFit,
|
actionManager.actions.zoomToFit,
|
||||||
actionManager.actions.zenMode,
|
actionManager.actions.zenMode,
|
||||||
actionManager.actions.viewMode,
|
actionManager.actions.viewMode,
|
||||||
|
actionManager.actions.gridMode,
|
||||||
actionManager.actions.objectsSnapMode,
|
actionManager.actions.objectsSnapMode,
|
||||||
actionManager.actions.toggleShortcuts,
|
actionManager.actions.toggleShortcuts,
|
||||||
actionManager.actions.selectAll,
|
actionManager.actions.selectAll,
|
||||||
@@ -533,15 +554,13 @@ function CommandPaletteInner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
stableDeps,
|
||||||
app,
|
app,
|
||||||
appProps,
|
|
||||||
uiAppState,
|
|
||||||
actionManager,
|
actionManager,
|
||||||
setAllCommands,
|
setAllCommands,
|
||||||
lastUsed?.label,
|
lastUsed?.label,
|
||||||
setLastUsed,
|
setLastUsed,
|
||||||
setAppState,
|
setAppState,
|
||||||
customCommandPaletteItems,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [commandSearch, setCommandSearch] = useState("");
|
const [commandSearch, setCommandSearch] = useState("");
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
|
|||||||
}) => {
|
}) => {
|
||||||
const title =
|
const title =
|
||||||
props.title ||
|
props.title ||
|
||||||
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
|
(props.value === THEME.DARK
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KEYS } from "../keys";
|
|||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import "./HelpDialog.scss";
|
import "./HelpDialog.scss";
|
||||||
import { ExternalLinkIcon } from "./icons";
|
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||||
@@ -17,8 +17,8 @@ const Header = () => (
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{t("helpDialog.documentation")}
|
|
||||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||||
|
{t("helpDialog.documentation")}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
@@ -26,8 +26,8 @@ const Header = () => (
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{t("helpDialog.blog")}
|
|
||||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
||||||
|
{t("helpDialog.blog")}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
className="HelpDialog__btn"
|
className="HelpDialog__btn"
|
||||||
@@ -35,8 +35,17 @@ const Header = () => (
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
<div className="HelpDialog__link-icon">{GithubIcon}</div>
|
||||||
{t("helpDialog.github")}
|
{t("helpDialog.github")}
|
||||||
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
|
</a>
|
||||||
|
<a
|
||||||
|
className="HelpDialog__btn"
|
||||||
|
href="https://youtube.com/@excalidraw"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div className="HelpDialog__link-icon">{youtubeIcon}</div>
|
||||||
|
YouTube
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -264,7 +273,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+S")]}
|
shortcuts={[getShortcutKey("Alt+S")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.showGrid")}
|
label={t("labels.toggleGrid")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import "./RadioGroup.scss";
|
|||||||
|
|
||||||
export type RadioGroupChoice<T> = {
|
export type RadioGroupChoice<T> = {
|
||||||
value: T;
|
value: T;
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RadioGroupProps<T> = {
|
export type RadioGroupProps<T> = {
|
||||||
@@ -26,13 +27,15 @@ export const RadioGroup = function <T>({
|
|||||||
className={clsx("RadioGroup__choice", {
|
className={clsx("RadioGroup__choice", {
|
||||||
active: choice.value === value,
|
active: choice.value === value,
|
||||||
})}
|
})}
|
||||||
key={choice.label}
|
key={String(choice.value)}
|
||||||
|
title={choice.ariaLabel}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
type="radio"
|
type="radio"
|
||||||
checked={choice.value === value}
|
checked={choice.value === value}
|
||||||
onChange={() => onChange(choice.value)}
|
onChange={() => onChange(choice.value)}
|
||||||
|
aria-label={choice.ariaLabel}
|
||||||
/>
|
/>
|
||||||
{choice.label}
|
{choice.label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { TTDDialogInput } from "./TTDDialogInput";
|
|||||||
import { TTDDialogOutput } from "./TTDDialogOutput";
|
import { TTDDialogOutput } from "./TTDDialogOutput";
|
||||||
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
|
||||||
import { EDITOR_LS_KEYS } from "../../constants";
|
import { EDITOR_LS_KEYS } from "../../constants";
|
||||||
import { debounce } from "../../utils";
|
import { debounce, isDevEnv } from "../../utils";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
|
|
||||||
const MERMAID_EXAMPLE =
|
const MERMAID_EXAMPLE =
|
||||||
@@ -54,7 +54,11 @@ const MermaidToExcalidraw = ({
|
|||||||
mermaidToExcalidrawLib,
|
mermaidToExcalidrawLib,
|
||||||
setError,
|
setError,
|
||||||
mermaidDefinition: deferredText,
|
mermaidDefinition: deferredText,
|
||||||
}).catch(() => {});
|
}).catch((err) => {
|
||||||
|
if (isDevEnv()) {
|
||||||
|
console.error("Failed to parse mermaid definition", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
debouncedSaveMermaidDefinition(deferredText);
|
debouncedSaveMermaidDefinition(deferredText);
|
||||||
}, [deferredText, mermaidToExcalidrawLib]);
|
}, [deferredText, mermaidToExcalidrawLib]);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
disabled={isLoading || props.isLoading}
|
disabled={isLoading || props.isLoading || !!props.disabled}
|
||||||
>
|
>
|
||||||
{(props.icon || props.label) && (
|
{(props.icon || props.label) && (
|
||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled={!!props.disabled}
|
||||||
|
>
|
||||||
{props.icon || props.label}
|
{props.icon || props.label}
|
||||||
{props.keyBindingLabel && (
|
{props.keyBindingLabel && (
|
||||||
<span className="ToolIcon__keybinding">
|
<span className="ToolIcon__keybinding">
|
||||||
|
|||||||
@@ -77,8 +77,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon_type_button,
|
.ToolIcon_type_button,
|
||||||
.Modal .ToolIcon_type_button,
|
.Modal .ToolIcon_type_button {
|
||||||
.ToolIcon_type_button {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -101,6 +100,22 @@
|
|||||||
background-color: var(--button-gray-3);
|
background-color: var(--button-gray-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:focus-visible,
|
||||||
|
&:hover {
|
||||||
|
background-color: initial;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--show {
|
&--show {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,12 @@
|
|||||||
&__shortcut {
|
&__shortcut {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
|
&--orphaned {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -94,6 +100,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu-item-bare {
|
||||||
|
align-items: center;
|
||||||
|
height: 2rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
@media screen and (min-width: 1921px) {
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-menu-item-custom {
|
.dropdown-menu-item-custom {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useDevice } from "../App";
|
||||||
|
import { RadioGroup } from "../RadioGroup";
|
||||||
|
|
||||||
|
type Props<T> = {
|
||||||
|
value: T;
|
||||||
|
shortcut?: string;
|
||||||
|
choices: {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownMenuItemContentRadio = <T,>({
|
||||||
|
value,
|
||||||
|
shortcut,
|
||||||
|
onChange,
|
||||||
|
choices,
|
||||||
|
children,
|
||||||
|
name,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const device = useDevice();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
|
||||||
|
<label className="dropdown-menu-item__text" htmlFor={name}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
<RadioGroup
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
choices={choices}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{shortcut && !device.editor.isMobile && (
|
||||||
|
<div className="dropdown-menu-item__shortcut dropdown-menu-item__shortcut--orphaned">
|
||||||
|
{shortcut}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DropdownMenuItemContentRadio.displayName = "DropdownMenuItemContentRadio";
|
||||||
|
|
||||||
|
export default DropdownMenuItemContentRadio;
|
||||||
@@ -26,9 +26,9 @@ import clsx from "clsx";
|
|||||||
import { KEYS } from "../../keys";
|
import { KEYS } from "../../keys";
|
||||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
|
||||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
|
import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
|
||||||
import { getSelectedElements } from "../../scene";
|
import { getSelectedElements } from "../../scene";
|
||||||
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
import { hitElementBoundingBox } from "../../element/collision";
|
||||||
import { isLocalLink, normalizeLink } from "../../data/url";
|
import { isLocalLink, normalizeLink } from "../../data/url";
|
||||||
|
|
||||||
import "./Hyperlink.scss";
|
import "./Hyperlink.scss";
|
||||||
@@ -425,15 +425,7 @@ const shouldHideLinkPopup = (
|
|||||||
|
|
||||||
const threshold = 15 / appState.zoom.value;
|
const threshold = 15 / appState.zoom.value;
|
||||||
// hitbox to prevent hiding when hovered in element bounding box
|
// hitbox to prevent hiding when hovered in element bounding box
|
||||||
if (
|
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
|
||||||
isPointHittingElementBoundingBox(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
[sceneX, sceneY],
|
|
||||||
threshold,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MIME_TYPES } from "../../constants";
|
import { MIME_TYPES } from "../../constants";
|
||||||
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
|
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
import { isPointHittingElementBoundingBox } from "../../element/collision";
|
import { hitElementBoundingBox } from "../../element/collision";
|
||||||
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
|
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
import { rotate } from "../../math";
|
||||||
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
||||||
@@ -75,17 +75,10 @@ export const isPointHittingLink = (
|
|||||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const threshold = 4 / appState.zoom.value;
|
|
||||||
if (
|
if (
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
appState.viewModeEnabled &&
|
appState.viewModeEnabled &&
|
||||||
isPointHittingElementBoundingBox(
|
hitElementBoundingBox(x, y, element, elementsMap)
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
[x, y],
|
|
||||||
threshold,
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,15 +433,10 @@ export const MoonIcon = createIcon(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const SunIcon = createIcon(
|
export const SunIcon = createIcon(
|
||||||
<g
|
<g stroke="currentColor" strokeLinejoin="round">
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.25"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM10 4.167V2.5M14.167 5.833l1.166-1.166M15.833 10H17.5M14.167 14.167l1.166 1.166M10 15.833V17.5M5.833 14.167l-1.166 1.166M5 10H3.333M5.833 5.833 4.667 4.667" />
|
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM10 4.167V2.5M14.167 5.833l1.166-1.166M15.833 10H17.5M14.167 14.167l1.166 1.166M10 15.833V17.5M5.833 14.167l-1.166 1.166M5 10H3.333M5.833 5.833 4.667 4.667" />
|
||||||
</g>,
|
</g>,
|
||||||
modifiedTablerIconProps,
|
{ ...modifiedTablerIconProps, strokeWidth: 1.5 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HamburgerMenuIcon = createIcon(
|
export const HamburgerMenuIcon = createIcon(
|
||||||
@@ -2092,3 +2087,45 @@ export const coffeeIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const DeviceDesktopIcon = createIcon(
|
||||||
|
<g stroke="currentColor">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 5a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-10zM7 20h10M9 16v4M15 16v4" />
|
||||||
|
</g>,
|
||||||
|
{ ...tablerIconProps, strokeWidth: 1.5 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// arrow-bar-to-left
|
||||||
|
export const arrowBarToLeftIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M10 12l10 0" />
|
||||||
|
<path d="M10 12l4 4" />
|
||||||
|
<path d="M10 12l4 -4" />
|
||||||
|
<path d="M4 4l0 16" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const youtubeIcon = createIcon(
|
||||||
|
<g>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M2 8a4 4 0 0 1 4 -4h12a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4v-8z" />
|
||||||
|
<path d="M10 9l5 3l-5 3z" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const gridIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 6h18" />
|
||||||
|
<path d="M3 12h18" />
|
||||||
|
<path d="M3 18h18" />
|
||||||
|
<path d="M6 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M18 3v18" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../App";
|
} from "../App";
|
||||||
import {
|
import {
|
||||||
boltIcon,
|
boltIcon,
|
||||||
|
DeviceDesktopIcon,
|
||||||
ExportIcon,
|
ExportIcon,
|
||||||
ExportImageIcon,
|
ExportImageIcon,
|
||||||
HelpIcon,
|
HelpIcon,
|
||||||
@@ -35,6 +36,10 @@ import { jotaiScope } from "../../jotai";
|
|||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
|
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||||
|
import { THEME } from "../../constants";
|
||||||
|
import type { Theme } from "../../element/types";
|
||||||
|
import { trackEvent } from "../../analytics";
|
||||||
|
|
||||||
import "./DefaultItems.scss";
|
import "./DefaultItems.scss";
|
||||||
|
|
||||||
@@ -118,7 +123,7 @@ export const SaveAsImage = () => {
|
|||||||
};
|
};
|
||||||
SaveAsImage.displayName = "SaveAsImage";
|
SaveAsImage.displayName = "SaveAsImage";
|
||||||
|
|
||||||
export const CommandPalette = () => {
|
export const CommandPalette = (opts?: { className?: string }) => {
|
||||||
const setAppState = useExcalidrawSetAppState();
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -126,9 +131,13 @@ export const CommandPalette = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
icon={boltIcon}
|
icon={boltIcon}
|
||||||
data-testid="command-palette-button"
|
data-testid="command-palette-button"
|
||||||
onSelect={() => setAppState({ openDialog: { name: "commandPalette" } })}
|
onSelect={() => {
|
||||||
|
trackEvent("command_palette", "open", "menu");
|
||||||
|
setAppState({ openDialog: { name: "commandPalette" } });
|
||||||
|
}}
|
||||||
shortcut={getShortcutFromShortcutName("commandPalette")}
|
shortcut={getShortcutFromShortcutName("commandPalette")}
|
||||||
aria-label={t("commandPalette.title")}
|
aria-label={t("commandPalette.title")}
|
||||||
|
className={opts?.className}
|
||||||
>
|
>
|
||||||
{t("commandPalette.title")}
|
{t("commandPalette.title")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -181,32 +190,80 @@ export const ClearCanvas = () => {
|
|||||||
};
|
};
|
||||||
ClearCanvas.displayName = "ClearCanvas";
|
ClearCanvas.displayName = "ClearCanvas";
|
||||||
|
|
||||||
export const ToggleTheme = () => {
|
export const ToggleTheme = (
|
||||||
|
props:
|
||||||
|
| {
|
||||||
|
allowSystemTheme: true;
|
||||||
|
theme: Theme | "system";
|
||||||
|
onSelect: (theme: Theme | "system") => void;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
allowSystemTheme?: false;
|
||||||
|
onSelect?: (theme: Theme) => void;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
const shortcut = getShortcutFromShortcutName("toggleTheme");
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props?.allowSystemTheme) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItemContentRadio
|
||||||
|
name="theme"
|
||||||
|
value={props.theme}
|
||||||
|
onChange={(value: Theme | "system") => props.onSelect(value)}
|
||||||
|
choices={[
|
||||||
|
{
|
||||||
|
value: THEME.LIGHT,
|
||||||
|
label: SunIcon,
|
||||||
|
ariaLabel: `${t("buttons.lightMode")} - ${shortcut}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: THEME.DARK,
|
||||||
|
label: MoonIcon,
|
||||||
|
ariaLabel: `${t("buttons.darkMode")} - ${shortcut}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "system",
|
||||||
|
label: DeviceDesktopIcon,
|
||||||
|
ariaLabel: t("buttons.systemMode"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("labels.theme")}
|
||||||
|
</DropdownMenuItemContentRadio>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
// do not close the menu when changing theme
|
// do not close the menu when changing theme
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return actionManager.executeAction(actionToggleTheme);
|
|
||||||
|
if (props?.onSelect) {
|
||||||
|
props.onSelect(
|
||||||
|
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return actionManager.executeAction(actionToggleTheme);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
|
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
shortcut={getShortcutFromShortcutName("toggleTheme")}
|
shortcut={shortcut}
|
||||||
aria-label={
|
aria-label={
|
||||||
appState.theme === "dark"
|
appState.theme === THEME.DARK
|
||||||
? t("buttons.lightMode")
|
? t("buttons.lightMode")
|
||||||
: t("buttons.darkMode")
|
: t("buttons.darkMode")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{appState.theme === "dark"
|
{appState.theme === THEME.DARK
|
||||||
? t("buttons.lightMode")
|
? t("buttons.lightMode")
|
||||||
: t("buttons.darkMode")}
|
: t("buttons.darkMode")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const VERSION_TIMEOUT = 30000;
|
|||||||
export const SCROLL_TIMEOUT = 100;
|
export const SCROLL_TIMEOUT = 100;
|
||||||
export const ZOOM_STEP = 0.1;
|
export const ZOOM_STEP = 0.1;
|
||||||
export const MIN_ZOOM = 0.1;
|
export const MIN_ZOOM = 0.1;
|
||||||
|
export const MAX_ZOOM = 30.0;
|
||||||
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
export const HYPERLINK_TOOLTIP_DELAY = 300;
|
||||||
|
|
||||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
@@ -316,10 +317,6 @@ export const ROUNDNESS = {
|
|||||||
ADAPTIVE_RADIUS: 3,
|
ADAPTIVE_RADIUS: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
|
||||||
* collaboration */
|
|
||||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
|
||||||
|
|
||||||
export const ROUGHNESS = {
|
export const ROUGHNESS = {
|
||||||
architect: 0,
|
architect: 0,
|
||||||
artist: 1,
|
artist: 1,
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
--color-gray-90: #1e1e1e;
|
--color-gray-90: #1e1e1e;
|
||||||
--color-gray-100: #121212;
|
--color-gray-100: #121212;
|
||||||
|
|
||||||
|
--color-disabled: var(--color-gray-40);
|
||||||
|
|
||||||
--color-warning: #fceeca;
|
--color-warning: #fceeca;
|
||||||
--color-warning-dark: #f5c354;
|
--color-warning-dark: #f5c354;
|
||||||
--color-warning-darker: #f3ab2c;
|
--color-warning-darker: #f3ab2c;
|
||||||
@@ -208,6 +210,8 @@
|
|||||||
--color-primary-light-darker: #43415e;
|
--color-primary-light-darker: #43415e;
|
||||||
--color-primary-hover: #bbb8ff;
|
--color-primary-hover: #bbb8ff;
|
||||||
|
|
||||||
|
--color-disabled: var(--color-gray-70);
|
||||||
|
|
||||||
--color-text-warning: var(--color-gray-80);
|
--color-text-warning: var(--color-gray-80);
|
||||||
|
|
||||||
--color-danger: #ffa8a5;
|
--color-danger: #ffa8a5;
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
color: var(--color-on-primary-container);
|
color: var(--color-on-primary-container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[aria-disabled="true"] {
|
||||||
|
background: initial;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--color-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { THEME } from "../constants";
|
||||||
import { Theme } from "../element/types";
|
import { Theme } from "../element/types";
|
||||||
import { DataURL } from "../types";
|
import { DataURL } from "../types";
|
||||||
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||||
@@ -39,7 +40,7 @@ export async function diagramToHTML({
|
|||||||
image,
|
image,
|
||||||
apiKey,
|
apiKey,
|
||||||
text,
|
text,
|
||||||
theme = "light",
|
theme = THEME.LIGHT,
|
||||||
}: {
|
}: {
|
||||||
image: DataURL;
|
image: DataURL;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { OrderedExcalidrawElement } from "../element/types";
|
||||||
|
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { MakeBrand } from "../utility-types";
|
||||||
|
import { arrayToMap } from "../utils";
|
||||||
|
|
||||||
|
export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||||
|
MakeBrand<"ReconciledElement">;
|
||||||
|
|
||||||
|
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||||
|
MakeBrand<"RemoteExcalidrawElement">;
|
||||||
|
|
||||||
|
const shouldDiscardRemoteElement = (
|
||||||
|
localAppState: AppState,
|
||||||
|
local: OrderedExcalidrawElement | undefined,
|
||||||
|
remote: RemoteExcalidrawElement,
|
||||||
|
): boolean => {
|
||||||
|
if (
|
||||||
|
local &&
|
||||||
|
// local element is being edited
|
||||||
|
(local.id === localAppState.editingElement?.id ||
|
||||||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
|
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array
|
||||||
|
// 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 OrderedExcalidrawElement[],
|
||||||
|
remoteElements: readonly RemoteExcalidrawElement[],
|
||||||
|
localAppState: AppState,
|
||||||
|
): ReconciledExcalidrawElement[] => {
|
||||||
|
const localElementsMap = arrayToMap(localElements);
|
||||||
|
const reconciledElements: OrderedExcalidrawElement[] = [];
|
||||||
|
const added = new Set<string>();
|
||||||
|
|
||||||
|
// process remote elements
|
||||||
|
for (const remoteElement of remoteElements) {
|
||||||
|
if (!added.has(remoteElement.id)) {
|
||||||
|
const localElement = localElementsMap.get(remoteElement.id);
|
||||||
|
const discardRemoteElement = shouldDiscardRemoteElement(
|
||||||
|
localAppState,
|
||||||
|
localElement,
|
||||||
|
remoteElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localElement && discardRemoteElement) {
|
||||||
|
reconciledElements.push(localElement);
|
||||||
|
added.add(localElement.id);
|
||||||
|
} else {
|
||||||
|
reconciledElements.push(remoteElement);
|
||||||
|
added.add(remoteElement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// process remaining local elements
|
||||||
|
for (const localElement of localElements) {
|
||||||
|
if (!added.has(localElement.id)) {
|
||||||
|
reconciledElements.push(localElement);
|
||||||
|
added.add(localElement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedElements = orderByFractionalIndex(reconciledElements);
|
||||||
|
|
||||||
|
// de-duplicate indices
|
||||||
|
syncInvalidIndices(orderedElements);
|
||||||
|
|
||||||
|
return orderedElements as ReconciledExcalidrawElement[];
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
PRECEDING_ELEMENT_KEY,
|
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
getDefaultLineHeight,
|
getDefaultLineHeight,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { normalizeLink } from "./url";
|
import { normalizeLink } from "./url";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -73,7 +74,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type RestoredDataState = {
|
export type RestoredDataState = {
|
||||||
elements: ExcalidrawElement[];
|
elements: OrderedExcalidrawElement[];
|
||||||
appState: RestoredAppState;
|
appState: RestoredAppState;
|
||||||
files: BinaryFiles;
|
files: BinaryFiles;
|
||||||
};
|
};
|
||||||
@@ -101,8 +102,6 @@ const restoreElementWithProperties = <
|
|||||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
strokeSharpness?: StrokeRoundness;
|
strokeSharpness?: StrokeRoundness;
|
||||||
/** metadata that may be present in elements during collaboration */
|
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
},
|
},
|
||||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||||
>(
|
>(
|
||||||
@@ -115,14 +114,13 @@ const restoreElementWithProperties = <
|
|||||||
> &
|
> &
|
||||||
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
|
||||||
): T => {
|
): T => {
|
||||||
const base: Pick<T, keyof ExcalidrawElement> & {
|
const base: Pick<T, keyof ExcalidrawElement> = {
|
||||||
[PRECEDING_ELEMENT_KEY]?: string;
|
|
||||||
} = {
|
|
||||||
type: extra.type || element.type,
|
type: extra.type || element.type,
|
||||||
// all elements must have version > 0 so getSceneVersion() will pick up
|
// all elements must have version > 0 so getSceneVersion() will pick up
|
||||||
// newly added elements
|
// newly added elements
|
||||||
version: element.version || 1,
|
version: element.version || 1,
|
||||||
versionNonce: element.versionNonce ?? 0,
|
versionNonce: element.versionNonce ?? 0,
|
||||||
|
index: element.index ?? null,
|
||||||
isDeleted: element.isDeleted ?? false,
|
isDeleted: element.isDeleted ?? false,
|
||||||
id: element.id || randomId(),
|
id: element.id || randomId(),
|
||||||
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||||
@@ -166,10 +164,6 @@ const restoreElementWithProperties = <
|
|||||||
"customData" in extra ? extra.customData : element.customData;
|
"customData" in extra ? extra.customData : element.customData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PRECEDING_ELEMENT_KEY in element) {
|
|
||||||
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
...getNormalizedDimensions(base),
|
...getNormalizedDimensions(base),
|
||||||
@@ -300,7 +294,7 @@ const restoreElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repairs contaienr element's boundElements array by removing duplicates and
|
* Repairs container element's boundElements array by removing duplicates and
|
||||||
* fixing containerId of bound elements if not present. Also removes any
|
* fixing containerId of bound elements if not present. Also removes any
|
||||||
* bound elements that do not exist in the elements array.
|
* bound elements that do not exist in the elements array.
|
||||||
*
|
*
|
||||||
@@ -407,30 +401,35 @@ export const restoreElements = (
|
|||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||||
): ExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
// used to detect duplicate top-level element ids
|
// used to detect duplicate top-level element ids
|
||||||
const existingIds = new Set<string>();
|
const existingIds = new Set<string>();
|
||||||
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
const localElementsMap = localElements ? arrayToMap(localElements) : null;
|
||||||
const restoredElements = (elements || []).reduce((elements, element) => {
|
const restoredElements = syncInvalidIndices(
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
(elements || []).reduce((elements, element) => {
|
||||||
// and causing issues if retained
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
// and causing issues if retained
|
||||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
if (migratedElement) {
|
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||||
const localElement = localElementsMap?.get(element.id);
|
if (migratedElement) {
|
||||||
if (localElement && localElement.version > migratedElement.version) {
|
const localElement = localElementsMap?.get(element.id);
|
||||||
migratedElement = bumpVersion(migratedElement, localElement.version);
|
if (localElement && localElement.version > migratedElement.version) {
|
||||||
}
|
migratedElement = bumpVersion(
|
||||||
if (existingIds.has(migratedElement.id)) {
|
migratedElement,
|
||||||
migratedElement = { ...migratedElement, id: randomId() };
|
localElement.version,
|
||||||
}
|
);
|
||||||
existingIds.add(migratedElement.id);
|
}
|
||||||
|
if (existingIds.has(migratedElement.id)) {
|
||||||
|
migratedElement = { ...migratedElement, id: randomId() };
|
||||||
|
}
|
||||||
|
existingIds.add(migratedElement.id);
|
||||||
|
|
||||||
elements.push(migratedElement);
|
elements.push(migratedElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return elements;
|
||||||
return elements;
|
}, [] as ExcalidrawElement[]),
|
||||||
}, [] as ExcalidrawElement[]);
|
);
|
||||||
|
|
||||||
if (!opts?.repairBindings) {
|
if (!opts?.repairBindings) {
|
||||||
return restoredElements;
|
return restoredElements;
|
||||||
|
|||||||
@@ -152,14 +152,14 @@ describe("Test Transform", () => {
|
|||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -235,14 +235,14 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(12);
|
expect(excalidrawElements.length).toBe(12);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -293,14 +293,14 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(8);
|
expect(excalidrawElements.length).toBe(8);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -338,13 +338,13 @@ describe("Test Transform", () => {
|
|||||||
name: "My frame",
|
name: "My frame",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elementsSkeleton,
|
elementsSkeleton,
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchObject({
|
expect(ele).toMatchObject({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -383,11 +383,11 @@ describe("Test Transform", () => {
|
|||||||
height: 100,
|
height: 100,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elementsSkeleton,
|
elementsSkeleton,
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
|
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||||
expect(frame.width).toBe(800);
|
expect(frame.width).toBe(800);
|
||||||
expect(frame.height).toBe(126);
|
expect(frame.height).toBe(126);
|
||||||
});
|
});
|
||||||
@@ -411,13 +411,13 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
const [arrow, text, rectangle, ellipse] = excaldrawElements;
|
const [arrow, text, rectangle, ellipse] = excalidrawElements;
|
||||||
expect(arrow).toMatchObject({
|
expect(arrow).toMatchObject({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
x: 255,
|
x: 255,
|
||||||
@@ -466,7 +466,7 @@ describe("Test Transform", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -495,13 +495,13 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
const [arrow, text1, text2, text3] = excaldrawElements;
|
const [arrow, text1, text2, text3] = excalidrawElements;
|
||||||
|
|
||||||
expect(arrow).toMatchObject({
|
expect(arrow).toMatchObject({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
@@ -551,7 +551,7 @@ describe("Test Transform", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -611,14 +611,14 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(5);
|
expect(excalidrawElements.length).toBe(5);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -660,14 +660,14 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
|
|
||||||
excaldrawElements.forEach((ele) => {
|
excalidrawElements.forEach((ele) => {
|
||||||
expect(ele).toMatchSnapshot({
|
expect(ele).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
@@ -714,13 +714,13 @@ describe("Test Transform", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(4);
|
expect(excalidrawElements.length).toBe(4);
|
||||||
const [, , arrow, text] = excaldrawElements;
|
const [, , arrow, text] = excalidrawElements;
|
||||||
expect(arrow).toMatchObject({
|
expect(arrow).toMatchObject({
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
x: 255,
|
x: 255,
|
||||||
@@ -765,12 +765,12 @@ describe("Test Transform", () => {
|
|||||||
backgroundColor: "#bac8ff",
|
backgroundColor: "#bac8ff",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
expect(excaldrawElements.length).toBe(2);
|
expect(excalidrawElements.length).toBe(2);
|
||||||
const [arrow, rect] = excaldrawElements;
|
const [arrow, rect] = excalidrawElements;
|
||||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||||
elementId: "rect-1",
|
elementId: "rect-1",
|
||||||
focus: 0,
|
focus: 0,
|
||||||
@@ -808,13 +808,13 @@ describe("Test Transform", () => {
|
|||||||
height: 200,
|
height: 200,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const excaldrawElements = convertToExcalidrawElements(
|
const excalidrawElements = convertToExcalidrawElements(
|
||||||
elements as ExcalidrawElementSkeleton[],
|
elements as ExcalidrawElementSkeleton[],
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(excaldrawElements.length).toBe(1);
|
expect(excalidrawElements.length).toBe(1);
|
||||||
expect(excaldrawElements[0]).toMatchSnapshot({
|
expect(excalidrawElements[0]).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
versionNonce: expect.any(Number),
|
versionNonce: expect.any(Number),
|
||||||
});
|
});
|
||||||
@@ -840,4 +840,130 @@ describe("Test Transform", () => {
|
|||||||
createdBy: "user01",
|
createdBy: "user01",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should transform the elements correctly when linear elements have single point", () => {
|
||||||
|
const elements: ExcalidrawElementSkeleton[] = [
|
||||||
|
{
|
||||||
|
id: "B",
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["subgraph_group_B"],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 166.03125,
|
||||||
|
height: 163,
|
||||||
|
label: {
|
||||||
|
groupIds: ["subgraph_group_B"],
|
||||||
|
text: "B",
|
||||||
|
fontSize: 20,
|
||||||
|
verticalAlign: "top",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "A",
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["subgraph_group_A"],
|
||||||
|
x: 364.546875,
|
||||||
|
y: 0,
|
||||||
|
width: 120.265625,
|
||||||
|
height: 114,
|
||||||
|
label: {
|
||||||
|
groupIds: ["subgraph_group_A"],
|
||||||
|
text: "A",
|
||||||
|
fontSize: 20,
|
||||||
|
verticalAlign: "top",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Alice",
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["subgraph_group_A"],
|
||||||
|
x: 389.546875,
|
||||||
|
y: 35,
|
||||||
|
width: 70.265625,
|
||||||
|
height: 44,
|
||||||
|
strokeWidth: 2,
|
||||||
|
label: {
|
||||||
|
groupIds: ["subgraph_group_A"],
|
||||||
|
text: "Alice",
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Bob",
|
||||||
|
type: "rectangle",
|
||||||
|
groupIds: ["subgraph_group_B"],
|
||||||
|
x: 54.76953125,
|
||||||
|
y: 35,
|
||||||
|
width: 56.4921875,
|
||||||
|
height: 44,
|
||||||
|
strokeWidth: 2,
|
||||||
|
label: {
|
||||||
|
groupIds: ["subgraph_group_B"],
|
||||||
|
text: "Bob",
|
||||||
|
fontSize: 20,
|
||||||
|
},
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Bob_Alice",
|
||||||
|
type: "arrow",
|
||||||
|
groupIds: [],
|
||||||
|
x: 111.262,
|
||||||
|
y: 57,
|
||||||
|
strokeWidth: 2,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[272.985, 0],
|
||||||
|
],
|
||||||
|
label: {
|
||||||
|
text: "How are you?",
|
||||||
|
fontSize: 20,
|
||||||
|
groupIds: [],
|
||||||
|
},
|
||||||
|
roundness: {
|
||||||
|
type: 2,
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
id: "Bob",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
id: "Alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "Bob_B",
|
||||||
|
type: "arrow",
|
||||||
|
groupIds: [],
|
||||||
|
x: 77.017,
|
||||||
|
y: 79,
|
||||||
|
strokeWidth: 2,
|
||||||
|
points: [[0, 0]],
|
||||||
|
label: {
|
||||||
|
text: "Friendship",
|
||||||
|
fontSize: 20,
|
||||||
|
groupIds: [],
|
||||||
|
},
|
||||||
|
roundness: {
|
||||||
|
type: 2,
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
id: "Bob",
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
id: "B",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const excalidrawElements = convertToExcalidrawElements(elements, opts);
|
||||||
|
expect(excalidrawElements.length).toBe(12);
|
||||||
|
excalidrawElements.forEach((ele) => {
|
||||||
|
expect(ele).toMatchSnapshot({
|
||||||
|
seed: expect.any(Number),
|
||||||
|
versionNonce: expect.any(Number),
|
||||||
|
id: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,9 +44,16 @@ import {
|
|||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
assertNever,
|
||||||
|
cloneJSON,
|
||||||
|
getFontString,
|
||||||
|
toBrandedType,
|
||||||
|
} from "../utils";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
@@ -398,11 +405,21 @@ const bindLinearElementToElement = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safe check to early return for single point
|
||||||
|
if (linearElement.points.length < 2) {
|
||||||
|
return {
|
||||||
|
linearElement,
|
||||||
|
startBoundElement,
|
||||||
|
endBoundElement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
||||||
const endPointIndex = linearElement.points.length - 1;
|
const endPointIndex = linearElement.points.length - 1;
|
||||||
const delta = 0.5;
|
const delta = 0.5;
|
||||||
|
|
||||||
const newPoints = cloneJSON(linearElement.points) as [number, number][];
|
const newPoints = cloneJSON(linearElement.points) as [number, number][];
|
||||||
|
|
||||||
// left to right so shift the arrow towards right
|
// left to right so shift the arrow towards right
|
||||||
if (
|
if (
|
||||||
linearElement.points[endPointIndex][0] >
|
linearElement.points[endPointIndex][0] >
|
||||||
@@ -457,12 +474,15 @@ class ElementStore {
|
|||||||
|
|
||||||
this.excalidrawElements.set(ele.id, ele);
|
this.excalidrawElements.set(ele.id, ele);
|
||||||
};
|
};
|
||||||
|
|
||||||
getElements = () => {
|
getElements = () => {
|
||||||
return Array.from(this.excalidrawElements.values());
|
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
|
||||||
};
|
};
|
||||||
|
|
||||||
getElementsMap = () => {
|
getElementsMap = () => {
|
||||||
return toBrandedType<NonDeletedSceneElementsMap>(this.excalidrawElements);
|
return toBrandedType<NonDeletedSceneElementsMap>(
|
||||||
|
arrayToMap(this.getElements()),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
getElement = (id: string) => {
|
getElement = (id: string) => {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
|
||||||
|
export const sanitizeHTMLAttribute = (html: string) => {
|
||||||
|
return html.replace(/"/g, """);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeLink = (link: string) => {
|
export const normalizeLink = (link: string) => {
|
||||||
link = link.trim();
|
link = link.trim();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
return sanitizeUrl(link);
|
return sanitizeUrl(sanitizeHTMLAttribute(link));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isLocalLink = (link: string | null) => {
|
export const isLocalLink = (link: string | null) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -299,13 +299,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pointRelativeTo = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
absoluteCoords: Point,
|
|
||||||
): Point => {
|
|
||||||
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||||
// Here we add +1 to avoid these numbers to be 0
|
// Here we add +1 to avoid these numbers to be 0
|
||||||
// otherwise rough.js will throw an error complaining about it
|
// otherwise rough.js will throw an error complaining about it
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,27 +11,33 @@ import {
|
|||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
IframeData,
|
IframeData,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { sanitizeHTMLAttribute } from "../data/url";
|
||||||
|
import { MarkRequired } from "../utility-types";
|
||||||
|
import { StoreAction } from "../store";
|
||||||
|
|
||||||
const embeddedLinkCache = new Map<string, IframeData>();
|
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||||
|
|
||||||
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||||
|
|
||||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
||||||
const RE_GH_GIST_EMBED =
|
const RE_GH_GIST_EMBED =
|
||||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||||
|
|
||||||
// not anchored to start to allow <blockquote> twitter embeds
|
// not anchored to start to allow <blockquote> twitter embeds
|
||||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/;
|
const RE_TWITTER =
|
||||||
|
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||||
const RE_TWITTER_EMBED =
|
const RE_TWITTER_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
|
||||||
|
|
||||||
const RE_VALTOWN =
|
const RE_VALTOWN =
|
||||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||||
|
|
||||||
const RE_GENERIC_EMBED =
|
const RE_GENERIC_EMBED =
|
||||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||||
@@ -53,7 +59,18 @@ const ALLOWED_DOMAINS = new Set([
|
|||||||
"stackblitz.com",
|
"stackblitz.com",
|
||||||
"val.town",
|
"val.town",
|
||||||
"giphy.com",
|
"giphy.com",
|
||||||
"dddice.com",
|
]);
|
||||||
|
|
||||||
|
const ALLOW_SAME_ORIGIN = new Set([
|
||||||
|
"youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"vimeo.com",
|
||||||
|
"player.vimeo.com",
|
||||||
|
"figma.com",
|
||||||
|
"twitter.com",
|
||||||
|
"x.com",
|
||||||
|
"*.simplepdf.eu",
|
||||||
|
"stackblitz.com",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const createSrcDoc = (body: string) => {
|
export const createSrcDoc = (body: string) => {
|
||||||
@@ -62,7 +79,7 @@ export const createSrcDoc = (body: string) => {
|
|||||||
|
|
||||||
export const getEmbedLink = (
|
export const getEmbedLink = (
|
||||||
link: string | null | undefined,
|
link: string | null | undefined,
|
||||||
): IframeData | null => {
|
): IframeDataWithSandbox | null => {
|
||||||
if (!link) {
|
if (!link) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -73,6 +90,10 @@ export const getEmbedLink = (
|
|||||||
|
|
||||||
const originalLink = link;
|
const originalLink = link;
|
||||||
|
|
||||||
|
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
|
||||||
|
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
|
||||||
|
);
|
||||||
|
|
||||||
let type: "video" | "generic" = "generic";
|
let type: "video" | "generic" = "generic";
|
||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
@@ -99,8 +120,14 @@ export const getEmbedLink = (
|
|||||||
link,
|
link,
|
||||||
intrinsicSize: aspectRatio,
|
intrinsicSize: aspectRatio,
|
||||||
type,
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
});
|
});
|
||||||
return { link, intrinsicSize: aspectRatio, type };
|
return {
|
||||||
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const vimeoLink = link.match(RE_VIMEO);
|
const vimeoLink = link.match(RE_VIMEO);
|
||||||
@@ -118,8 +145,15 @@ export const getEmbedLink = (
|
|||||||
link,
|
link,
|
||||||
intrinsicSize: aspectRatio,
|
intrinsicSize: aspectRatio,
|
||||||
type,
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
});
|
});
|
||||||
return { link, intrinsicSize: aspectRatio, type, error };
|
return {
|
||||||
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
error,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const figmaLink = link.match(RE_FIGMA);
|
const figmaLink = link.match(RE_FIGMA);
|
||||||
@@ -133,8 +167,14 @@ export const getEmbedLink = (
|
|||||||
link,
|
link,
|
||||||
intrinsicSize: aspectRatio,
|
intrinsicSize: aspectRatio,
|
||||||
type,
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
});
|
});
|
||||||
return { link, intrinsicSize: aspectRatio, type };
|
return {
|
||||||
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const valLink = link.match(RE_VALTOWN);
|
const valLink = link.match(RE_VALTOWN);
|
||||||
@@ -145,70 +185,74 @@ export const getEmbedLink = (
|
|||||||
link,
|
link,
|
||||||
intrinsicSize: aspectRatio,
|
intrinsicSize: aspectRatio,
|
||||||
type,
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
});
|
});
|
||||||
return { link, intrinsicSize: aspectRatio, type };
|
return {
|
||||||
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RE_TWITTER.test(link)) {
|
if (RE_TWITTER.test(link)) {
|
||||||
// the embed srcdoc still supports twitter.com domain only
|
const postId = link.match(RE_TWITTER)![1];
|
||||||
link = link.replace(/\bx.com\b/, "twitter.com");
|
// the embed srcdoc still supports twitter.com domain only.
|
||||||
|
// Note that we don't attempt to parse the username as it can consist of
|
||||||
|
// non-latin1 characters, and the username in the url can be set to anything
|
||||||
|
// without affecting the embed.
|
||||||
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
|
`https://twitter.com/x/status/${postId}`,
|
||||||
|
);
|
||||||
|
|
||||||
let ret: IframeData;
|
const ret: IframeDataWithSandbox = {
|
||||||
// assume embed code
|
type: "document",
|
||||||
if (/<blockquote/.test(link)) {
|
srcdoc: (theme: string) =>
|
||||||
const srcDoc = createSrcDoc(link);
|
createSrcDoc(
|
||||||
ret = {
|
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||||
type: "document",
|
),
|
||||||
srcdoc: () => srcDoc,
|
intrinsicSize: { w: 480, h: 480 },
|
||||||
intrinsicSize: { w: 480, h: 480 },
|
sandbox: { allowSameOrigin },
|
||||||
};
|
};
|
||||||
// assume regular tweet url
|
|
||||||
} else {
|
|
||||||
ret = {
|
|
||||||
type: "document",
|
|
||||||
srcdoc: (theme: string) =>
|
|
||||||
createSrcDoc(
|
|
||||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
|
||||||
),
|
|
||||||
intrinsicSize: { w: 480, h: 480 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
embeddedLinkCache.set(originalLink, ret);
|
embeddedLinkCache.set(originalLink, ret);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RE_GH_GIST.test(link)) {
|
if (RE_GH_GIST.test(link)) {
|
||||||
let ret: IframeData;
|
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||||
// assume embed code
|
const safeURL = sanitizeHTMLAttribute(
|
||||||
if (/<script>/.test(link)) {
|
`https://gist.github.com/${user}/${gistId}`,
|
||||||
const srcDoc = createSrcDoc(link);
|
);
|
||||||
ret = {
|
const ret: IframeDataWithSandbox = {
|
||||||
type: "document",
|
type: "document",
|
||||||
srcdoc: () => srcDoc,
|
srcdoc: () =>
|
||||||
intrinsicSize: { w: 550, h: 720 },
|
createSrcDoc(`
|
||||||
};
|
<script src="${safeURL}.js"></script>
|
||||||
// assume regular url
|
|
||||||
} else {
|
|
||||||
ret = {
|
|
||||||
type: "document",
|
|
||||||
srcdoc: () =>
|
|
||||||
createSrcDoc(`
|
|
||||||
<script src="${link}.js"></script>
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
* { margin: 0px; }
|
* { margin: 0px; }
|
||||||
table, .gist { height: 100%; }
|
table, .gist { height: 100%; }
|
||||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||||
</style>
|
</style>
|
||||||
`),
|
`),
|
||||||
intrinsicSize: { w: 550, h: 720 },
|
intrinsicSize: { w: 550, h: 720 },
|
||||||
};
|
sandbox: { allowSameOrigin },
|
||||||
}
|
};
|
||||||
embeddedLinkCache.set(link, ret);
|
embeddedLinkCache.set(link, ret);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
|
embeddedLinkCache.set(link, {
|
||||||
return { link, intrinsicSize: aspectRatio, type };
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
link,
|
||||||
|
intrinsicSize: aspectRatio,
|
||||||
|
type,
|
||||||
|
sandbox: { allowSameOrigin },
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createPlaceholderEmbeddableLabel = (
|
export const createPlaceholderEmbeddableLabel = (
|
||||||
@@ -271,39 +315,44 @@ export const actionSetEmbeddableAsActiveTool = register({
|
|||||||
type: "embeddable",
|
type: "embeddable",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
storeAction: StoreAction.NONE,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validateHostname = (
|
const matchHostname = (
|
||||||
url: string,
|
url: string,
|
||||||
/** using a Set assumes it already contains normalized bare domains */
|
/** using a Set assumes it already contains normalized bare domains */
|
||||||
allowedHostnames: Set<string> | string,
|
allowedHostnames: Set<string> | string,
|
||||||
): boolean => {
|
): string | null => {
|
||||||
try {
|
try {
|
||||||
const { hostname } = new URL(url);
|
const { hostname } = new URL(url);
|
||||||
|
|
||||||
const bareDomain = hostname.replace(/^www\./, "");
|
const bareDomain = hostname.replace(/^www\./, "");
|
||||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
|
||||||
/^([^.]+)/,
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allowedHostnames instanceof Set) {
|
if (allowedHostnames instanceof Set) {
|
||||||
return (
|
if (ALLOWED_DOMAINS.has(bareDomain)) {
|
||||||
ALLOWED_DOMAINS.has(bareDomain) ||
|
return bareDomain;
|
||||||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
}
|
||||||
|
|
||||||
|
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||||
|
/^([^.]+)/,
|
||||||
|
"*",
|
||||||
);
|
);
|
||||||
|
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
|
||||||
|
return bareDomainWithFirstSubdomainWildcarded;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
|
||||||
return true;
|
if (bareDomain === bareAllowedHostname) {
|
||||||
|
return bareAllowedHostname;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maybeParseEmbedSrc = (str: string): string => {
|
export const maybeParseEmbedSrc = (str: string): string => {
|
||||||
@@ -325,6 +374,7 @@ export const maybeParseEmbedSrc = (str: string): string => {
|
|||||||
if (match && match.length === 2) {
|
if (match && match.length === 2) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -352,7 +402,7 @@ export const embeddableURLValidator = (
|
|||||||
if (url.match(domain)) {
|
if (url.match(domain)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (validateHostname(url, domain)) {
|
} else if (matchHostname(url, domain)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,5 +410,5 @@ export const embeddableURLValidator = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return validateHostname(url, ALLOWED_DOMAINS);
|
return !!matchHostname(url, ALLOWED_DOMAINS);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ export {
|
|||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
export {
|
|
||||||
hitTest,
|
|
||||||
isHittingElementBoundingBoxWithoutHittingElement,
|
|
||||||
} from "./collision";
|
|
||||||
export {
|
export {
|
||||||
resizeTest,
|
resizeTest,
|
||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +33,9 @@ import {
|
|||||||
AppState,
|
AppState,
|
||||||
PointerCoords,
|
PointerCoords,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
|
AppClassProperties,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import History from "../history";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
@@ -50,6 +49,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
|||||||
import { DRAGGING_THRESHOLD } from "../constants";
|
import { DRAGGING_THRESHOLD } from "../constants";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
|
import { IStore } from "../store";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
version: number | null;
|
version: number | null;
|
||||||
@@ -334,9 +334,10 @@ export class LinearElementEditor {
|
|||||||
event: PointerEvent,
|
event: PointerEvent,
|
||||||
editingLinearElement: LinearElementEditor,
|
editingLinearElement: LinearElementEditor,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
editingLinearElement;
|
editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
@@ -380,8 +381,7 @@ export class LinearElementEditor {
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elements,
|
app,
|
||||||
elementsMap,
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -642,16 +642,17 @@ export class LinearElementEditor {
|
|||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
history: History,
|
store: IStore,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
app: AppClassProperties,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
): {
|
): {
|
||||||
didAddPoint: boolean;
|
didAddPoint: boolean;
|
||||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||||
linearElementEditor: LinearElementEditor | null;
|
linearElementEditor: LinearElementEditor | null;
|
||||||
} {
|
} {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||||
didAddPoint: false,
|
didAddPoint: false,
|
||||||
hitElement: null,
|
hitElement: null,
|
||||||
@@ -699,7 +700,7 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
}
|
}
|
||||||
history.resumeRecording();
|
store.shouldCaptureIncrement();
|
||||||
ret.linearElementEditor = {
|
ret.linearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
pointerDownState: {
|
pointerDownState: {
|
||||||
@@ -714,11 +715,7 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
selectedPointsIndices: [element.points.length - 1],
|
selectedPointsIndices: [element.points.length - 1],
|
||||||
lastUncommittedPoint: null,
|
lastUncommittedPoint: null,
|
||||||
endBindingElement: getHoveredElementForBinding(
|
endBindingElement: getHoveredElementForBinding(scenePointer, app),
|
||||||
scenePointer,
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ret.didAddPoint = true;
|
ret.didAddPoint = true;
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { getUpdatedTimestamp } from "../utils";
|
|||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
|
|
||||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce"
|
"id" | "version" | "versionNonce" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// This function tracks updates of text elements for the purposes for collaboration.
|
// This function tracks updates of text elements for the purposes for collaboration.
|
||||||
@@ -79,6 +79,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
didChange = true;
|
didChange = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!didChange) {
|
if (!didChange) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
|
|||||||
| "angle"
|
| "angle"
|
||||||
| "groupIds"
|
| "groupIds"
|
||||||
| "frameId"
|
| "frameId"
|
||||||
|
| "index"
|
||||||
| "boundElements"
|
| "boundElements"
|
||||||
| "seed"
|
| "seed"
|
||||||
| "version"
|
| "version"
|
||||||
@@ -89,6 +90,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
angle = 0,
|
angle = 0,
|
||||||
groupIds = [],
|
groupIds = [],
|
||||||
frameId = null,
|
frameId = null,
|
||||||
|
index = null,
|
||||||
roundness = null,
|
roundness = null,
|
||||||
boundElements = null,
|
boundElements = null,
|
||||||
link = null,
|
link = null,
|
||||||
@@ -114,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
opacity,
|
opacity,
|
||||||
groupIds,
|
groupIds,
|
||||||
frameId,
|
frameId,
|
||||||
|
index,
|
||||||
roundness,
|
roundness,
|
||||||
seed: rest.seed ?? randomInteger(),
|
seed: rest.seed ?? randomInteger(),
|
||||||
version: rest.version || 1,
|
version: rest.version || 1,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { AppState, Zoom } from "../types";
|
|||||||
import { getElementBounds } from "./bounds";
|
import { getElementBounds } from "./bounds";
|
||||||
import { viewportCoordsToSceneCoords } from "../utils";
|
import { viewportCoordsToSceneCoords } from "../utils";
|
||||||
|
|
||||||
|
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||||
|
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||||
|
// - could also be part of `_clearElements`
|
||||||
export const isInvisiblySmallElement = (
|
export const isInvisiblySmallElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
|||||||
@@ -26,16 +26,11 @@ import { isTextElement } from ".";
|
|||||||
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isTextBindableContainer } from "./typeChecks";
|
|
||||||
import { getElementAbsoluteCoords } from ".";
|
|
||||||
import { getSelectedElements } from "../scene";
|
|
||||||
import { isHittingElementNotConsideringBoundingBox } from "./collision";
|
|
||||||
|
|
||||||
import { ExtractSetType, MakeBrand } from "../utility-types";
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
} from "./containerCache";
|
} from "./containerCache";
|
||||||
|
import { ExtractSetType, MakeBrand } from "../utility-types";
|
||||||
|
|
||||||
export const normalizeText = (text: string) => {
|
export const normalizeText = (text: string) => {
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +48,7 @@ export const redrawTextBoundingBox = (
|
|||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawElement | null,
|
container: ExcalidrawElement | null,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
informMutation: boolean = true,
|
||||||
) => {
|
) => {
|
||||||
let maxWidth = undefined;
|
let maxWidth = undefined;
|
||||||
const boundTextUpdates = {
|
const boundTextUpdates = {
|
||||||
@@ -61,6 +57,7 @@ export const redrawTextBoundingBox = (
|
|||||||
text: textElement.text,
|
text: textElement.text,
|
||||||
width: textElement.width,
|
width: textElement.width,
|
||||||
height: textElement.height,
|
height: textElement.height,
|
||||||
|
angle: container?.angle ?? textElement.angle,
|
||||||
};
|
};
|
||||||
|
|
||||||
boundTextUpdates.text = textElement.text;
|
boundTextUpdates.text = textElement.text;
|
||||||
@@ -94,7 +91,7 @@ export const redrawTextBoundingBox = (
|
|||||||
metrics.height,
|
metrics.height,
|
||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { height: nextHeight });
|
mutateElement(container, { height: nextHeight }, informMutation);
|
||||||
updateOriginalContainerCache(container.id, nextHeight);
|
updateOriginalContainerCache(container.id, nextHeight);
|
||||||
}
|
}
|
||||||
if (metrics.width > maxContainerWidth) {
|
if (metrics.width > maxContainerWidth) {
|
||||||
@@ -102,7 +99,7 @@ export const redrawTextBoundingBox = (
|
|||||||
metrics.width,
|
metrics.width,
|
||||||
container.type,
|
container.type,
|
||||||
);
|
);
|
||||||
mutateElement(container, { width: nextWidth });
|
mutateElement(container, { width: nextWidth }, informMutation);
|
||||||
}
|
}
|
||||||
const updatedTextElement = {
|
const updatedTextElement = {
|
||||||
...textElement,
|
...textElement,
|
||||||
@@ -117,7 +114,7 @@ export const redrawTextBoundingBox = (
|
|||||||
boundTextUpdates.y = y;
|
boundTextUpdates.y = y;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateElement(textElement, boundTextUpdates);
|
mutateElement(textElement, boundTextUpdates, informMutation);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bindTextToShapeAfterDuplication = (
|
export const bindTextToShapeAfterDuplication = (
|
||||||
@@ -771,50 +768,6 @@ export const suppportsHorizontalAlign = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTextBindableContainerAtPosition = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): ExcalidrawTextContainer | null => {
|
|
||||||
const selectedElements = getSelectedElements(elements, appState);
|
|
||||||
if (selectedElements.length === 1) {
|
|
||||||
return isTextBindableContainer(selectedElements[0], false)
|
|
||||||
? selectedElements[0]
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
let hitElement = null;
|
|
||||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
|
||||||
for (let index = elements.length - 1; index >= 0; --index) {
|
|
||||||
if (elements[index].isDeleted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
|
||||||
elements[index],
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
isArrowElement(elements[index]) &&
|
|
||||||
isHittingElementNotConsideringBoundingBox(
|
|
||||||
elements[index],
|
|
||||||
appState,
|
|
||||||
null,
|
|
||||||
[x, y],
|
|
||||||
elementsMap,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
hitElement = elements[index];
|
|
||||||
break;
|
|
||||||
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
|
|
||||||
hitElement = elements[index];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTextBindableContainer(hitElement, false) ? hitElement : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_CONTAINER_TYPES = new Set([
|
const VALID_CONTAINER_TYPES = new Set([
|
||||||
"rectangle",
|
"rectangle",
|
||||||
"ellipse",
|
"ellipse",
|
||||||
|
|||||||
@@ -1454,7 +1454,7 @@ describe("textWysiwyg", () => {
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
updated: 1,
|
updated: 1,
|
||||||
version: 1,
|
version: 2,
|
||||||
width: 610,
|
width: 610,
|
||||||
x: 15,
|
x: 15,
|
||||||
y: 25,
|
y: 25,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const isInitializedImageElement = (
|
export const isInitializedImageElement = (
|
||||||
@@ -101,7 +102,7 @@ export const isLinearElement = (
|
|||||||
|
|
||||||
export const isArrowElement = (
|
export const isArrowElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
): element is ExcalidrawLinearElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
return element != null && element.type === "arrow";
|
return element != null && element.type === "arrow";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
|||||||
|
|
||||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||||
|
export type FractionalIndex = string & { _brand: "franctionalIndex" };
|
||||||
|
|
||||||
|
export type BoundElement = Readonly<{
|
||||||
|
id: ExcalidrawLinearElement["id"];
|
||||||
|
type: "arrow" | "text";
|
||||||
|
}>;
|
||||||
|
|
||||||
type _ExcalidrawElementBase = Readonly<{
|
type _ExcalidrawElementBase = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,18 +56,18 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
Used for deterministic reconciliation of updates during collaboration,
|
Used for deterministic reconciliation of updates during collaboration,
|
||||||
in case the versions (see above) are identical. */
|
in case the versions (see above) are identical. */
|
||||||
versionNonce: number;
|
versionNonce: number;
|
||||||
|
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
|
||||||
|
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
|
||||||
|
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
|
||||||
|
Could be null, i.e. for new elements which were not yet assigned to the scene. */
|
||||||
|
index: FractionalIndex | null;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
/** List of groups the element belongs to.
|
/** List of groups the element belongs to.
|
||||||
Ordered from deepest to shallowest. */
|
Ordered from deepest to shallowest. */
|
||||||
groupIds: readonly GroupId[];
|
groupIds: readonly GroupId[];
|
||||||
frameId: string | null;
|
frameId: string | null;
|
||||||
/** other elements that are bound to this element */
|
/** other elements that are bound to this element */
|
||||||
boundElements:
|
boundElements: readonly BoundElement[] | null;
|
||||||
| readonly Readonly<{
|
|
||||||
id: ExcalidrawLinearElement["id"];
|
|
||||||
type: "arrow" | "text";
|
|
||||||
}>[]
|
|
||||||
| null;
|
|
||||||
/** epoch (ms) timestamp of last element update */
|
/** epoch (ms) timestamp of last element update */
|
||||||
updated: number;
|
updated: number;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -105,6 +111,7 @@ export type IframeData =
|
|||||||
| {
|
| {
|
||||||
intrinsicSize: { w: number; h: number };
|
intrinsicSize: { w: number; h: number };
|
||||||
error?: Error;
|
error?: Error;
|
||||||
|
sandbox?: { allowSameOrigin?: boolean };
|
||||||
} & (
|
} & (
|
||||||
| { type: "video" | "generic"; link: string }
|
| { type: "video" | "generic"; link: string }
|
||||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||||
@@ -164,6 +171,12 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawIframeElement
|
| ExcalidrawIframeElement
|
||||||
| ExcalidrawEmbeddableElement;
|
| ExcalidrawEmbeddableElement;
|
||||||
|
|
||||||
|
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||||
|
index: FractionalIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
|
||||||
|
|
||||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
};
|
};
|
||||||
@@ -275,7 +288,10 @@ export type NonDeletedElementsMap = Map<
|
|||||||
* Map of all excalidraw Scene elements, including deleted.
|
* Map of all excalidraw Scene elements, including deleted.
|
||||||
* Not a subset. Use this type when you need access to current Scene elements.
|
* Not a subset. Use this type when you need access to current Scene elements.
|
||||||
*/
|
*/
|
||||||
export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
export type SceneElementsMap = Map<
|
||||||
|
ExcalidrawElement["id"],
|
||||||
|
Ordered<ExcalidrawElement>
|
||||||
|
> &
|
||||||
MakeBrand<"SceneElementsMap">;
|
MakeBrand<"SceneElementsMap">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,7 +300,7 @@ export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
|
|||||||
*/
|
*/
|
||||||
export type NonDeletedSceneElementsMap = Map<
|
export type NonDeletedSceneElementsMap = Map<
|
||||||
ExcalidrawElement["id"],
|
ExcalidrawElement["id"],
|
||||||
NonDeletedExcalidrawElement
|
Ordered<NonDeletedExcalidrawElement>
|
||||||
> &
|
> &
|
||||||
MakeBrand<"NonDeletedSceneElementsMap">;
|
MakeBrand<"NonDeletedSceneElementsMap">;
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,7 @@ export class ImageSceneDataError extends Error {
|
|||||||
this.code = code;
|
this.code = code;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidFractionalIndexError extends Error {
|
||||||
|
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { generateNKeysBetween } from "fractional-indexing";
|
||||||
|
import { mutateElement } from "./element/mutateElement";
|
||||||
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
FractionalIndex,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "./element/types";
|
||||||
|
import { InvalidFractionalIndexError } from "./errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envisioned relation between array order and fractional indices:
|
||||||
|
*
|
||||||
|
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
|
||||||
|
* - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure
|
||||||
|
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
|
||||||
|
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
|
||||||
|
* - it's necessary to always keep the fractional indices in sync with the array order
|
||||||
|
* - elements with invalid indices should be detected and synced, without altering the already valid indices
|
||||||
|
*
|
||||||
|
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
|
||||||
|
* - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs
|
||||||
|
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo
|
||||||
|
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
|
||||||
|
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that all elements have valid fractional indices.
|
||||||
|
*
|
||||||
|
* @throws `InvalidFractionalIndexError` if invalid index is detected.
|
||||||
|
*/
|
||||||
|
export const validateFractionalIndices = (
|
||||||
|
indices: (ExcalidrawElement["index"] | undefined)[],
|
||||||
|
) => {
|
||||||
|
for (const [i, index] of indices.entries()) {
|
||||||
|
const predecessorIndex = indices[i - 1];
|
||||||
|
const successorIndex = indices[i + 1];
|
||||||
|
|
||||||
|
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
|
||||||
|
throw new InvalidFractionalIndexError(
|
||||||
|
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order the elements based on the fractional indices.
|
||||||
|
* - when fractional indices are identical, break the tie based on the element id
|
||||||
|
* - when there is no fractional index in one of the elements, respect the order of the array
|
||||||
|
*/
|
||||||
|
export const orderByFractionalIndex = (
|
||||||
|
elements: OrderedExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
return elements.sort((a, b) => {
|
||||||
|
// in case the indices are not the defined at runtime
|
||||||
|
if (isOrderedElement(a) && isOrderedElement(b)) {
|
||||||
|
if (a.index < b.index) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.index > b.index) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// break ties based on the element id
|
||||||
|
return a.id < b.id ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensively keep the array order
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
|
||||||
|
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
|
||||||
|
*/
|
||||||
|
export const syncMovedIndices = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
movedElements: Map<string, ExcalidrawElement>,
|
||||||
|
): OrderedExcalidrawElement[] => {
|
||||||
|
try {
|
||||||
|
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||||
|
|
||||||
|
// try generatating indices, throws on invalid movedElements
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
|
validateFractionalIndices(
|
||||||
|
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
|
||||||
|
);
|
||||||
|
|
||||||
|
// split mutation so we don't end up in an incosistent state
|
||||||
|
for (const [element, update] of elementsUpdates) {
|
||||||
|
mutateElement(element, update, false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// fallback to default sync
|
||||||
|
syncInvalidIndices(elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements as OrderedExcalidrawElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||||
|
*
|
||||||
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
|
*/
|
||||||
|
export const syncInvalidIndices = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): OrderedExcalidrawElement[] => {
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
for (const [element, update] of elementsUpdates) {
|
||||||
|
mutateElement(element, update, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements as OrderedExcalidrawElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
|
*
|
||||||
|
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
|
||||||
|
*/
|
||||||
|
const getMovedIndicesGroups = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
movedElements: Map<string, ExcalidrawElement>,
|
||||||
|
) => {
|
||||||
|
const indicesGroups: number[][] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < elements.length) {
|
||||||
|
if (
|
||||||
|
movedElements.has(elements[i].id) &&
|
||||||
|
!isValidFractionalIndex(
|
||||||
|
elements[i]?.index,
|
||||||
|
elements[i - 1]?.index,
|
||||||
|
elements[i + 1]?.index,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||||
|
|
||||||
|
while (++i < elements.length) {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
movedElements.has(elements[i].id) &&
|
||||||
|
!isValidFractionalIndex(
|
||||||
|
elements[i]?.index,
|
||||||
|
elements[i - 1]?.index,
|
||||||
|
elements[i + 1]?.index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
indicesGroup.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
indicesGroup.push(i); // push the upper bound index as the last item
|
||||||
|
indicesGroups.push(indicesGroup);
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicesGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
|
||||||
|
*
|
||||||
|
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
|
||||||
|
*/
|
||||||
|
const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
|
||||||
|
const indicesGroups: number[][] = [];
|
||||||
|
|
||||||
|
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
|
||||||
|
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||||
|
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
|
||||||
|
let lowerBoundIndex: number = -1;
|
||||||
|
let upperBoundIndex: number = 0;
|
||||||
|
|
||||||
|
/** @returns maybe valid lowerBound */
|
||||||
|
const getLowerBound = (
|
||||||
|
index: number,
|
||||||
|
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||||
|
const lowerBound = elements[lowerBoundIndex]
|
||||||
|
? elements[lowerBoundIndex].index
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// we are already iterating left to right, therefore there is no need for additional looping
|
||||||
|
const candidate = elements[index - 1]?.index;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!lowerBound && candidate) || // first lowerBound
|
||||||
|
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
|
||||||
|
) {
|
||||||
|
// WARN: candidate's index could be higher or same as the current element's index
|
||||||
|
return [candidate, index - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// cache hit! take the last lower bound
|
||||||
|
return [lowerBound, lowerBoundIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @returns always valid upperBound */
|
||||||
|
const getUpperBound = (
|
||||||
|
index: number,
|
||||||
|
): [ExcalidrawElement["index"] | undefined, number] => {
|
||||||
|
const upperBound = elements[upperBoundIndex]
|
||||||
|
? elements[upperBoundIndex].index
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// cache hit! don't let it find the upper bound again
|
||||||
|
if (upperBound && index < upperBoundIndex) {
|
||||||
|
return [upperBound, upperBoundIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// set the current upperBoundIndex as the starting point
|
||||||
|
let i = upperBoundIndex;
|
||||||
|
while (++i < elements.length) {
|
||||||
|
const candidate = elements[i]?.index;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!upperBound && candidate) || // first upperBound
|
||||||
|
(upperBound && candidate && candidate > upperBound) // next upperBound
|
||||||
|
) {
|
||||||
|
return [candidate, i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// we reached the end, sky is the limit
|
||||||
|
return [undefined, i];
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < elements.length) {
|
||||||
|
const current = elements[i].index;
|
||||||
|
[lowerBound, lowerBoundIndex] = getLowerBound(i);
|
||||||
|
[upperBound, upperBoundIndex] = getUpperBound(i);
|
||||||
|
|
||||||
|
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
|
||||||
|
// push the lower bound index as the first item
|
||||||
|
const indicesGroup = [lowerBoundIndex, i];
|
||||||
|
|
||||||
|
while (++i < elements.length) {
|
||||||
|
const current = elements[i].index;
|
||||||
|
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
|
||||||
|
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
|
||||||
|
|
||||||
|
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign bounds only for the moved elements
|
||||||
|
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
|
||||||
|
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
|
||||||
|
|
||||||
|
indicesGroup.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// push the upper bound index as the last item
|
||||||
|
indicesGroup.push(upperBoundIndex);
|
||||||
|
indicesGroups.push(indicesGroup);
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return indicesGroups;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidFractionalIndex = (
|
||||||
|
index: ExcalidrawElement["index"] | undefined,
|
||||||
|
predecessor: ExcalidrawElement["index"] | undefined,
|
||||||
|
successor: ExcalidrawElement["index"] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predecessor && successor) {
|
||||||
|
return predecessor < index && index < successor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!predecessor && successor) {
|
||||||
|
// first element
|
||||||
|
return index < successor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (predecessor && !successor) {
|
||||||
|
// last element
|
||||||
|
return predecessor < index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only element in the array
|
||||||
|
return !!index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateIndices = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
indicesGroups: number[][],
|
||||||
|
) => {
|
||||||
|
const elementsUpdates = new Map<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{ index: FractionalIndex }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const indices of indicesGroups) {
|
||||||
|
const lowerBoundIndex = indices.shift()!;
|
||||||
|
const upperBoundIndex = indices.pop()!;
|
||||||
|
|
||||||
|
const fractionalIndices = generateNKeysBetween(
|
||||||
|
elements[lowerBoundIndex]?.index,
|
||||||
|
elements[upperBoundIndex]?.index,
|
||||||
|
indices.length,
|
||||||
|
) as FractionalIndex[];
|
||||||
|
|
||||||
|
for (let i = 0; i < indices.length; i++) {
|
||||||
|
const element = elements[indices[i]];
|
||||||
|
|
||||||
|
elementsUpdates.set(element, {
|
||||||
|
index: fractionalIndices[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return elementsUpdates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOrderedElement = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
): element is OrderedExcalidrawElement => {
|
||||||
|
// for now it's sufficient whether the index is there
|
||||||
|
// meaning, the element was already ordered in the past
|
||||||
|
// meaning, it is not a newly inserted element, not an unrestored element, etc.
|
||||||
|
// it does not have to mean that the index itself is valid
|
||||||
|
if (element.index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
@@ -29,7 +29,7 @@ import { ReadonlySetLike } from "./utility-types";
|
|||||||
|
|
||||||
// --------------------------- Frame State ------------------------------------
|
// --------------------------- Frame State ------------------------------------
|
||||||
export const bindElementsToFramesAfterDuplication = (
|
export const bindElementsToFramesAfterDuplication = (
|
||||||
nextElements: ExcalidrawElement[],
|
nextElements: readonly ExcalidrawElement[],
|
||||||
oldElements: readonly ExcalidrawElement[],
|
oldElements: readonly ExcalidrawElement[],
|
||||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -355,6 +355,24 @@ export const getMaximumGroups = (
|
|||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
|
||||||
|
const nonDeletedGroupIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const [, element] of elements) {
|
||||||
|
// defensive check
|
||||||
|
if (element.isDeleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// defensive fallback
|
||||||
|
for (const groupId of element.groupIds ?? []) {
|
||||||
|
nonDeletedGroupIds.add(groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonDeletedGroupIds;
|
||||||
|
};
|
||||||
|
|
||||||
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
||||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||||
const groupCount = new Map<string, number>();
|
const groupCount = new Map<string, number>();
|
||||||
|
|||||||
+186
-241
@@ -1,265 +1,210 @@
|
|||||||
|
import { AppStateChange, ElementsChange } from "./change";
|
||||||
|
import { SceneElementsMap } from "./element/types";
|
||||||
|
import { Emitter } from "./emitter";
|
||||||
|
import { Snapshot } from "./store";
|
||||||
import { AppState } from "./types";
|
import { AppState } from "./types";
|
||||||
import { ExcalidrawElement } from "./element/types";
|
|
||||||
import { isLinearElement } from "./element/typeChecks";
|
|
||||||
import { deepCopyElement } from "./element/newElement";
|
|
||||||
import { Mutable } from "./utility-types";
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
type HistoryStack = HistoryEntry[];
|
||||||
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
|
|
||||||
elements: ExcalidrawElement[];
|
export class HistoryChangedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly isUndoStackEmpty: boolean = true,
|
||||||
|
public readonly isRedoStackEmpty: boolean = true,
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DehydratedExcalidrawElement {
|
export class History {
|
||||||
id: string;
|
public readonly onHistoryChangedEmitter = new Emitter<
|
||||||
versionNonce: number;
|
[HistoryChangedEvent]
|
||||||
}
|
>();
|
||||||
|
|
||||||
interface DehydratedHistoryEntry {
|
private readonly undoStack: HistoryStack = [];
|
||||||
appState: string;
|
private readonly redoStack: HistoryStack = [];
|
||||||
elements: DehydratedExcalidrawElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAppStatePropertiesForHistory = (appState: AppState) => {
|
public get isUndoStackEmpty() {
|
||||||
return {
|
return this.undoStack.length === 0;
|
||||||
selectedElementIds: appState.selectedElementIds,
|
|
||||||
selectedGroupIds: appState.selectedGroupIds,
|
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
|
||||||
editingLinearElement: appState.editingLinearElement,
|
|
||||||
editingGroupId: appState.editingGroupId,
|
|
||||||
name: appState.name,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
class History {
|
|
||||||
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
|
|
||||||
private recording: boolean = true;
|
|
||||||
private stateHistory: DehydratedHistoryEntry[] = [];
|
|
||||||
private redoStack: DehydratedHistoryEntry[] = [];
|
|
||||||
private lastEntry: HistoryEntry | null = null;
|
|
||||||
|
|
||||||
private hydrateHistoryEntry({
|
|
||||||
appState,
|
|
||||||
elements,
|
|
||||||
}: DehydratedHistoryEntry): HistoryEntry {
|
|
||||||
return {
|
|
||||||
appState: JSON.parse(appState),
|
|
||||||
elements: elements.map((dehydratedExcalidrawElement) => {
|
|
||||||
const element = this.elementCache
|
|
||||||
.get(dehydratedExcalidrawElement.id)
|
|
||||||
?.get(dehydratedExcalidrawElement.versionNonce);
|
|
||||||
if (!element) {
|
|
||||||
throw new Error(
|
|
||||||
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private dehydrateHistoryEntry({
|
public get isRedoStackEmpty() {
|
||||||
appState,
|
return this.redoStack.length === 0;
|
||||||
elements,
|
|
||||||
}: HistoryEntry): DehydratedHistoryEntry {
|
|
||||||
return {
|
|
||||||
appState: JSON.stringify(appState),
|
|
||||||
elements: elements.map((element: ExcalidrawElement) => {
|
|
||||||
if (!this.elementCache.has(element.id)) {
|
|
||||||
this.elementCache.set(element.id, new Map());
|
|
||||||
}
|
|
||||||
const versions = this.elementCache.get(element.id)!;
|
|
||||||
if (!versions.has(element.versionNonce)) {
|
|
||||||
versions.set(element.versionNonce, deepCopyElement(element));
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: element.id,
|
|
||||||
versionNonce: element.versionNonce,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshotForTest() {
|
public clear() {
|
||||||
return {
|
this.undoStack.length = 0;
|
||||||
recording: this.recording,
|
|
||||||
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
|
|
||||||
this.hydrateHistoryEntry(dehydratedHistoryEntry),
|
|
||||||
),
|
|
||||||
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
|
|
||||||
this.hydrateHistoryEntry(dehydratedHistoryEntry),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.stateHistory.length = 0;
|
|
||||||
this.redoStack.length = 0;
|
this.redoStack.length = 0;
|
||||||
this.lastEntry = null;
|
|
||||||
this.elementCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateEntry = (
|
|
||||||
appState: AppState,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): DehydratedHistoryEntry =>
|
|
||||||
this.dehydrateHistoryEntry({
|
|
||||||
appState: clearAppStatePropertiesForHistory(appState),
|
|
||||||
elements: elements.reduce((elements, element) => {
|
|
||||||
if (
|
|
||||||
isLinearElement(element) &&
|
|
||||||
appState.multiElement &&
|
|
||||||
appState.multiElement.id === element.id
|
|
||||||
) {
|
|
||||||
// don't store multi-point arrow if still has only one point
|
|
||||||
if (
|
|
||||||
appState.multiElement &&
|
|
||||||
appState.multiElement.id === element.id &&
|
|
||||||
element.points.length < 2
|
|
||||||
) {
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.push({
|
|
||||||
...element,
|
|
||||||
// don't store last point if not committed
|
|
||||||
points:
|
|
||||||
element.lastCommittedPoint !==
|
|
||||||
element.points[element.points.length - 1]
|
|
||||||
? element.points.slice(0, -1)
|
|
||||||
: element.points,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
elements.push(element);
|
|
||||||
}
|
|
||||||
return elements;
|
|
||||||
}, [] as Mutable<typeof elements>),
|
|
||||||
});
|
|
||||||
|
|
||||||
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
|
|
||||||
const { lastEntry } = this;
|
|
||||||
|
|
||||||
if (!lastEntry) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextEntry.elements.length !== lastEntry.elements.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// loop from right to left as changes are likelier to happen on new elements
|
|
||||||
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
|
|
||||||
const prev = nextEntry.elements[i];
|
|
||||||
const next = lastEntry.elements[i];
|
|
||||||
if (
|
|
||||||
!prev ||
|
|
||||||
!next ||
|
|
||||||
prev.id !== next.id ||
|
|
||||||
prev.versionNonce !== next.versionNonce
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// note: this is safe because entry's appState is guaranteed no excess props
|
|
||||||
let key: keyof typeof nextEntry.appState;
|
|
||||||
for (key in nextEntry.appState) {
|
|
||||||
if (key === "editingLinearElement") {
|
|
||||||
if (
|
|
||||||
nextEntry.appState[key]?.elementId ===
|
|
||||||
lastEntry.appState[key]?.elementId
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (key === "selectedElementIds" || key === "selectedGroupIds") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
|
||||||
const newEntryDehydrated = this.generateEntry(appState, elements);
|
|
||||||
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
|
|
||||||
|
|
||||||
if (newEntry) {
|
|
||||||
if (!this.shouldCreateEntry(newEntry)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stateHistory.push(newEntryDehydrated);
|
|
||||||
this.lastEntry = newEntry;
|
|
||||||
// As a new entry was pushed, we invalidate the redo stack
|
|
||||||
this.clearRedoStack();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearRedoStack() {
|
|
||||||
this.redoStack.splice(0, this.redoStack.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
redoOnce(): HistoryEntry | null {
|
|
||||||
if (this.redoStack.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entryToRestore = this.redoStack.pop();
|
|
||||||
|
|
||||||
if (entryToRestore !== undefined) {
|
|
||||||
this.stateHistory.push(entryToRestore);
|
|
||||||
return this.hydrateHistoryEntry(entryToRestore);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
undoOnce(): HistoryEntry | null {
|
|
||||||
if (this.stateHistory.length === 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEntry = this.stateHistory.pop();
|
|
||||||
|
|
||||||
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
|
|
||||||
|
|
||||||
if (currentEntry !== undefined) {
|
|
||||||
this.redoStack.push(currentEntry);
|
|
||||||
return this.hydrateHistoryEntry(entryToRestore);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates history's `lastEntry` to latest app state. This is necessary
|
* Record a local change which will go into the history
|
||||||
* when doing undo/redo which itself doesn't commit to history, but updates
|
|
||||||
* app state in a way that would break `shouldCreateEntry` which relies on
|
|
||||||
* `lastEntry` to reflect last comittable history state.
|
|
||||||
* We can't update `lastEntry` from within history when calling undo/redo
|
|
||||||
* because the action potentially mutates appState/elements before storing
|
|
||||||
* it.
|
|
||||||
*/
|
*/
|
||||||
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
public record(
|
||||||
this.lastEntry = this.hydrateHistoryEntry(
|
elementsChange: ElementsChange,
|
||||||
this.generateEntry(appState, elements),
|
appStateChange: AppStateChange,
|
||||||
|
) {
|
||||||
|
const entry = HistoryEntry.create(appStateChange, elementsChange);
|
||||||
|
|
||||||
|
if (!entry.isEmpty()) {
|
||||||
|
// we have the latest changes, no need to `applyLatest`, which is done within `History.push`
|
||||||
|
this.undoStack.push(entry.inverse());
|
||||||
|
|
||||||
|
if (!entry.elementsChange.isEmpty()) {
|
||||||
|
// don't reset redo stack on local appState changes,
|
||||||
|
// as a simple click (unselect) could lead to losing all the redo entries
|
||||||
|
// only reset on non empty elements changes!
|
||||||
|
this.redoStack.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onHistoryChangedEmitter.trigger(
|
||||||
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public undo(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
snapshot: Readonly<Snapshot>,
|
||||||
|
) {
|
||||||
|
return this.perform(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
snapshot,
|
||||||
|
() => History.pop(this.undoStack),
|
||||||
|
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suspicious that this is called so many places. Seems error-prone.
|
public redo(
|
||||||
resumeRecording() {
|
elements: SceneElementsMap,
|
||||||
this.recording = true;
|
appState: AppState,
|
||||||
|
snapshot: Readonly<Snapshot>,
|
||||||
|
) {
|
||||||
|
return this.perform(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
snapshot,
|
||||||
|
() => History.pop(this.redoStack),
|
||||||
|
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
record(state: AppState, elements: readonly ExcalidrawElement[]) {
|
private perform(
|
||||||
if (this.recording) {
|
elements: SceneElementsMap,
|
||||||
this.pushEntry(state, elements);
|
appState: AppState,
|
||||||
this.recording = false;
|
snapshot: Readonly<Snapshot>,
|
||||||
|
pop: () => HistoryEntry | null,
|
||||||
|
push: (entry: HistoryEntry) => void,
|
||||||
|
): [SceneElementsMap, AppState] | void {
|
||||||
|
try {
|
||||||
|
let historyEntry = pop();
|
||||||
|
|
||||||
|
if (historyEntry === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextElements = elements;
|
||||||
|
let nextAppState = appState;
|
||||||
|
let containsVisibleChange = false;
|
||||||
|
|
||||||
|
// iterate through the history entries in case they result in no visible changes
|
||||||
|
while (historyEntry) {
|
||||||
|
try {
|
||||||
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
|
historyEntry.applyTo(nextElements, nextAppState, snapshot);
|
||||||
|
} finally {
|
||||||
|
// make sure to always push / pop, even if the increment is corrupted
|
||||||
|
push(historyEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsVisibleChange) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyEntry = pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [nextElements, nextAppState];
|
||||||
|
} finally {
|
||||||
|
// trigger the history change event before returning completely
|
||||||
|
// also trigger it just once, no need doing so on each entry
|
||||||
|
this.onHistoryChangedEmitter.trigger(
|
||||||
|
new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static pop(stack: HistoryStack): HistoryEntry | null {
|
||||||
|
if (!stack.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = stack.pop();
|
||||||
|
|
||||||
|
if (entry !== undefined) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static push(
|
||||||
|
stack: HistoryStack,
|
||||||
|
entry: HistoryEntry,
|
||||||
|
prevElements: SceneElementsMap,
|
||||||
|
) {
|
||||||
|
const updatedEntry = entry.inverse().applyLatestChanges(prevElements);
|
||||||
|
return stack.push(updatedEntry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default History;
|
export class HistoryEntry {
|
||||||
|
private constructor(
|
||||||
|
public readonly appStateChange: AppStateChange,
|
||||||
|
public readonly elementsChange: ElementsChange,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
appStateChange: AppStateChange,
|
||||||
|
elementsChange: ElementsChange,
|
||||||
|
) {
|
||||||
|
return new HistoryEntry(appStateChange, elementsChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
public inverse(): HistoryEntry {
|
||||||
|
return new HistoryEntry(
|
||||||
|
this.appStateChange.inverse(),
|
||||||
|
this.elementsChange.inverse(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyTo(
|
||||||
|
elements: SceneElementsMap,
|
||||||
|
appState: AppState,
|
||||||
|
snapshot: Readonly<Snapshot>,
|
||||||
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
|
const [nextElements, elementsContainVisibleChange] =
|
||||||
|
this.elementsChange.applyTo(elements, snapshot.elements);
|
||||||
|
|
||||||
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
|
this.appStateChange.applyTo(appState, nextElements);
|
||||||
|
|
||||||
|
const appliedVisibleChanges =
|
||||||
|
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||||
|
|
||||||
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
|
||||||
|
*/
|
||||||
|
public applyLatestChanges(elements: SceneElementsMap): HistoryEntry {
|
||||||
|
const updatedElementsChange =
|
||||||
|
this.elementsChange.applyLatestChanges(elements);
|
||||||
|
|
||||||
|
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isEmpty(): boolean {
|
||||||
|
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useLayoutEffect } from "react";
|
import { useState, useLayoutEffect } from "react";
|
||||||
import { useDevice, useExcalidrawContainer } from "../components/App";
|
import { useDevice, useExcalidrawContainer } from "../components/App";
|
||||||
|
import { THEME } from "../constants";
|
||||||
import { useUIAppState } from "../context/ui-appState";
|
import { useUIAppState } from "../context/ui-appState";
|
||||||
|
|
||||||
export const useCreatePortalContainer = (opts?: {
|
export const useCreatePortalContainer = (opts?: {
|
||||||
@@ -18,7 +19,7 @@ export const useCreatePortalContainer = (opts?: {
|
|||||||
div.className = "";
|
div.className = "";
|
||||||
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
|
||||||
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
|
div.classList.toggle("excalidraw--mobile", device.editor.isMobile);
|
||||||
div.classList.toggle("theme--dark", theme === "dark");
|
div.classList.toggle("theme--dark", theme === THEME.DARK);
|
||||||
}
|
}
|
||||||
}, [div, theme, device.editor.isMobile, opts?.className]);
|
}, [div, theme, device.editor.isMobile, opts?.className]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Emitter } from "../emitter";
|
||||||
|
|
||||||
|
export const useEmitter = <TEvent extends unknown>(
|
||||||
|
emitter: Emitter<[TEvent]>,
|
||||||
|
initialState: TEvent,
|
||||||
|
) => {
|
||||||
|
const [event, setEvent] = useState<TEvent>(initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = emitter.on((event) => {
|
||||||
|
setEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [emitter]);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
};
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"group": "Group selection",
|
"group": "Group selection",
|
||||||
"ungroup": "Ungroup selection",
|
"ungroup": "Ungroup selection",
|
||||||
"collaborators": "Collaborators",
|
"collaborators": "Collaborators",
|
||||||
"showGrid": "Show grid",
|
"toggleGrid": "Toggle grid",
|
||||||
"addToLibrary": "Add to library",
|
"addToLibrary": "Add to library",
|
||||||
"removeFromLibrary": "Remove from library",
|
"removeFromLibrary": "Remove from library",
|
||||||
"libraryLoadingMessage": "Loading library…",
|
"libraryLoadingMessage": "Loading library…",
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
"showStroke": "Show stroke color picker",
|
"showStroke": "Show stroke color picker",
|
||||||
"showBackground": "Show background color picker",
|
"showBackground": "Show background color picker",
|
||||||
"toggleTheme": "Toggle light/dark theme",
|
"toggleTheme": "Toggle light/dark theme",
|
||||||
|
"theme": "Theme",
|
||||||
"personalLib": "Personal Library",
|
"personalLib": "Personal Library",
|
||||||
"excalidrawLib": "Excalidraw Library",
|
"excalidrawLib": "Excalidraw Library",
|
||||||
"decreaseFontSize": "Decrease font size",
|
"decreaseFontSize": "Decrease font size",
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
"fullScreen": "Full screen",
|
"fullScreen": "Full screen",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"lightMode": "Light mode",
|
"lightMode": "Light mode",
|
||||||
|
"systemMode": "System mode",
|
||||||
"zenMode": "Zen mode",
|
"zenMode": "Zen mode",
|
||||||
"objectsSnapMode": "Snap to objects",
|
"objectsSnapMode": "Snap to objects",
|
||||||
"exitZenMode": "Exit zen mode",
|
"exitZenMode": "Exit zen mode",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
|
"fractional-indexing": "3.2.0",
|
||||||
"fuzzy": "0.1.3",
|
"fuzzy": "0.1.3",
|
||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "1.13.1",
|
"jotai": "1.13.1",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user