Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 027ef1d641 | |||
| d9bbf1eda6 | |||
| f79fb9aae2 | |||
| 275f6fbe24 | |||
| 88812e0386 | |||
| 6e5aeb112d | |||
| 4d83d1c91e | |||
| a04676d423 | |||
| c851aaaf7b | |||
| 1bd2b1fe55 | |||
| 015b46ab23 |
@@ -115,7 +115,7 @@ function App() {
|
|||||||
<button className="custom-button" onClick={updateScene}>
|
<button className="custom-button" onClick={updateScene}>
|
||||||
Update Scene
|
Update Scene
|
||||||
</button>
|
</button>
|
||||||
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ function App() {
|
|||||||
Update Library
|
Update Library
|
||||||
</button>
|
</button>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
|
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
|
||||||
initialData={{
|
initialData={{
|
||||||
libraryItems: initialData.libraryItems,
|
libraryItems: initialData.libraryItems,
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialog,
|
TTDDialog,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
} from "../packages/excalidraw/index";
|
StoreAction,
|
||||||
|
reconcileElements,
|
||||||
|
} from "../packages/excalidraw";
|
||||||
import {
|
import {
|
||||||
AppState,
|
AppState,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
@@ -106,10 +108,7 @@ 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 {
|
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
||||||
RemoteExcalidrawElement,
|
|
||||||
reconcileElements,
|
|
||||||
} from "../packages/excalidraw/data/reconcile";
|
|
||||||
import {
|
import {
|
||||||
CommandPalette,
|
CommandPalette,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
@@ -438,7 +437,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...data.scene,
|
...data.scene,
|
||||||
...restore(data.scene, null, null, { repairBindings: true }),
|
...restore(data.scene, null, null, { repairBindings: true }),
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -469,6 +468,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
setLangCode(langCode);
|
setLangCode(langCode);
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
...localDataState,
|
...localDataState,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
LibraryIndexedDBAdapter.load().then((data) => {
|
LibraryIndexedDBAdapter.load().then((data) => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -604,6 +604,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (didChange) {
|
if (didChange) {
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import {
|
import {
|
||||||
|
StoreAction,
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
restoreElements,
|
restoreElements,
|
||||||
zoomToFitBounds,
|
zoomToFitBounds,
|
||||||
} from "../../packages/excalidraw/index";
|
reconcileElements,
|
||||||
|
} from "../../packages/excalidraw";
|
||||||
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
assertNever,
|
assertNever,
|
||||||
@@ -79,10 +81,9 @@ 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 {
|
import type {
|
||||||
ReconciledExcalidrawElement,
|
ReconciledExcalidrawElement,
|
||||||
RemoteExcalidrawElement,
|
RemoteExcalidrawElement,
|
||||||
reconcileElements,
|
|
||||||
} from "../../packages/excalidraw/data/reconcile";
|
} from "../../packages/excalidraw/data/reconcile";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
@@ -356,6 +357,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -506,6 +508,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
// to database even if deleted before creating the room.
|
// to database even if deleted before creating the room.
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
@@ -743,6 +746,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
) => {
|
) => {
|
||||||
this.excalidrawAPI.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.loadImageFiles();
|
this.loadImageFiles();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import throttle from "lodash.throttle";
|
|||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
import { encryptData } from "../../packages/excalidraw/data/encryption";
|
||||||
import type { Socket } from "socket.io-client";
|
import type { Socket } from "socket.io-client";
|
||||||
|
import { StoreAction } from "../../packages/excalidraw";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: TCollabClass;
|
collab: TCollabClass;
|
||||||
@@ -127,6 +128,7 @@ class Portal {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}, FILE_UPLOAD_TIMEOUT);
|
}, FILE_UPLOAD_TIMEOUT);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { StoreAction } from "../../packages/excalidraw";
|
||||||
import { compressData } from "../../packages/excalidraw/data/encode";
|
import { compressData } from "../../packages/excalidraw/data/encode";
|
||||||
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
@@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
|
|||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { reconcileElements } from "../../packages/excalidraw";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
@@ -22,10 +23,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
|||||||
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 {
|
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
|
||||||
RemoteExcalidrawElement,
|
|
||||||
reconcileElements,
|
|
||||||
} from "../../packages/excalidraw/data/reconcile";
|
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -269,7 +269,6 @@ export const loadScene = async (
|
|||||||
// in the scene database/localStorage, and instead fetch them async
|
// in the scene database/localStorage, and instead fetch them async
|
||||||
// from a different database
|
// from a different database
|
||||||
files: data.files,
|
files: data.files,
|
||||||
commitToStore: false,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "../../packages/excalidraw/actions/actionHistory";
|
} from "../../packages/excalidraw/actions/actionHistory";
|
||||||
import { newElementWith } from "../../packages/excalidraw";
|
import { StoreAction, newElementWith } from "../../packages/excalidraw";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ describe("collaboration", () => {
|
|||||||
|
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1, rect2]),
|
elements: syncInvalidIndices([rect1, rect2]),
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
@@ -98,7 +98,7 @@ describe("collaboration", () => {
|
|||||||
rect1,
|
rect1,
|
||||||
newElementWith(h.elements[1], { isDeleted: true }),
|
newElementWith(h.elements[1], { isDeleted: true }),
|
||||||
]),
|
]),
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -145,6 +145,7 @@ describe("collaboration", () => {
|
|||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -182,7 +183,7 @@ describe("collaboration", () => {
|
|||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { x: 100 }),
|
newElementWith(h.elements[1], { x: 100 }),
|
||||||
]),
|
]),
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -217,6 +218,7 @@ describe("collaboration", () => {
|
|||||||
// simulate force deleting the element remotely
|
// simulate force deleting the element remotely
|
||||||
updateSceneData({
|
updateSceneData({
|
||||||
elements: syncInvalidIndices([rect1]),
|
elements: syncInvalidIndices([rect1]),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// snapshot was correctly updated and marked the element as deleted
|
// snapshot was correctly updated and marked the element as deleted
|
||||||
|
|||||||
+4
-4
@@ -26,8 +26,8 @@
|
|||||||
"@types/chai": "4.3.0",
|
"@types/chai": "4.3.0",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "27.4.0",
|
||||||
"@types/lodash.throttle": "4.1.7",
|
"@types/lodash.throttle": "4.1.7",
|
||||||
"@types/react": "18.0.15",
|
"@types/react": "18.2.0",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.2.0",
|
||||||
"@types/socket.io-client": "3.0.0",
|
"@types/socket.io-client": "3.0.0",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "3.1.0",
|
||||||
"@vitest/coverage-v8": "0.33.0",
|
"@vitest/coverage-v8": "0.33.0",
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
"vite-plugin-ejs": "1.7.0",
|
"vite-plugin-ejs": "1.7.0",
|
||||||
"vite-plugin-pwa": "0.17.4",
|
"vite-plugin-pwa": "0.17.4",
|
||||||
"vite-plugin-svgr": "2.4.0",
|
"vite-plugin-svgr": "2.4.0",
|
||||||
"vitest": "1.0.1",
|
"vitest": "1.5.3",
|
||||||
"vitest-canvas-mock": "0.3.2"
|
"vitest-canvas-mock": "0.3.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18.0.0 - 20.x.x"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
|
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
|
||||||
|
|
||||||
### Breaking Changes
|
| | Before `commitToHistory` | After `storeAction` | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
|
||||||
|
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
|
||||||
|
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
|
||||||
|
|
||||||
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { arrayToMap } from "../utils";
|
|||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getCommonBoundingBox } from "../element/bounds";
|
import { getCommonBoundingBox } from "../element/bounds";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindSelectedElements,
|
bindOrUnbindLinearElements,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
unbindLinearElements,
|
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@@ -89,7 +89,6 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const updatedElements = flipElements(
|
const updatedElements = flipElements(
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elements,
|
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState,
|
appState,
|
||||||
flipDirection,
|
flipDirection,
|
||||||
@@ -105,7 +104,6 @@ const flipSelectedElements = (
|
|||||||
|
|
||||||
const flipElements = (
|
const flipElements = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
@@ -119,13 +117,17 @@ const flipElements = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
"nw",
|
"nw",
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
flipDirection === "horizontal" ? maxX : minX,
|
flipDirection === "horizontal" ? maxX : minX,
|
||||||
flipDirection === "horizontal" ? minY : maxY,
|
flipDirection === "horizontal" ? minY : maxY,
|
||||||
);
|
);
|
||||||
|
|
||||||
isBindingEnabled(appState)
|
bindOrUnbindLinearElements(
|
||||||
? bindOrUnbindSelectedElements(selectedElements, app)
|
selectedElements.filter(isLinearElement),
|
||||||
: unbindLinearElements(selectedElements, elementsMap);
|
app,
|
||||||
|
isBindingEnabled(appState),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { KEYS } from "../keys";
|
|||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { isWindows } from "../constants";
|
import { isWindows } from "../constants";
|
||||||
import { SceneElementsMap } from "../element/types";
|
import { SceneElementsMap } from "../element/types";
|
||||||
import { IStore, StoreAction } from "../store";
|
import { Store, StoreAction } from "../store";
|
||||||
import { useEmitter } from "../hooks/useEmitter";
|
import { useEmitter } from "../hooks/useEmitter";
|
||||||
|
|
||||||
const writeData = (
|
const writeData = (
|
||||||
@@ -40,7 +40,7 @@ const writeData = (
|
|||||||
return { storeAction: StoreAction.NONE };
|
return { storeAction: StoreAction.NONE };
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionCreator = (history: History, store: IStore) => Action;
|
type ActionCreator = (history: History, store: Store) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
UIAppState,
|
UIAppState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MarkOptional } from "../utility-types";
|
import { MarkOptional } from "../utility-types";
|
||||||
import { StoreAction } from "../store";
|
import { StoreActionType } from "../store";
|
||||||
|
|
||||||
export type ActionSource =
|
export type ActionSource =
|
||||||
| "ui"
|
| "ui"
|
||||||
@@ -26,7 +26,7 @@ export type ActionResult =
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||||
> | null;
|
> | null;
|
||||||
files?: BinaryFiles | null;
|
files?: BinaryFiles | null;
|
||||||
storeAction: keyof typeof StoreAction;
|
storeAction: StoreActionType;
|
||||||
replaceFiles?: boolean;
|
replaceFiles?: boolean;
|
||||||
}
|
}
|
||||||
| false;
|
| false;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
EDITOR_LS_KEYS,
|
EDITOR_LS_KEYS,
|
||||||
isIOS,
|
isIOS,
|
||||||
supportsResizeObserver,
|
supportsResizeObserver,
|
||||||
|
DEFAULT_COLLISION_THRESHOLD,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
@@ -121,17 +122,16 @@ import {
|
|||||||
} from "../element";
|
} from "../element";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
bindOrUnbindSelectedElements,
|
bindOrUnbindLinearElements,
|
||||||
fixBindingsAfterDeletion,
|
fixBindingsAfterDeletion,
|
||||||
fixBindingsAfterDuplication,
|
fixBindingsAfterDuplication,
|
||||||
getEligibleElementsForBinding,
|
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
isLinearElementSimpleAndAlreadyBound,
|
isLinearElementSimpleAndAlreadyBound,
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
shouldEnableBindingForPointerEvent,
|
shouldEnableBindingForPointerEvent,
|
||||||
unbindLinearElements,
|
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
|
getSuggestedBindingsForArrows,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
@@ -183,7 +183,6 @@ import {
|
|||||||
ExcalidrawIframeElement,
|
ExcalidrawIframeElement,
|
||||||
ExcalidrawEmbeddableElement,
|
ExcalidrawEmbeddableElement,
|
||||||
Ordered,
|
Ordered,
|
||||||
OrderedExcalidrawElement,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import {
|
import {
|
||||||
@@ -412,7 +411,7 @@ import { ElementCanvasButton } from "./MagicButton";
|
|||||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||||
import FollowMode from "./FollowMode/FollowMode";
|
import FollowMode from "./FollowMode/FollowMode";
|
||||||
import { IStore, Store, StoreAction } from "../store";
|
import { Store, StoreAction } from "../store";
|
||||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||||
import { AnimatedTrail } from "../animated-trail";
|
import { AnimatedTrail } from "../animated-trail";
|
||||||
import { LaserTrails } from "../laser-trails";
|
import { LaserTrails } from "../laser-trails";
|
||||||
@@ -543,7 +542,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
public library: AppClassProperties["library"];
|
public library: AppClassProperties["library"];
|
||||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||||
public id: string;
|
public id: string;
|
||||||
private store: IStore;
|
private store: Store;
|
||||||
private history: History;
|
private history: History;
|
||||||
private excalidrawContainerValue: {
|
private excalidrawContainerValue: {
|
||||||
container: HTMLDivElement | null;
|
container: HTMLDivElement | null;
|
||||||
@@ -1704,6 +1703,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
scale={window.devicePixelRatio}
|
scale={window.devicePixelRatio}
|
||||||
appState={this.state}
|
appState={this.state}
|
||||||
|
device={this.device}
|
||||||
renderInteractiveSceneCallback={
|
renderInteractiveSceneCallback={
|
||||||
this.renderInteractiveSceneCallback
|
this.renderInteractiveSceneCallback
|
||||||
}
|
}
|
||||||
@@ -2123,7 +2123,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
}),
|
}),
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2810,7 +2810,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store.capture(elementsMap, this.state);
|
this.store.commit(elementsMap, this.state);
|
||||||
|
|
||||||
// Do not notify consumers if we're still loading the scene. Among other
|
// Do not notify consumers if we're still loading the scene. Among other
|
||||||
// potential issues, this fixes a case where the tab isn't focused during
|
// potential issues, this fixes a case where the tab isn't focused during
|
||||||
@@ -3683,51 +3683,39 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements?: SceneData["elements"];
|
elements?: SceneData["elements"];
|
||||||
appState?: Pick<AppState, K> | null;
|
appState?: Pick<AppState, K> | null;
|
||||||
collaborators?: SceneData["collaborators"];
|
collaborators?: SceneData["collaborators"];
|
||||||
commitToStore?: SceneData["commitToStore"];
|
/** @default StoreAction.CAPTURE */
|
||||||
|
storeAction?: SceneData["storeAction"];
|
||||||
}) => {
|
}) => {
|
||||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||||
|
|
||||||
if (sceneData.commitToStore) {
|
if (sceneData.storeAction && sceneData.storeAction !== StoreAction.NONE) {
|
||||||
this.store.shouldCaptureIncrement();
|
const prevCommittedAppState = this.store.snapshot.appState;
|
||||||
}
|
const prevCommittedElements = this.store.snapshot.elements;
|
||||||
|
|
||||||
if (sceneData.elements || sceneData.appState) {
|
const nextCommittedAppState = sceneData.appState
|
||||||
let nextCommittedAppState = this.state;
|
? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
|
||||||
let nextCommittedElements: Map<string, OrderedExcalidrawElement>;
|
: prevCommittedAppState;
|
||||||
|
|
||||||
if (sceneData.appState) {
|
const nextCommittedElements = sceneData.elements
|
||||||
nextCommittedAppState = {
|
? this.store.filterUncomittedElements(
|
||||||
...this.state,
|
this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
|
||||||
...sceneData.appState, // Here we expect just partial appState
|
arrayToMap(nextElements), // We expect all (already reconciled) elements
|
||||||
};
|
)
|
||||||
}
|
: prevCommittedElements;
|
||||||
|
|
||||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
// WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
|
||||||
|
// do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
|
||||||
if (sceneData.elements) {
|
if (sceneData.storeAction === StoreAction.CAPTURE) {
|
||||||
/**
|
this.store.captureIncrement(
|
||||||
* We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update),
|
nextCommittedElements,
|
||||||
* as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff).
|
nextCommittedAppState,
|
||||||
*
|
);
|
||||||
* This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true,
|
} else if (sceneData.storeAction === StoreAction.UPDATE) {
|
||||||
* as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history).
|
this.store.updateSnapshot(
|
||||||
*
|
nextCommittedElements,
|
||||||
* WARN: be careful here as moving it elsewhere could break the history for remote client without noticing
|
nextCommittedAppState,
|
||||||
* - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories.
|
|
||||||
*/
|
|
||||||
this.store.shouldUpdateSnapshot();
|
|
||||||
|
|
||||||
// TODO#7348: deprecate once exchanging just store increments between clients
|
|
||||||
nextCommittedElements = this.store.ignoreUncomittedElements(
|
|
||||||
arrayToMap(prevElements),
|
|
||||||
arrayToMap(nextElements),
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
nextCommittedElements = arrayToMap(prevElements);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
|
|
||||||
this.store.capture(nextCommittedElements, nextCommittedAppState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneData.appState) {
|
if (sceneData.appState) {
|
||||||
@@ -3949,7 +3937,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.maybeSuggestBindingForAll(selectedElements);
|
this.setState({
|
||||||
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
|
selectedElements,
|
||||||
|
this,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ENTER) {
|
||||||
@@ -4116,11 +4109,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ isBindingEnabled: true });
|
this.setState({ isBindingEnabled: true });
|
||||||
}
|
}
|
||||||
if (isArrowKey(event.key)) {
|
if (isArrowKey(event.key)) {
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
bindOrUnbindLinearElements(
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
this.scene.getSelectedElements(this.state).filter(isLinearElement),
|
||||||
isBindingEnabled(this.state)
|
this,
|
||||||
? bindOrUnbindSelectedElements(selectedElements, this)
|
isBindingEnabled(this.state),
|
||||||
: unbindLinearElements(selectedElements, elementsMap);
|
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||||
|
);
|
||||||
this.setState({ suggestedBindings: [] });
|
this.setState({ suggestedBindings: [] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -4179,6 +4173,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
originSnapOffset: null,
|
originSnapOffset: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
if (nextActiveTool.type === "freedraw") {
|
||||||
|
this.store.shouldCaptureIncrement();
|
||||||
|
}
|
||||||
|
|
||||||
if (nextActiveTool.type !== "selection") {
|
if (nextActiveTool.type !== "selection") {
|
||||||
return {
|
return {
|
||||||
...prevState,
|
...prevState,
|
||||||
@@ -4536,7 +4535,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
shape: this.getElementShape(elementWithHighestZIndex),
|
shape: this.getElementShape(elementWithHighestZIndex),
|
||||||
// when overlapping, we would like to be more precise
|
// when overlapping, we would like to be more precise
|
||||||
// this also avoids the need to update past tests
|
// this also avoids the need to update past tests
|
||||||
threshold: this.getHitThreshold() / 2,
|
threshold: this.getElementHitThreshold() / 2,
|
||||||
frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
|
frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
|
||||||
? this.frameNameBoundsCache.get(elementWithHighestZIndex)
|
? this.frameNameBoundsCache.get(elementWithHighestZIndex)
|
||||||
: null,
|
: null,
|
||||||
@@ -4599,8 +4598,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHitThreshold() {
|
private getElementHitThreshold() {
|
||||||
return 10 / this.state.zoom.value;
|
return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hitElement(
|
private hitElement(
|
||||||
@@ -4618,7 +4617,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const selectionShape = getSelectionBoxShape(
|
const selectionShape = getSelectionBoxShape(
|
||||||
element,
|
element,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.getHitThreshold(),
|
this.getElementHitThreshold(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return isPointInShape([x, y], selectionShape);
|
return isPointInShape([x, y], selectionShape);
|
||||||
@@ -4639,7 +4638,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y,
|
y,
|
||||||
element,
|
element,
|
||||||
shape: this.getElementShape(element),
|
shape: this.getElementShape(element),
|
||||||
threshold: this.getHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
frameNameBound: isFrameLikeElement(element)
|
frameNameBound: isFrameLikeElement(element)
|
||||||
? this.frameNameBoundsCache.get(element)
|
? this.frameNameBoundsCache.get(element)
|
||||||
: null,
|
: null,
|
||||||
@@ -4671,7 +4670,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y,
|
y,
|
||||||
element: elements[index],
|
element: elements[index],
|
||||||
shape: this.getElementShape(elements[index]),
|
shape: this.getElementShape(elements[index]),
|
||||||
threshold: this.getHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
hitElement = elements[index];
|
hitElement = elements[index];
|
||||||
@@ -4924,7 +4923,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y: sceneY,
|
y: sceneY,
|
||||||
element: container,
|
element: container,
|
||||||
shape: this.getElementShape(container),
|
shape: this.getElementShape(container),
|
||||||
threshold: this.getHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
const midPoint = getContainerCenter(
|
const midPoint = getContainerCenter(
|
||||||
@@ -5339,24 +5338,41 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!isOverScrollBar &&
|
!isOverScrollBar &&
|
||||||
!this.state.editingLinearElement
|
!this.state.editingLinearElement
|
||||||
) {
|
) {
|
||||||
const elementWithTransformHandleType = getElementWithTransformHandleType(
|
// for linear elements, we'd like to prioritize point dragging over edge resizing
|
||||||
elements,
|
// therefore, we update and check hovered point index first
|
||||||
this.state,
|
if (this.state.selectedLinearElement) {
|
||||||
scenePointerX,
|
this.handleHoverSelectedLinearElement(
|
||||||
scenePointerY,
|
this.state.selectedLinearElement,
|
||||||
this.state.zoom,
|
scenePointerX,
|
||||||
event.pointerType,
|
scenePointerY,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
elementWithTransformHandleType &&
|
|
||||||
elementWithTransformHandleType.transformHandleType
|
|
||||||
) {
|
|
||||||
setCursor(
|
|
||||||
this.interactiveCanvas,
|
|
||||||
getCursorForResizingElement(elementWithTransformHandleType),
|
|
||||||
);
|
);
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.state.selectedLinearElement ||
|
||||||
|
this.state.selectedLinearElement.hoverPointIndex === -1
|
||||||
|
) {
|
||||||
|
const elementWithTransformHandleType =
|
||||||
|
getElementWithTransformHandleType(
|
||||||
|
elements,
|
||||||
|
this.state,
|
||||||
|
scenePointerX,
|
||||||
|
scenePointerY,
|
||||||
|
this.state.zoom,
|
||||||
|
event.pointerType,
|
||||||
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.device,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
elementWithTransformHandleType &&
|
||||||
|
elementWithTransformHandleType.transformHandleType
|
||||||
|
) {
|
||||||
|
setCursor(
|
||||||
|
this.interactiveCanvas,
|
||||||
|
getCursorForResizingElement(elementWithTransformHandleType),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (selectedElements.length > 1 && !isOverScrollBar) {
|
} else if (selectedElements.length > 1 && !isOverScrollBar) {
|
||||||
const transformHandleType = getTransformHandleTypeFromCoords(
|
const transformHandleType = getTransformHandleTypeFromCoords(
|
||||||
@@ -5365,6 +5381,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
scenePointerY,
|
scenePointerY,
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
event.pointerType,
|
event.pointerType,
|
||||||
|
this.device,
|
||||||
);
|
);
|
||||||
if (transformHandleType) {
|
if (transformHandleType) {
|
||||||
setCursor(
|
setCursor(
|
||||||
@@ -5517,7 +5534,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
scenePointer.x,
|
scenePointer.x,
|
||||||
scenePointer.y,
|
scenePointer.y,
|
||||||
);
|
);
|
||||||
const threshold = this.getHitThreshold();
|
const threshold = this.getElementHitThreshold();
|
||||||
const point = { ...pointerDownState.lastCoords };
|
const point = { ...pointerDownState.lastCoords };
|
||||||
let samplingInterval = 0;
|
let samplingInterval = 0;
|
||||||
while (samplingInterval <= distance) {
|
while (samplingInterval <= distance) {
|
||||||
@@ -5614,7 +5631,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||||
} else {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
}
|
}
|
||||||
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
@@ -5704,6 +5721,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6313,7 +6331,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
|
|
||||||
if (selectedElements.length === 1 && !this.state.editingLinearElement) {
|
if (
|
||||||
|
selectedElements.length === 1 &&
|
||||||
|
!this.state.editingLinearElement &&
|
||||||
|
!(
|
||||||
|
this.state.selectedLinearElement &&
|
||||||
|
this.state.selectedLinearElement.hoverPointIndex !== -1
|
||||||
|
)
|
||||||
|
) {
|
||||||
const elementWithTransformHandleType =
|
const elementWithTransformHandleType =
|
||||||
getElementWithTransformHandleType(
|
getElementWithTransformHandleType(
|
||||||
elements,
|
elements,
|
||||||
@@ -6323,6 +6348,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
event.pointerType,
|
event.pointerType,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
|
this.device,
|
||||||
);
|
);
|
||||||
if (elementWithTransformHandleType != null) {
|
if (elementWithTransformHandleType != null) {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -6338,6 +6364,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.origin.y,
|
pointerDownState.origin.y,
|
||||||
this.state.zoom,
|
this.state.zoom,
|
||||||
event.pointerType,
|
event.pointerType,
|
||||||
|
this.device,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pointerDownState.resize.handleType) {
|
if (pointerDownState.resize.handleType) {
|
||||||
@@ -6594,7 +6621,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// How many pixels off the shape boundary we still consider a hit
|
// How many pixels off the shape boundary we still consider a hit
|
||||||
const threshold = this.getHitThreshold();
|
const threshold = this.getElementHitThreshold();
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
|
||||||
return (
|
return (
|
||||||
point.x > x1 - threshold &&
|
point.x > x1 - threshold &&
|
||||||
@@ -7431,7 +7458,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
event[KEYS.CTRL_OR_CMD] ? null : this.state.gridSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.maybeSuggestBindingForAll(selectedElements);
|
this.setState({
|
||||||
|
suggestedBindings: getSuggestedBindingsForArrows(
|
||||||
|
selectedElements,
|
||||||
|
this,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
// We duplicate the selected element if alt is pressed on pointer move
|
// We duplicate the selected element if alt is pressed on pointer move
|
||||||
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
|
if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
|
||||||
@@ -7993,6 +8025,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
appState: {
|
appState: {
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
},
|
},
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -8165,6 +8198,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
elements: this.scene
|
elements: this.scene
|
||||||
.getElementsIncludingDeleted()
|
.getElementsIncludingDeleted()
|
||||||
.filter((el) => el.id !== resizingElement.id),
|
.filter((el) => el.id !== resizingElement.id),
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8417,7 +8451,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
y: pointerDownState.origin.y,
|
y: pointerDownState.origin.y,
|
||||||
element: hitElement,
|
element: hitElement,
|
||||||
shape: this.getElementShape(hitElement),
|
shape: this.getElementShape(hitElement),
|
||||||
threshold: this.getHitThreshold(),
|
threshold: this.getElementHitThreshold(),
|
||||||
frameNameBound: isFrameLikeElement(hitElement)
|
frameNameBound: isFrameLikeElement(hitElement)
|
||||||
? this.frameNameBoundsCache.get(hitElement)
|
? this.frameNameBoundsCache.get(hitElement)
|
||||||
: null,
|
: null,
|
||||||
@@ -8476,15 +8510,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
|
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
|
||||||
isBindingEnabled(this.state)
|
// We only allow binding via linear elements, specifically via dragging
|
||||||
? bindOrUnbindSelectedElements(
|
// the endpoints ("start" or "end").
|
||||||
this.scene.getSelectedElements(this.state),
|
const linearElements = this.scene
|
||||||
this,
|
.getSelectedElements(this.state)
|
||||||
)
|
.filter(isLinearElement);
|
||||||
: unbindLinearElements(
|
|
||||||
this.scene.getNonDeletedElements(),
|
bindOrUnbindLinearElements(
|
||||||
elementsMap,
|
linearElements,
|
||||||
);
|
this,
|
||||||
|
isBindingEnabled(this.state),
|
||||||
|
this.state.selectedLinearElement?.selectedPointsIndices ?? [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTool.type === "laser") {
|
if (activeTool.type === "laser") {
|
||||||
@@ -9016,19 +9053,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ suggestedBindings });
|
this.setState({ suggestedBindings });
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeSuggestBindingForAll(
|
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
|
||||||
): void {
|
|
||||||
if (selectedElements.length > 50) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const suggestedBindings = getEligibleElementsForBinding(
|
|
||||||
selectedElements,
|
|
||||||
this,
|
|
||||||
);
|
|
||||||
this.setState({ suggestedBindings });
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearSelection(hitElement: ExcalidrawElement | null): void {
|
private clearSelection(hitElement: ExcalidrawElement | null): void {
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
selectedElementIds: makeNextSelectedElementIds({}, prevState),
|
selectedElementIds: makeNextSelectedElementIds({}, prevState),
|
||||||
@@ -9228,13 +9252,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ret.type === MIME_TYPES.excalidraw) {
|
if (ret.type === MIME_TYPES.excalidraw) {
|
||||||
// Restore the fractional indices by mutating elements and update the
|
// restore the fractional indices by mutating elements
|
||||||
// store snapshot, otherwise we would end up with duplicate indices
|
|
||||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||||
this.store.snapshot = this.store.snapshot.clone(
|
|
||||||
arrayToMap(elements),
|
// update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
|
||||||
this.state,
|
this.store.updateSnapshot(arrayToMap(elements), this.state);
|
||||||
);
|
|
||||||
this.setState({ isLoading: true });
|
this.setState({ isLoading: true });
|
||||||
this.syncActionResult({
|
this.syncActionResult({
|
||||||
...ret.data,
|
...ret.data,
|
||||||
@@ -9416,8 +9439,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.originSnapOffset,
|
this.state.originSnapOffset,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.maybeSuggestBindingForAll([draggingElement]);
|
|
||||||
|
|
||||||
// highlight elements that are to be added to frames on frames creation
|
// highlight elements that are to be added to frames on frames creation
|
||||||
if (
|
if (
|
||||||
this.state.activeTool.type === TOOL_TYPE.frame ||
|
this.state.activeTool.type === TOOL_TYPE.frame ||
|
||||||
@@ -9531,7 +9552,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getElementsMapIncludingDeleted(),
|
this.scene.getElementsMapIncludingDeleted(),
|
||||||
shouldRotateWithDiscreteAngle(event),
|
shouldRotateWithDiscreteAngle(event),
|
||||||
shouldResizeFromCenter(event),
|
shouldResizeFromCenter(event),
|
||||||
selectedElements.length === 1 && isImageElement(selectedElements[0])
|
selectedElements.some((element) => isImageElement(element))
|
||||||
? !shouldMaintainAspectRatio(event)
|
? !shouldMaintainAspectRatio(event)
|
||||||
: shouldMaintainAspectRatio(event),
|
: shouldMaintainAspectRatio(event),
|
||||||
resizeX,
|
resizeX,
|
||||||
@@ -9540,7 +9561,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.resize.center.y,
|
pointerDownState.resize.center.y,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.maybeSuggestBindingForAll(selectedElements);
|
const suggestedBindings = getSuggestedBindingsForArrows(
|
||||||
|
selectedElements,
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
|
||||||
const elementsToHighlight = new Set<ExcalidrawElement>();
|
const elementsToHighlight = new Set<ExcalidrawElement>();
|
||||||
selectedFrames.forEach((frame) => {
|
selectedFrames.forEach((frame) => {
|
||||||
@@ -9554,6 +9578,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
elementsToHighlight: [...elementsToHighlight],
|
elementsToHighlight: [...elementsToHighlight],
|
||||||
|
suggestedBindings,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.SVGLayer {
|
.SVGLayer {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -7,7 +9,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
z-index: 2;
|
z-index: var(--zIndex-svgLayer);
|
||||||
|
|
||||||
& svg {
|
& svg {
|
||||||
image-rendering: auto;
|
image-rendering: auto;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
|
|||||||
import { CURSOR_TYPE } from "../../constants";
|
import { CURSOR_TYPE } from "../../constants";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import type { DOMAttributes } from "react";
|
import type { DOMAttributes } from "react";
|
||||||
import type { AppState, InteractiveCanvasAppState } from "../../types";
|
import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
|
||||||
import type {
|
import type {
|
||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
RenderableElementsMap,
|
RenderableElementsMap,
|
||||||
@@ -23,6 +23,7 @@ type InteractiveCanvasProps = {
|
|||||||
selectionNonce: number | undefined;
|
selectionNonce: number | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
appState: InteractiveCanvasAppState;
|
appState: InteractiveCanvasAppState;
|
||||||
|
device: Device;
|
||||||
renderInteractiveSceneCallback: (
|
renderInteractiveSceneCallback: (
|
||||||
data: RenderInteractiveSceneCallback,
|
data: RenderInteractiveSceneCallback,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -132,6 +133,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||||||
selectionColor,
|
selectionColor,
|
||||||
renderScrollbars: false,
|
renderScrollbars: false,
|
||||||
},
|
},
|
||||||
|
device: props.device,
|
||||||
callback: props.renderInteractiveSceneCallback,
|
callback: props.renderInteractiveSceneCallback,
|
||||||
},
|
},
|
||||||
isRenderThrottlingEnabled(),
|
isRenderThrottlingEnabled(),
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
|
|||||||
export const DEFAULT_VERSION = "{version}";
|
export const DEFAULT_VERSION = "{version}";
|
||||||
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
||||||
|
|
||||||
|
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||||
|
// a small epsilon to make side resizing always take precedence
|
||||||
|
// (avoids an increase in renders and changes to tests)
|
||||||
|
const EPSILON = 0.00001;
|
||||||
|
export const DEFAULT_COLLISION_THRESHOLD =
|
||||||
|
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
|
||||||
|
|
||||||
export const COLOR_WHITE = "#ffffff";
|
export const COLOR_WHITE = "#ffffff";
|
||||||
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
||||||
// keep this in sync with CSS
|
// keep this in sync with CSS
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--zIndex-canvas: 1;
|
--zIndex-canvas: 1;
|
||||||
--zIndex-interactiveCanvas: 2;
|
--zIndex-interactiveCanvas: 2;
|
||||||
|
--zIndex-svgLayer: 3;
|
||||||
--zIndex-wysiwyg: 3;
|
--zIndex-wysiwyg: 3;
|
||||||
--zIndex-canvasButtons: 3;
|
--zIndex-canvasButtons: 3;
|
||||||
--zIndex-layerUI: 4;
|
--zIndex-layerUI: 4;
|
||||||
|
|||||||
@@ -131,73 +131,193 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
if (bindableElement !== "keep") {
|
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
||||||
if (bindableElement != null) {
|
if (bindableElement === "keep") {
|
||||||
// Don't bind if we're trying to bind or are already bound to the same
|
return;
|
||||||
// element on the other edge already ("start" edge takes precedence).
|
}
|
||||||
if (
|
|
||||||
otherEdgeBindableElement == null ||
|
// null means break the bind, so nothing to consider here
|
||||||
(otherEdgeBindableElement === "keep"
|
if (bindableElement === null) {
|
||||||
? !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
const unbound = unbindLinearElement(linearElement, startOrEnd);
|
||||||
linearElement,
|
if (unbound != null) {
|
||||||
bindableElement,
|
unboundFromElementIds.add(unbound);
|
||||||
startOrEnd,
|
|
||||||
)
|
|
||||||
: startOrEnd === "start" ||
|
|
||||||
otherEdgeBindableElement.id !== bindableElement.id)
|
|
||||||
) {
|
|
||||||
bindLinearElement(
|
|
||||||
linearElement,
|
|
||||||
bindableElement,
|
|
||||||
startOrEnd,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
boundToElementIds.add(bindableElement.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const unbound = unbindLinearElement(linearElement, startOrEnd);
|
|
||||||
if (unbound != null) {
|
|
||||||
unboundFromElementIds.add(unbound);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While complext arrows can do anything, simple arrow with both ends trying
|
||||||
|
// to bind to the same bindable should not be allowed, start binding takes
|
||||||
|
// precedence
|
||||||
|
if (isLinearElementSimple(linearElement)) {
|
||||||
|
if (
|
||||||
|
otherEdgeBindableElement == null ||
|
||||||
|
(otherEdgeBindableElement === "keep"
|
||||||
|
? // TODO: Refactor - Needlessly complex
|
||||||
|
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||||
|
linearElement,
|
||||||
|
bindableElement,
|
||||||
|
startOrEnd,
|
||||||
|
)
|
||||||
|
: startOrEnd === "start" ||
|
||||||
|
otherEdgeBindableElement.id !== bindableElement.id)
|
||||||
|
) {
|
||||||
|
bindLinearElement(
|
||||||
|
linearElement,
|
||||||
|
bindableElement,
|
||||||
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
boundToElementIds.add(bindableElement.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap);
|
||||||
|
boundToElementIds.add(bindableElement.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bindOrUnbindSelectedElements = (
|
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
edge: "start" | "end",
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
|
): NonDeleted<ExcalidrawElement> | null => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||||
|
const elementId =
|
||||||
|
edge === "start"
|
||||||
|
? linearElement.startBinding?.elementId
|
||||||
|
: linearElement.endBinding?.elementId;
|
||||||
|
if (elementId) {
|
||||||
|
const element = elementsMap.get(
|
||||||
|
elementId,
|
||||||
|
) as NonDeleted<ExcalidrawBindableElement>;
|
||||||
|
if (bindingBorderTest(element, coors, app)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
app: AppClassProperties,
|
||||||
|
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||||
|
["start", "end"].map((edge) =>
|
||||||
|
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||||
|
linearElement,
|
||||||
|
edge as "start" | "end",
|
||||||
|
app,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||||
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
isBindingEnabled: boolean,
|
||||||
|
draggingPoints: readonly number[],
|
||||||
|
app: AppClassProperties,
|
||||||
|
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||||
|
const startIdx = 0;
|
||||||
|
const endIdx = selectedElement.points.length - 1;
|
||||||
|
const startDragged = draggingPoints.findIndex((i) => i === startIdx) > -1;
|
||||||
|
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||||
|
const start = startDragged
|
||||||
|
? isBindingEnabled
|
||||||
|
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||||
|
: null // If binding is disabled and start is dragged, break all binds
|
||||||
|
: // We have to update the focus and gap of the binding, so let's rebind
|
||||||
|
getElligibleElementForBindingElement(selectedElement, "start", app);
|
||||||
|
const end = endDragged
|
||||||
|
? isBindingEnabled
|
||||||
|
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||||
|
: null // If binding is disabled and end is dragged, break all binds
|
||||||
|
: // We have to update the focus and gap of the binding, so let's rebind
|
||||||
|
getElligibleElementForBindingElement(selectedElement, "end", app);
|
||||||
|
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBindingStrategyForDraggingArrowOrJoints = (
|
||||||
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
app: AppClassProperties,
|
||||||
|
isBindingEnabled: boolean,
|
||||||
|
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||||
|
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
||||||
|
selectedElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
const start = startIsClose
|
||||||
|
? isBindingEnabled
|
||||||
|
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
const end = endIsClose
|
||||||
|
? isBindingEnabled
|
||||||
|
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [start, end];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bindOrUnbindLinearElements = (
|
||||||
|
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
|
||||||
|
app: AppClassProperties,
|
||||||
|
isBindingEnabled: boolean,
|
||||||
|
draggingPoints: readonly number[] | null,
|
||||||
): void => {
|
): void => {
|
||||||
selectedElements.forEach((selectedElement) => {
|
selectedElements.forEach((selectedElement) => {
|
||||||
if (isBindingElement(selectedElement)) {
|
const [start, end] = draggingPoints?.length
|
||||||
bindOrUnbindLinearElement(
|
? // The arrow edge points are dragged (i.e. start, end)
|
||||||
selectedElement,
|
getBindingStrategyForDraggingArrowEndpoints(
|
||||||
getElligibleElementForBindingElement(selectedElement, "start", app),
|
selectedElement,
|
||||||
getElligibleElementForBindingElement(selectedElement, "end", app),
|
isBindingEnabled,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
draggingPoints ?? [],
|
||||||
);
|
app,
|
||||||
} else if (isBindableElement(selectedElement)) {
|
)
|
||||||
maybeBindBindableElement(
|
: // The arrow itself (the shaft) or the inner joins are dragged
|
||||||
selectedElement,
|
getBindingStrategyForDraggingArrowOrJoints(
|
||||||
app.scene.getNonDeletedElementsMap(),
|
selectedElement,
|
||||||
app,
|
app,
|
||||||
);
|
isBindingEnabled,
|
||||||
}
|
);
|
||||||
|
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
selectedElement,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const maybeBindBindableElement = (
|
export const getSuggestedBindingsForArrows = (
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): void => {
|
): SuggestedBinding[] => {
|
||||||
getElligibleElementsForBindableElementAndWhere(bindableElement, app).forEach(
|
// HOT PATH: Bail out if selected elements list is too large
|
||||||
([linearElement, where]) =>
|
if (selectedElements.length > 50) {
|
||||||
bindOrUnbindLinearElement(
|
return [];
|
||||||
linearElement,
|
}
|
||||||
where === "end" ? "keep" : bindableElement,
|
|
||||||
where === "start" ? "keep" : bindableElement,
|
return (
|
||||||
elementsMap,
|
selectedElements
|
||||||
),
|
.filter(isLinearElement)
|
||||||
|
.flatMap((element) =>
|
||||||
|
getOriginalBindingsIfStillCloseToArrowEnds(element, app),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||||
|
element !== null,
|
||||||
|
)
|
||||||
|
// Filter out bind candidates which are in the
|
||||||
|
// same selection / group with the arrow
|
||||||
|
//
|
||||||
|
// TODO: Is it worth turning the list into a set to avoid dupes?
|
||||||
|
.filter(
|
||||||
|
(element) =>
|
||||||
|
selectedElements.filter((selected) => selected.id === element?.id)
|
||||||
|
.length === 0,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,20 +403,14 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
|||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return (
|
return (
|
||||||
alreadyBoundToId === bindableElement.id && linearElement.points.length < 3
|
alreadyBoundToId === bindableElement.id &&
|
||||||
|
isLinearElementSimple(linearElement)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const unbindLinearElements = (
|
const isLinearElementSimple = (
|
||||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
): boolean => linearElement.points.length < 3;
|
||||||
): void => {
|
|
||||||
elements.forEach((element) => {
|
|
||||||
if (isBindingElement(element)) {
|
|
||||||
bindOrUnbindLinearElement(element, null, null, elementsMap);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const unbindLinearElement = (
|
const unbindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@@ -543,42 +657,6 @@ const maybeCalculateNewGapWhenScaling = (
|
|||||||
return { elementId, gap: newGap, focus };
|
return { elementId, gap: newGap, focus };
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: this is a bottleneck, optimise
|
|
||||||
export const getEligibleElementsForBinding = (
|
|
||||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
|
||||||
app: AppClassProperties,
|
|
||||||
): SuggestedBinding[] => {
|
|
||||||
const includedElementIds = new Set(selectedElements.map(({ id }) => id));
|
|
||||||
return selectedElements.flatMap((selectedElement) =>
|
|
||||||
isBindingElement(selectedElement, false)
|
|
||||||
? (getElligibleElementsForBindingElement(
|
|
||||||
selectedElement as NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
app,
|
|
||||||
).filter(
|
|
||||||
(element) => !includedElementIds.has(element.id),
|
|
||||||
) as SuggestedBinding[])
|
|
||||||
: isBindableElement(selectedElement, false)
|
|
||||||
? getElligibleElementsForBindableElementAndWhere(
|
|
||||||
selectedElement,
|
|
||||||
app,
|
|
||||||
).filter((binding) => !includedElementIds.has(binding[0].id))
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getElligibleElementsForBindingElement = (
|
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
app: AppClassProperties,
|
|
||||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
|
||||||
return [
|
|
||||||
getElligibleElementForBindingElement(linearElement, "start", app),
|
|
||||||
getElligibleElementForBindingElement(linearElement, "end", app),
|
|
||||||
].filter(
|
|
||||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
|
||||||
element != null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getElligibleElementForBindingElement = (
|
const getElligibleElementForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
@@ -609,67 +687,6 @@ const getLinearElementEdgeCoors = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElligibleElementsForBindableElementAndWhere = (
|
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
|
||||||
app: AppClassProperties,
|
|
||||||
): SuggestedPointBinding[] => {
|
|
||||||
const scene = Scene.getScene(bindableElement)!;
|
|
||||||
return scene
|
|
||||||
.getNonDeletedElements()
|
|
||||||
.map((element) => {
|
|
||||||
if (!isBindingElement(element, false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
|
||||||
element,
|
|
||||||
"start",
|
|
||||||
bindableElement,
|
|
||||||
scene.getNonDeletedElementsMap(),
|
|
||||||
app,
|
|
||||||
);
|
|
||||||
const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
|
|
||||||
element,
|
|
||||||
"end",
|
|
||||||
bindableElement,
|
|
||||||
scene.getNonDeletedElementsMap(),
|
|
||||||
app,
|
|
||||||
);
|
|
||||||
if (!canBindStart && !canBindEnd) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
element,
|
|
||||||
canBindStart && canBindEnd ? "both" : canBindStart ? "start" : "end",
|
|
||||||
bindableElement,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
.filter((maybeElement) => maybeElement != null) as SuggestedPointBinding[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLinearElementEligibleForNewBindingByBindable = (
|
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
startOrEnd: "start" | "end",
|
|
||||||
bindableElement: NonDeleted<ExcalidrawBindableElement>,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
app: AppClassProperties,
|
|
||||||
): boolean => {
|
|
||||||
const existingBinding =
|
|
||||||
linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
|
|
||||||
return (
|
|
||||||
existingBinding == null &&
|
|
||||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
|
||||||
linearElement,
|
|
||||||
bindableElement,
|
|
||||||
startOrEnd,
|
|
||||||
) &&
|
|
||||||
bindingBorderTest(
|
|
||||||
bindableElement,
|
|
||||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
|
||||||
app,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// We need to:
|
// We need to:
|
||||||
// 1: Update elements not selected to point to duplicated elements
|
// 1: Update elements not selected to point to duplicated elements
|
||||||
// 2: Update duplicated elements to point to other duplicated elements
|
// 2: Update duplicated elements to point to other duplicated elements
|
||||||
@@ -805,7 +822,7 @@ const newBoundElements = (
|
|||||||
return nextBoundElements;
|
return nextBoundElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bindingBorderTest = (
|
const bindingBorderTest = (
|
||||||
element: NonDeleted<ExcalidrawBindableElement>,
|
element: NonDeleted<ExcalidrawBindableElement>,
|
||||||
{ x, y }: { x: number; y: number },
|
{ x, y }: { x: number; y: number },
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
@@ -827,7 +844,7 @@ export const maxBindingGap = (
|
|||||||
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
|
return Math.max(16, Math.min(0.25 * smallerDimension, 32));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
const distanceToBindableElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@@ -884,7 +901,7 @@ const distanceToDiamond = (
|
|||||||
return GAPoint.distanceToLine(pointRel, side);
|
return GAPoint.distanceToLine(pointRel, side);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const distanceToEllipse = (
|
const distanceToEllipse = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@@ -1003,7 +1020,7 @@ const coordsCenter = (
|
|||||||
// all focus points lie, so it's a number between -1 and 1.
|
// all focus points lie, so it's a number between -1 and 1.
|
||||||
// The line going through `a` and `b` is a tangent to the "focus image"
|
// The line going through `a` and `b` is a tangent to the "focus image"
|
||||||
// of the element.
|
// of the element.
|
||||||
export const determineFocusDistance = (
|
const determineFocusDistance = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: Point,
|
a: Point,
|
||||||
@@ -1044,7 +1061,7 @@ export const determineFocusDistance = (
|
|||||||
return ret || 0;
|
return ret || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const determineFocusPoint = (
|
const determineFocusPoint = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
// The oriented, relative distance from the center of `element` of the
|
// The oriented, relative distance from the center of `element` of the
|
||||||
// returned focusPoint
|
// returned focusPoint
|
||||||
@@ -1084,7 +1101,7 @@ export const determineFocusPoint = (
|
|||||||
|
|
||||||
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
||||||
// and the `element`, in ascending order of distance from `a`.
|
// and the `element`, in ascending order of distance from `a`.
|
||||||
export const intersectElementWithLine = (
|
const intersectElementWithLine = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: Point,
|
a: Point,
|
||||||
@@ -1251,7 +1268,7 @@ const getEllipseIntersections = (
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCircleIntersections = (
|
const getCircleIntersections = (
|
||||||
center: GA.Point,
|
center: GA.Point,
|
||||||
radius: number,
|
radius: number,
|
||||||
line: GA.Line,
|
line: GA.Line,
|
||||||
@@ -1281,7 +1298,7 @@ export const getCircleIntersections = (
|
|||||||
|
|
||||||
// The focus point is the tangent point of the "focus image" of the
|
// The focus point is the tangent point of the "focus image" of the
|
||||||
// `element`, where the tangent goes through `point`.
|
// `element`, where the tangent goes through `point`.
|
||||||
export const findFocusPointForEllipse = (
|
const findFocusPointForEllipse = (
|
||||||
ellipse: ExcalidrawEllipseElement,
|
ellipse: ExcalidrawEllipseElement,
|
||||||
// Between -1 and 1 (not 0) the relative size of the "focus image" of
|
// Between -1 and 1 (not 0) the relative size of the "focus image" of
|
||||||
// the element on which the focus point lies
|
// the element on which the focus point lies
|
||||||
@@ -1318,7 +1335,7 @@ export const findFocusPointForEllipse = (
|
|||||||
return GA.point(x, (-m * x - 1) / n);
|
return GA.point(x, (-m * x - 1) / n);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findFocusPointForRectangulars = (
|
const findFocusPointForRectangulars = (
|
||||||
element:
|
element:
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
|
|||||||
@@ -49,7 +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";
|
import { Store } from "../store";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
version: number | null;
|
version: number | null;
|
||||||
@@ -642,7 +642,7 @@ export class LinearElementEditor {
|
|||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
store: IStore,
|
store: Store,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||||
import { rescalePoints } from "../points";
|
import { rescalePoints } from "../points";
|
||||||
|
|
||||||
import {
|
import { rotate, centerPoint, rotatePoint } from "../math";
|
||||||
rotate,
|
|
||||||
adjustXYWithRotation,
|
|
||||||
centerPoint,
|
|
||||||
rotatePoint,
|
|
||||||
} from "../math";
|
|
||||||
import {
|
import {
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -23,7 +18,6 @@ import {
|
|||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
getCommonBoundingBox,
|
getCommonBoundingBox,
|
||||||
getElementPointsCoords,
|
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -38,7 +32,6 @@ import { mutateElement } from "./mutateElement";
|
|||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import {
|
import {
|
||||||
TransformHandleType,
|
|
||||||
MaybeTransformHandleType,
|
MaybeTransformHandleType,
|
||||||
TransformHandleDirection,
|
TransformHandleDirection,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
@@ -54,6 +47,7 @@ import {
|
|||||||
getApproxMinLineHeight,
|
getApproxMinLineHeight,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
import { isInGroup } from "../groups";
|
||||||
|
|
||||||
export const normalizeAngle = (angle: number): number => {
|
export const normalizeAngle = (angle: number): number => {
|
||||||
if (angle < 0) {
|
if (angle < 0) {
|
||||||
@@ -133,18 +127,14 @@ export const transformElements = (
|
|||||||
centerY,
|
centerY,
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
} else if (
|
} else if (transformHandleType) {
|
||||||
transformHandleType === "nw" ||
|
|
||||||
transformHandleType === "ne" ||
|
|
||||||
transformHandleType === "sw" ||
|
|
||||||
transformHandleType === "se"
|
|
||||||
) {
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
originalElements,
|
originalElements,
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
shouldResizeFromCenter,
|
shouldResizeFromCenter,
|
||||||
|
shouldMaintainAspectRatio,
|
||||||
pointerX,
|
pointerX,
|
||||||
pointerY,
|
pointerY,
|
||||||
);
|
);
|
||||||
@@ -232,26 +222,6 @@ const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSidesForTransformHandle = (
|
|
||||||
transformHandleType: TransformHandleType,
|
|
||||||
shouldResizeFromCenter: boolean,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
n:
|
|
||||||
/^(n|ne|nw)$/.test(transformHandleType) ||
|
|
||||||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
|
|
||||||
s:
|
|
||||||
/^(s|se|sw)$/.test(transformHandleType) ||
|
|
||||||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
|
|
||||||
w:
|
|
||||||
/^(w|nw|sw)$/.test(transformHandleType) ||
|
|
||||||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
|
|
||||||
e:
|
|
||||||
/^(e|ne|se)$/.test(transformHandleType) ||
|
|
||||||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
const resizeSingleTextElement = (
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
@@ -260,9 +230,10 @@ const resizeSingleTextElement = (
|
|||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
const cx = (x1 + x2) / 2;
|
element,
|
||||||
const cy = (y1 + y2) / 2;
|
elementsMap,
|
||||||
|
);
|
||||||
// rotation pointer with reverse angle
|
// rotation pointer with reverse angle
|
||||||
const [rotatedX, rotatedY] = rotate(
|
const [rotatedX, rotatedY] = rotate(
|
||||||
pointerX,
|
pointerX,
|
||||||
@@ -271,33 +242,24 @@ const resizeSingleTextElement = (
|
|||||||
cy,
|
cy,
|
||||||
-element.angle,
|
-element.angle,
|
||||||
);
|
);
|
||||||
let scale: number;
|
let scaleX = 0;
|
||||||
switch (transformHandleType) {
|
let scaleY = 0;
|
||||||
case "se":
|
|
||||||
scale = Math.max(
|
if (transformHandleType.includes("e")) {
|
||||||
(rotatedX - x1) / (x2 - x1),
|
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||||
(rotatedY - y1) / (y2 - y1),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "nw":
|
|
||||||
scale = Math.max(
|
|
||||||
(x2 - rotatedX) / (x2 - x1),
|
|
||||||
(y2 - rotatedY) / (y2 - y1),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "ne":
|
|
||||||
scale = Math.max(
|
|
||||||
(rotatedX - x1) / (x2 - x1),
|
|
||||||
(y2 - rotatedY) / (y2 - y1),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "sw":
|
|
||||||
scale = Math.max(
|
|
||||||
(x2 - rotatedX) / (x2 - x1),
|
|
||||||
(rotatedY - y1) / (y2 - y1),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (transformHandleType.includes("w")) {
|
||||||
|
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||||
|
}
|
||||||
|
if (transformHandleType.includes("n")) {
|
||||||
|
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||||
|
}
|
||||||
|
if (transformHandleType.includes("s")) {
|
||||||
|
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.max(scaleX, scaleY);
|
||||||
|
|
||||||
if (scale > 0) {
|
if (scale > 0) {
|
||||||
const nextWidth = element.width * scale;
|
const nextWidth = element.width * scale;
|
||||||
const nextHeight = element.height * scale;
|
const nextHeight = element.height * scale;
|
||||||
@@ -305,32 +267,55 @@ const resizeSingleTextElement = (
|
|||||||
if (metrics === null) {
|
if (metrics === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
|
||||||
element,
|
const startTopLeft = [x1, y1];
|
||||||
nextWidth,
|
const startBottomRight = [x2, y2];
|
||||||
nextHeight,
|
const startCenter = [cx, cy];
|
||||||
false,
|
|
||||||
);
|
let newTopLeft = [x1, y1] as [number, number];
|
||||||
const deltaX1 = (x1 - nextX1) / 2;
|
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||||
const deltaY1 = (y1 - nextY1) / 2;
|
newTopLeft = [
|
||||||
const deltaX2 = (x2 - nextX2) / 2;
|
startBottomRight[0] - Math.abs(nextWidth),
|
||||||
const deltaY2 = (y2 - nextY2) / 2;
|
startBottomRight[1] - Math.abs(nextHeight),
|
||||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
];
|
||||||
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
|
}
|
||||||
element.x,
|
if (transformHandleType === "ne") {
|
||||||
element.y,
|
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||||
element.angle,
|
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
|
||||||
deltaX1,
|
}
|
||||||
deltaY1,
|
if (transformHandleType === "sw") {
|
||||||
deltaX2,
|
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||||
deltaY2,
|
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (["s", "n"].includes(transformHandleType)) {
|
||||||
|
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
||||||
|
}
|
||||||
|
if (["e", "w"].includes(transformHandleType)) {
|
||||||
|
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldResizeFromCenter) {
|
||||||
|
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
||||||
|
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = element.angle;
|
||||||
|
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
|
||||||
|
const newCenter: Point = [
|
||||||
|
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||||
|
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||||
|
];
|
||||||
|
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
|
||||||
|
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||||
|
const [nextX, nextY] = newTopLeft;
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: nextWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: nextElementX,
|
x: nextX,
|
||||||
y: nextElementY,
|
y: nextY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -636,8 +621,9 @@ export const resizeMultipleElements = (
|
|||||||
originalElements: PointerDownState["originalElements"],
|
originalElements: PointerDownState["originalElements"],
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
|
shouldMaintainAspectRatio: boolean,
|
||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: number,
|
pointerY: number,
|
||||||
) => {
|
) => {
|
||||||
@@ -691,43 +677,80 @@ export const resizeMultipleElements = (
|
|||||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||||
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
targetElements.map(({ orig }) => orig).concat(boundTextElements),
|
||||||
);
|
);
|
||||||
|
const width = maxX - minX;
|
||||||
// const originalHeight = maxY - minY;
|
const height = maxY - minY;
|
||||||
// const originalWidth = maxX - minX;
|
|
||||||
|
|
||||||
const direction = transformHandleType;
|
const direction = transformHandleType;
|
||||||
|
|
||||||
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
const anchorsMap: Record<TransformHandleDirection, Point> = {
|
||||||
ne: [minX, maxY],
|
ne: [minX, maxY],
|
||||||
se: [minX, minY],
|
se: [minX, minY],
|
||||||
sw: [maxX, minY],
|
sw: [maxX, minY],
|
||||||
nw: [maxX, maxY],
|
nw: [maxX, maxY],
|
||||||
|
e: [minX, minY + height / 2],
|
||||||
|
w: [maxX, minY + height / 2],
|
||||||
|
n: [minX + width / 2, maxY],
|
||||||
|
s: [minX + width / 2, minY],
|
||||||
};
|
};
|
||||||
|
|
||||||
// anchor point must be on the opposite side of the dragged selection handle
|
// anchor point must be on the opposite side of the dragged selection handle
|
||||||
// or be the center of the selection if shouldResizeFromCenter
|
// or be the center of the selection if shouldResizeFromCenter
|
||||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||||
? [midX, midY]
|
? [midX, midY]
|
||||||
: mapDirectionsToAnchors[direction];
|
: anchorsMap[direction];
|
||||||
|
|
||||||
|
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||||
|
|
||||||
const scale =
|
const scale =
|
||||||
Math.max(
|
Math.max(
|
||||||
Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
|
Math.abs(pointerX - anchorX) / width || 0,
|
||||||
Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
|
Math.abs(pointerY - anchorY) / height || 0,
|
||||||
) * (shouldResizeFromCenter ? 2 : 1);
|
) * resizeFromCenterScale;
|
||||||
|
|
||||||
if (scale === 0) {
|
if (scale === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapDirectionsToPointerPositions: Record<
|
let scaleX =
|
||||||
typeof direction,
|
direction.includes("e") || direction.includes("w")
|
||||||
|
? (Math.abs(pointerX - anchorX) / width) * resizeFromCenterScale
|
||||||
|
: 1;
|
||||||
|
let scaleY =
|
||||||
|
direction.includes("n") || direction.includes("s")
|
||||||
|
? (Math.abs(pointerY - anchorY) / height) * resizeFromCenterScale
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const keepAspectRatio =
|
||||||
|
shouldMaintainAspectRatio ||
|
||||||
|
targetElements.some(
|
||||||
|
(item) =>
|
||||||
|
item.latest.angle !== 0 ||
|
||||||
|
isTextElement(item.latest) ||
|
||||||
|
isInGroup(item.latest),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keepAspectRatio) {
|
||||||
|
scaleX = scale;
|
||||||
|
scaleY = scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flipConditionsMap: Record<
|
||||||
|
TransformHandleDirection,
|
||||||
|
// Condition for which we should flip or not flip the selected elements
|
||||||
|
// - when evaluated to `true`, we flip
|
||||||
|
// - therefore, setting it to always `false` means we do not flip (in that direction) at all
|
||||||
[x: boolean, y: boolean]
|
[x: boolean, y: boolean]
|
||||||
> = {
|
> = {
|
||||||
ne: [pointerX >= anchorX, pointerY <= anchorY],
|
ne: [pointerX < anchorX, pointerY > anchorY],
|
||||||
se: [pointerX >= anchorX, pointerY >= anchorY],
|
se: [pointerX < anchorX, pointerY < anchorY],
|
||||||
sw: [pointerX <= anchorX, pointerY >= anchorY],
|
sw: [pointerX > anchorX, pointerY < anchorY],
|
||||||
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
nw: [pointerX > anchorX, pointerY > anchorY],
|
||||||
|
// e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
|
||||||
|
// and therefore, we do not need to flip in the `y` direction at all
|
||||||
|
e: [pointerX < anchorX, false],
|
||||||
|
w: [pointerX > anchorX, false],
|
||||||
|
n: [false, pointerY > anchorY],
|
||||||
|
s: [false, pointerY < anchorY],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -738,9 +761,9 @@ export const resizeMultipleElements = (
|
|||||||
* mirror points in the case of linear & freedraw elemenets
|
* mirror points in the case of linear & freedraw elemenets
|
||||||
* 3. adjust element angle
|
* 3. adjust element angle
|
||||||
*/
|
*/
|
||||||
const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
|
const [flipFactorX, flipFactorY] = flipConditionsMap[direction].map(
|
||||||
direction
|
(condition) => (condition ? -1 : 1),
|
||||||
].map((condition) => (condition ? 1 : -1));
|
);
|
||||||
const isFlippedByX = flipFactorX < 0;
|
const isFlippedByX = flipFactorX < 0;
|
||||||
const isFlippedByY = flipFactorY < 0;
|
const isFlippedByY = flipFactorY < 0;
|
||||||
|
|
||||||
@@ -762,8 +785,8 @@ export const resizeMultipleElements = (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = orig.width * scale;
|
const width = orig.width * scaleX;
|
||||||
const height = orig.height * scale;
|
const height = orig.height * scaleY;
|
||||||
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
|
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
|
||||||
|
|
||||||
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
||||||
@@ -771,8 +794,8 @@ export const resizeMultipleElements = (
|
|||||||
const offsetY = orig.y - anchorY;
|
const offsetY = orig.y - anchorY;
|
||||||
const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
|
const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
|
||||||
const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
|
const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
|
||||||
const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
|
const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
|
||||||
const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
|
const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
|
||||||
|
|
||||||
const rescaledPoints = rescalePointsInElement(
|
const rescaledPoints = rescalePointsInElement(
|
||||||
orig,
|
orig,
|
||||||
@@ -790,40 +813,10 @@ export const resizeMultipleElements = (
|
|||||||
...rescaledPoints,
|
...rescaledPoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isImageElement(orig) && targetElements.length === 1) {
|
if (isImageElement(orig)) {
|
||||||
update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
|
update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) {
|
|
||||||
const origBounds = getElementPointsCoords(orig, orig.points);
|
|
||||||
const newBounds = getElementPointsCoords(
|
|
||||||
{ ...orig, x, y },
|
|
||||||
rescaledPoints.points!,
|
|
||||||
);
|
|
||||||
const origXY = [orig.x, orig.y];
|
|
||||||
const newXY = [x, y];
|
|
||||||
|
|
||||||
const linearShift = (axis: "x" | "y") => {
|
|
||||||
const i = axis === "x" ? 0 : 1;
|
|
||||||
return (
|
|
||||||
(newBounds[i + 2] -
|
|
||||||
newXY[i] -
|
|
||||||
(origXY[i] - origBounds[i]) * scale +
|
|
||||||
(origBounds[i + 2] - origXY[i]) * scale -
|
|
||||||
(newXY[i] - newBounds[i])) /
|
|
||||||
2
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isFlippedByX) {
|
|
||||||
update.x -= linearShift("x");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFlippedByY) {
|
|
||||||
update.y -= linearShift("y");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTextElement(orig)) {
|
if (isTextElement(orig)) {
|
||||||
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
|
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
|
||||||
if (!metrics) {
|
if (!metrics) {
|
||||||
@@ -837,11 +830,15 @@ export const resizeMultipleElements = (
|
|||||||
) as ExcalidrawTextElementWithContainer | undefined;
|
) as ExcalidrawTextElementWithContainer | undefined;
|
||||||
|
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const newFontSize = boundTextElement.fontSize * scale;
|
if (keepAspectRatio) {
|
||||||
if (newFontSize < MIN_FONT_SIZE) {
|
const newFontSize = boundTextElement.fontSize * scale;
|
||||||
return;
|
if (newFontSize < MIN_FONT_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update.boundTextFontSize = newFontSize;
|
||||||
|
} else {
|
||||||
|
update.boundTextFontSize = boundTextElement.fontSize;
|
||||||
}
|
}
|
||||||
update.boundTextFontSize = newFontSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elementsAndUpdates.push({
|
elementsAndUpdates.push({
|
||||||
|
|||||||
@@ -6,15 +6,24 @@ import {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
|
||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
TransformHandle,
|
TransformHandle,
|
||||||
MaybeTransformHandleType,
|
MaybeTransformHandleType,
|
||||||
|
getOmitSidesForDevice,
|
||||||
|
canResizeFromSides,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Device, Zoom } from "../types";
|
||||||
import { Bounds } from "./bounds";
|
import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
||||||
|
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||||
|
import {
|
||||||
|
angleToDegrees,
|
||||||
|
pointOnLine,
|
||||||
|
pointRotate,
|
||||||
|
} from "../../utils/geometry/geometry";
|
||||||
|
import { Line, Point } from "../../utils/geometry/shape";
|
||||||
|
import { isLinearElement } from "./typeChecks";
|
||||||
|
|
||||||
const isInsideTransformHandle = (
|
const isInsideTransformHandle = (
|
||||||
transformHandle: TransformHandle,
|
transformHandle: TransformHandle,
|
||||||
@@ -34,13 +43,20 @@ export const resizeTest = (
|
|||||||
y: number,
|
y: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
|
device: Device,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
if (!appState.selectedElementIds[element.id]) {
|
if (!appState.selectedElementIds[element.id]) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rotation: rotationTransformHandle, ...transformHandles } =
|
const { rotation: rotationTransformHandle, ...transformHandles } =
|
||||||
getTransformHandles(element, zoom, elementsMap, pointerType);
|
getTransformHandles(
|
||||||
|
element,
|
||||||
|
zoom,
|
||||||
|
elementsMap,
|
||||||
|
pointerType,
|
||||||
|
getOmitSidesForDevice(device),
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rotationTransformHandle &&
|
rotationTransformHandle &&
|
||||||
@@ -62,6 +78,35 @@ export const resizeTest = (
|
|||||||
return filter[0] as TransformHandleType;
|
return filter[0] as TransformHandleType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canResizeFromSides(device)) {
|
||||||
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note that for a text element, when "resized" from the side
|
||||||
|
// we should make it wrap/unwrap
|
||||||
|
if (
|
||||||
|
element.type !== "text" &&
|
||||||
|
!(isLinearElement(element) && element.points.length <= 2)
|
||||||
|
) {
|
||||||
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
|
const sides = getSelectionBorders(
|
||||||
|
[x1 - SPACING, y1 - SPACING],
|
||||||
|
[x2 + SPACING, y2 + SPACING],
|
||||||
|
[cx, cy],
|
||||||
|
angleToDegrees(element.angle),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [dir, side] of Object.entries(sides)) {
|
||||||
|
// test to see if x, y are on the line segment
|
||||||
|
if (pointOnLine([x, y], side as Line, SPACING)) {
|
||||||
|
return dir as TransformHandleType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +118,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
device: Device,
|
||||||
) => {
|
) => {
|
||||||
return elements.reduce((result, element) => {
|
return elements.reduce((result, element) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -86,6 +132,7 @@ export const getElementWithTransformHandleType = (
|
|||||||
scenePointerY,
|
scenePointerY,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
|
device,
|
||||||
);
|
);
|
||||||
return transformHandleType ? { element, transformHandleType } : null;
|
return transformHandleType ? { element, transformHandleType } : null;
|
||||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||||
@@ -97,13 +144,14 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
|
device: Device,
|
||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0,
|
0,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
getOmitSidesForDevice(device),
|
||||||
);
|
);
|
||||||
|
|
||||||
const found = Object.keys(transformHandles).find((key) => {
|
const found = Object.keys(transformHandles).find((key) => {
|
||||||
@@ -114,7 +162,33 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return (found || false) as MaybeTransformHandleType;
|
|
||||||
|
if (found) {
|
||||||
|
return found as MaybeTransformHandleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canResizeFromSides(device)) {
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
const cy = (y1 + y2) / 2;
|
||||||
|
|
||||||
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
|
|
||||||
|
const sides = getSelectionBorders(
|
||||||
|
[x1 - SPACING, y1 - SPACING],
|
||||||
|
[x2 + SPACING, y2 + SPACING],
|
||||||
|
[cx, cy],
|
||||||
|
angleToDegrees(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [dir, side] of Object.entries(sides)) {
|
||||||
|
// test to see if x, y are on the line segment
|
||||||
|
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
|
||||||
|
return dir as TransformHandleType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
|
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
|
||||||
@@ -174,3 +248,22 @@ export const getCursorForResizingElement = (resizingElement: {
|
|||||||
|
|
||||||
return cursor ? `${cursor}-resize` : "";
|
return cursor ? `${cursor}-resize` : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSelectionBorders = (
|
||||||
|
[x1, y1]: Point,
|
||||||
|
[x2, y2]: Point,
|
||||||
|
center: Point,
|
||||||
|
angleInDegrees: number,
|
||||||
|
) => {
|
||||||
|
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
|
||||||
|
const topRight = pointRotate([x2, y1], angleInDegrees, center);
|
||||||
|
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
|
||||||
|
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
|
||||||
|
|
||||||
|
return {
|
||||||
|
n: [topLeft, topRight],
|
||||||
|
e: [topRight, bottomRight],
|
||||||
|
s: [bottomRight, bottomLeft],
|
||||||
|
w: [bottomLeft, topLeft],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import {
|
|||||||
|
|
||||||
import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
import { Bounds, getElementAbsoluteCoords } from "./bounds";
|
||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import { InteractiveCanvasAppState, Zoom } from "../types";
|
import { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants";
|
import {
|
||||||
|
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||||
|
isAndroid,
|
||||||
|
isIOS,
|
||||||
|
} from "../constants";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
| "n"
|
| "n"
|
||||||
@@ -38,6 +42,13 @@ const transformHandleSizes: { [k in PointerType]: number } = {
|
|||||||
|
|
||||||
const ROTATION_RESIZE_HANDLE_GAP = 16;
|
const ROTATION_RESIZE_HANDLE_GAP = 16;
|
||||||
|
|
||||||
|
export const DEFAULT_OMIT_SIDES = {
|
||||||
|
e: true,
|
||||||
|
s: true,
|
||||||
|
n: true,
|
||||||
|
w: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
||||||
e: true,
|
e: true,
|
||||||
s: true,
|
s: true,
|
||||||
@@ -89,6 +100,26 @@ const generateTransformHandle = (
|
|||||||
return [xx - width / 2, yy - height / 2, width, height];
|
return [xx - width / 2, yy - height / 2, width, height];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const canResizeFromSides = (device: Device) => {
|
||||||
|
if (device.viewport.isMobile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOmitSidesForDevice = (device: Device) => {
|
||||||
|
if (canResizeFromSides(device)) {
|
||||||
|
return DEFAULT_OMIT_SIDES;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
export const getTransformHandlesFromCoords = (
|
export const getTransformHandlesFromCoords = (
|
||||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||||
angle: number,
|
angle: number,
|
||||||
@@ -232,8 +263,8 @@ export const getTransformHandles = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
|
||||||
pointerType: PointerType = "mouse",
|
pointerType: PointerType = "mouse",
|
||||||
|
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
|
||||||
): TransformHandles => {
|
): TransformHandles => {
|
||||||
// so that when locked element is selected (especially when you toggle lock
|
// so that when locked element is selected (especially when you toggle lock
|
||||||
// via keyboard) the locked element is visually distinct, indicating
|
// via keyboard) the locked element is visually distinct, indicating
|
||||||
@@ -242,7 +273,6 @@ export const getTransformHandles = (
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
|
||||||
if (element.type === "freedraw" || isLinearElement(element)) {
|
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||||
if (element.points.length === 2) {
|
if (element.points.length === 2) {
|
||||||
// only check the last point because starting point is always (0,0)
|
// only check the last point because starting point is always (0,0)
|
||||||
@@ -263,6 +293,7 @@ export const getTransformHandles = (
|
|||||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||||
} else if (isFrameLikeElement(element)) {
|
} else if (isFrameLikeElement(element)) {
|
||||||
omitSides = {
|
omitSides = {
|
||||||
|
...omitSides,
|
||||||
rotation: true,
|
rotation: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,3 +387,7 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
|||||||
|
|
||||||
return maxGroup === elements.length;
|
return maxGroup === elements.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
|
||||||
|
return element.groupIds.length > 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ export {
|
|||||||
restoreLibraryItems,
|
restoreLibraryItems,
|
||||||
} from "./data/restore";
|
} from "./data/restore";
|
||||||
|
|
||||||
|
export { reconcileElements } from "./data/reconcile";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
exportToCanvas,
|
exportToCanvas,
|
||||||
exportToBlob,
|
exportToBlob,
|
||||||
@@ -251,6 +253,8 @@ export {
|
|||||||
bumpVersion,
|
bumpVersion,
|
||||||
} from "./element/mutateElement";
|
} from "./element/mutateElement";
|
||||||
|
|
||||||
|
export { StoreAction } from "./store";
|
||||||
|
|
||||||
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
export { parseLibraryTokensFromUrl, useHandleLibrary } from "./data/library";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
|
||||||
getTransformHandlesFromCoords,
|
getTransformHandlesFromCoords,
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
@@ -23,7 +22,7 @@ import {
|
|||||||
selectGroupsFromGivenElements,
|
selectGroupsFromGivenElements,
|
||||||
} from "../groups";
|
} from "../groups";
|
||||||
import {
|
import {
|
||||||
OMIT_SIDES_FOR_FRAME,
|
getOmitSidesForDevice,
|
||||||
shouldShowBoundingBox,
|
shouldShowBoundingBox,
|
||||||
TransformHandles,
|
TransformHandles,
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
@@ -577,6 +576,7 @@ const _renderInteractiveScene = ({
|
|||||||
scale,
|
scale,
|
||||||
appState,
|
appState,
|
||||||
renderConfig,
|
renderConfig,
|
||||||
|
device,
|
||||||
}: InteractiveSceneRenderConfig) => {
|
}: InteractiveSceneRenderConfig) => {
|
||||||
if (canvas === null) {
|
if (canvas === null) {
|
||||||
return { atLeastOneVisibleElement: false, elementsMap };
|
return { atLeastOneVisibleElement: false, elementsMap };
|
||||||
@@ -806,6 +806,7 @@ const _renderInteractiveScene = ({
|
|||||||
appState.zoom,
|
appState.zoom,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
"mouse", // when we render we don't know which pointer type so use mouse,
|
"mouse", // when we render we don't know which pointer type so use mouse,
|
||||||
|
getOmitSidesForDevice(device),
|
||||||
);
|
);
|
||||||
if (!appState.viewModeEnabled && showBoundingBox) {
|
if (!appState.viewModeEnabled && showBoundingBox) {
|
||||||
renderTransformHandles(
|
renderTransformHandles(
|
||||||
@@ -844,8 +845,8 @@ const _renderInteractiveScene = ({
|
|||||||
appState.zoom,
|
appState.zoom,
|
||||||
"mouse",
|
"mouse",
|
||||||
isFrameSelected
|
isFrameSelected
|
||||||
? OMIT_SIDES_FOR_FRAME
|
? { ...getOmitSidesForDevice(device), rotation: true }
|
||||||
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
: getOmitSidesForDevice(device),
|
||||||
);
|
);
|
||||||
if (selectedElements.some((element) => !element.locked)) {
|
if (selectedElements.some((element) => !element.locked)) {
|
||||||
renderTransformHandles(
|
renderTransformHandles(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
StaticCanvasAppState,
|
StaticCanvasAppState,
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
|
Device,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MakeBrand } from "../utility-types";
|
import { MakeBrand } from "../utility-types";
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export type InteractiveSceneRenderConfig = {
|
|||||||
scale: number;
|
scale: number;
|
||||||
appState: InteractiveCanvasAppState;
|
appState: InteractiveCanvasAppState;
|
||||||
renderConfig: InteractiveCanvasRenderConfig;
|
renderConfig: InteractiveCanvasRenderConfig;
|
||||||
|
device: Device;
|
||||||
callback: (data: RenderInteractiveSceneCallback) => void;
|
callback: (data: RenderInteractiveSceneCallback) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+184
-100
@@ -1,5 +1,6 @@
|
|||||||
import { getDefaultAppState } from "./appState";
|
import { getDefaultAppState } from "./appState";
|
||||||
import { AppStateChange, ElementsChange } from "./change";
|
import { AppStateChange, ElementsChange } from "./change";
|
||||||
|
import { ENV } from "./constants";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { deepCopyElement } from "./element/newElement";
|
import { deepCopyElement } from "./element/newElement";
|
||||||
import { OrderedExcalidrawElement } from "./element/types";
|
import { OrderedExcalidrawElement } from "./element/types";
|
||||||
@@ -7,8 +8,11 @@ import { Emitter } from "./emitter";
|
|||||||
import { AppState, ObservedAppState } from "./types";
|
import { AppState, ObservedAppState } from "./types";
|
||||||
import { isShallowEqual } from "./utils";
|
import { isShallowEqual } from "./utils";
|
||||||
|
|
||||||
|
// hidden non-enumerable property for runtime checks
|
||||||
|
const hiddenObservedAppStateProp = "__observedAppState";
|
||||||
|
|
||||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||||
return {
|
const observedAppState = {
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
@@ -17,14 +21,40 @@ export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
|||||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
value: true,
|
||||||
|
enumerable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return observedAppState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StoreAction = {
|
const isObservedAppState = (
|
||||||
NONE: "NONE",
|
appState: AppState | ObservedAppState,
|
||||||
UPDATE: "UPDATE",
|
): appState is ObservedAppState =>
|
||||||
CAPTURE: "CAPTURE",
|
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
||||||
|
|
||||||
|
export type StoreActionType = "capture" | "update" | "none";
|
||||||
|
|
||||||
|
export const StoreAction: {
|
||||||
|
[K in Uppercase<StoreActionType>]: StoreActionType;
|
||||||
|
} = {
|
||||||
|
CAPTURE: "capture",
|
||||||
|
UPDATE: "update",
|
||||||
|
NONE: "none",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represent an increment to the Store.
|
||||||
|
*/
|
||||||
|
class StoreIncrementEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly elementsChange: ElementsChange,
|
||||||
|
public readonly appStateChange: AppStateChange,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
|
||||||
*
|
*
|
||||||
@@ -41,18 +71,18 @@ export interface IStore {
|
|||||||
shouldUpdateSnapshot(): void;
|
shouldUpdateSnapshot(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use to schedule calculation of a store increment on a next component update.
|
* Use to schedule calculation of a store increment.
|
||||||
*/
|
*/
|
||||||
shouldCaptureIncrement(): void;
|
shouldCaptureIncrement(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture changes to the `elements` and `appState` by calculating changes (based on a snapshot) and emitting resulting changes as a store increment.
|
* Based on the scheduled operation, either only updates store snapshot or also calculates increment and emits the result as a `StoreIncrementEvent`.
|
||||||
*
|
*
|
||||||
* @emits StoreIncrementEvent
|
* @emits StoreIncrementEvent when increment is calculated.
|
||||||
*/
|
*/
|
||||||
capture(
|
commit(
|
||||||
elements: Map<string, OrderedExcalidrawElement>,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,33 +94,19 @@ export interface IStore {
|
|||||||
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
* Filters out yet uncomitted elements from `nextElements`, which are part of in-progress local async actions (ephemerals) and thus were not yet commited to the snapshot.
|
||||||
*
|
*
|
||||||
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
* This is necessary in updates in which we receive reconciled elements, already containing elements which were not yet captured by the local store (i.e. collab).
|
||||||
*
|
|
||||||
* Once we will be exchanging just store increments for all ephemerals, this could be deprecated.
|
|
||||||
*/
|
*/
|
||||||
ignoreUncomittedElements(
|
filterUncomittedElements(
|
||||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||||
): Map<string, OrderedExcalidrawElement>;
|
): Map<string, OrderedExcalidrawElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Represent an increment to the Store.
|
|
||||||
*/
|
|
||||||
class StoreIncrementEvent {
|
|
||||||
constructor(
|
|
||||||
public readonly elementsChange: ElementsChange,
|
|
||||||
public readonly appStateChange: AppStateChange,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Store implements IStore {
|
export class Store implements IStore {
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[StoreIncrementEvent]
|
[StoreIncrementEvent]
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private calculatingIncrement: boolean = false;
|
private scheduledActions: Set<StoreActionType> = new Set();
|
||||||
private updatingSnapshot: boolean = false;
|
|
||||||
|
|
||||||
private _snapshot = Snapshot.empty();
|
private _snapshot = Snapshot.empty();
|
||||||
|
|
||||||
public get snapshot() {
|
public get snapshot() {
|
||||||
@@ -101,64 +117,81 @@ export class Store implements IStore {
|
|||||||
this._snapshot = snapshot;
|
this._snapshot = snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public shouldUpdateSnapshot = () => {
|
// TODO: Suspicious that this is called so many places. Seems error-prone.
|
||||||
this.updatingSnapshot = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Suspicious that this is called so many places. Seems error-prone.
|
|
||||||
public shouldCaptureIncrement = () => {
|
public shouldCaptureIncrement = () => {
|
||||||
this.calculatingIncrement = true;
|
this.scheduleAction(StoreAction.CAPTURE);
|
||||||
};
|
};
|
||||||
|
|
||||||
public capture = (
|
public shouldUpdateSnapshot = () => {
|
||||||
elements: Map<string, OrderedExcalidrawElement>,
|
this.scheduleAction(StoreAction.UPDATE);
|
||||||
appState: AppState,
|
};
|
||||||
|
|
||||||
|
private scheduleAction = (action: StoreActionType) => {
|
||||||
|
this.scheduledActions.add(action);
|
||||||
|
this.satisfiesScheduledActionsInvariant();
|
||||||
|
};
|
||||||
|
|
||||||
|
public commit = (
|
||||||
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
): void => {
|
): void => {
|
||||||
// Quick exit for irrelevant changes
|
|
||||||
if (!this.calculatingIncrement && !this.updatingSnapshot) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextSnapshot = this._snapshot.clone(elements, appState);
|
// Capture has precedence since it also performs update
|
||||||
|
if (this.scheduledActions.has(StoreAction.CAPTURE)) {
|
||||||
// Optimisation, don't continue if nothing has changed
|
this.captureIncrement(elements, appState);
|
||||||
if (this._snapshot !== nextSnapshot) {
|
} else if (this.scheduledActions.has(StoreAction.UPDATE)) {
|
||||||
// Calculate and record the changes based on the previous and next snapshot
|
this.updateSnapshot(elements, appState);
|
||||||
if (this.calculatingIncrement) {
|
|
||||||
const elementsChange = nextSnapshot.meta.didElementsChange
|
|
||||||
? ElementsChange.calculate(
|
|
||||||
this._snapshot.elements,
|
|
||||||
nextSnapshot.elements,
|
|
||||||
)
|
|
||||||
: ElementsChange.empty();
|
|
||||||
|
|
||||||
const appStateChange = nextSnapshot.meta.didAppStateChange
|
|
||||||
? AppStateChange.calculate(
|
|
||||||
this._snapshot.appState,
|
|
||||||
nextSnapshot.appState,
|
|
||||||
)
|
|
||||||
: AppStateChange.empty();
|
|
||||||
|
|
||||||
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onStoreIncrementEmitter.trigger(
|
|
||||||
new StoreIncrementEvent(elementsChange, appStateChange),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the snapshot
|
|
||||||
this._snapshot = nextSnapshot;
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Reset props
|
this.satisfiesScheduledActionsInvariant();
|
||||||
this.updatingSnapshot = false;
|
// Defensively reset all scheduled actions, potentially cleans up other runtime garbage
|
||||||
this.calculatingIncrement = false;
|
this.scheduledActions = new Set();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public ignoreUncomittedElements = (
|
public captureIncrement = (
|
||||||
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
) => {
|
||||||
|
const prevSnapshot = this.snapshot;
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
||||||
|
|
||||||
|
// Optimisation, don't continue if nothing has changed
|
||||||
|
if (prevSnapshot !== nextSnapshot) {
|
||||||
|
// Calculate and record the changes based on the previous and next snapshot
|
||||||
|
const elementsChange = nextSnapshot.meta.didElementsChange
|
||||||
|
? ElementsChange.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||||
|
: ElementsChange.empty();
|
||||||
|
|
||||||
|
const appStateChange = nextSnapshot.meta.didAppStateChange
|
||||||
|
? AppStateChange.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||||
|
: AppStateChange.empty();
|
||||||
|
|
||||||
|
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
|
||||||
|
// Notify listeners with the increment
|
||||||
|
this.onStoreIncrementEmitter.trigger(
|
||||||
|
new StoreIncrementEvent(elementsChange, appStateChange),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update snapshot
|
||||||
|
this.snapshot = nextSnapshot;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public updateSnapshot = (
|
||||||
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
) => {
|
||||||
|
const nextSnapshot = this.snapshot.maybeClone(elements, appState);
|
||||||
|
|
||||||
|
if (this.snapshot !== nextSnapshot) {
|
||||||
|
// Update snapshot
|
||||||
|
this.snapshot = nextSnapshot;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public filterUncomittedElements = (
|
||||||
prevElements: Map<string, OrderedExcalidrawElement>,
|
prevElements: Map<string, OrderedExcalidrawElement>,
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -170,7 +203,7 @@ export class Store implements IStore {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elementSnapshot = this._snapshot.elements.get(id);
|
const elementSnapshot = this.snapshot.elements.get(id);
|
||||||
|
|
||||||
// Checks for in progress async user action
|
// Checks for in progress async user action
|
||||||
if (!elementSnapshot) {
|
if (!elementSnapshot) {
|
||||||
@@ -186,7 +219,19 @@ export class Store implements IStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public clear = (): void => {
|
public clear = (): void => {
|
||||||
this._snapshot = Snapshot.empty();
|
this.snapshot = Snapshot.empty();
|
||||||
|
this.scheduledActions = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
private satisfiesScheduledActionsInvariant = () => {
|
||||||
|
if (!(this.scheduledActions.size >= 0 && this.scheduledActions.size <= 3)) {
|
||||||
|
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledActions.size}".`;
|
||||||
|
console.error(message, this.scheduledActions.values());
|
||||||
|
|
||||||
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,29 +263,30 @@ export class Snapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Efficiently clone the existing snapshot.
|
* Efficiently clone the existing snapshot, only if we detected changes.
|
||||||
*
|
*
|
||||||
* @returns same instance if there are no changes detected, new instance otherwise.
|
* @returns same instance if there are no changes detected, new instance otherwise.
|
||||||
*/
|
*/
|
||||||
public clone(
|
public maybeClone(
|
||||||
elements: Map<string, OrderedExcalidrawElement>,
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
appState: AppState,
|
appState: AppState | ObservedAppState | undefined,
|
||||||
) {
|
) {
|
||||||
const didElementsChange = this.detectChangedElements(elements);
|
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(elements);
|
||||||
|
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(appState);
|
||||||
|
|
||||||
// Not watching over everything from app state, just the relevant props
|
let didElementsChange = false;
|
||||||
const nextAppStateSnapshot = getObservedAppState(appState);
|
let didAppStateChange = false;
|
||||||
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
|
|
||||||
|
|
||||||
// Nothing has changed, so there is no point of continuing further
|
if (this.elements !== nextElementsSnapshot) {
|
||||||
if (!didElementsChange && !didAppStateChange) {
|
didElementsChange = true;
|
||||||
return this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone only if there was really a change
|
if (this.appState !== nextAppStateSnapshot) {
|
||||||
let nextElementsSnapshot = this.elements;
|
didAppStateChange = true;
|
||||||
if (didElementsChange) {
|
}
|
||||||
nextElementsSnapshot = this.createElementsSnapshot(elements);
|
|
||||||
|
if (!didElementsChange && !didAppStateChange) {
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
|
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
|
||||||
@@ -251,10 +297,55 @@ export class Snapshot {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private maybeCreateAppStateSnapshot(
|
||||||
|
appState: AppState | ObservedAppState | undefined,
|
||||||
|
) {
|
||||||
|
if (!appState) {
|
||||||
|
return this.appState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not watching over everything from the app state, just the relevant props
|
||||||
|
const nextAppStateSnapshot = !isObservedAppState(appState)
|
||||||
|
? getObservedAppState(appState)
|
||||||
|
: appState;
|
||||||
|
|
||||||
|
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
|
||||||
|
|
||||||
|
if (!didAppStateChange) {
|
||||||
|
return this.appState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextAppStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectChangedAppState(nextObservedAppState: ObservedAppState) {
|
||||||
|
return !isShallowEqual(this.appState, nextObservedAppState, {
|
||||||
|
selectedElementIds: isShallowEqual,
|
||||||
|
selectedGroupIds: isShallowEqual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private maybeCreateElementsSnapshot(
|
||||||
|
elements: Map<string, OrderedExcalidrawElement> | undefined,
|
||||||
|
) {
|
||||||
|
if (!elements) {
|
||||||
|
return this.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didElementsChange = this.detectChangedElements(elements);
|
||||||
|
|
||||||
|
if (!didElementsChange) {
|
||||||
|
return this.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementsSnapshot = this.createElementsSnapshot(elements);
|
||||||
|
return elementsSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if there any changed elements.
|
* Detect if there any changed elements.
|
||||||
*
|
*
|
||||||
* NOTE: we shouldn't use `sceneVersionNonce` instead, as we need to call this before the scene updates.
|
* NOTE: we shouldn't just use `sceneVersionNonce` instead, as we need to call this before the scene updates.
|
||||||
*/
|
*/
|
||||||
private detectChangedElements(
|
private detectChangedElements(
|
||||||
nextElements: Map<string, OrderedExcalidrawElement>,
|
nextElements: Map<string, OrderedExcalidrawElement>,
|
||||||
@@ -286,13 +377,6 @@ export class Snapshot {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectChangedAppState(observedAppState: ObservedAppState) {
|
|
||||||
return !isShallowEqual(this.appState, observedAppState, {
|
|
||||||
selectedElementIds: isShallowEqual,
|
|
||||||
selectedGroupIds: isShallowEqual,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform structural clone, cloning only elements that changed.
|
* Perform structural clone, cloning only elements that changed.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2170,14 +2170,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 1014066025,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -2404,14 +2404,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 1150084233,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -2438,14 +2438,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1150084233,
|
"seed": 1014066025,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 1116226695,
|
"versionNonce": 238820263,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10,
|
"y": 10,
|
||||||
@@ -2704,14 +2704,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 1505387817,
|
"versionNonce": 493213705,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -2740,14 +2740,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1150084233,
|
"seed": 1014066025,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 23633383,
|
"versionNonce": 915032327,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@@ -3060,14 +3060,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 449462985,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 640725609,
|
"versionNonce": 941653321,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -3094,14 +3094,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 760410951,
|
"seed": 289600103,
|
||||||
"strokeColor": "#e03131",
|
"strokeColor": "#e03131",
|
||||||
"strokeStyle": "dotted",
|
"strokeStyle": "dotted",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 9,
|
"version": 9,
|
||||||
"versionNonce": 1315507081,
|
"versionNonce": 640725609,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@@ -3840,14 +3840,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1150084233,
|
"seed": 1014066025,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 23633383,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@@ -3874,14 +3874,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1278240551,
|
"seed": 449462985,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 401146281,
|
"versionNonce": 1150084233,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -5224,8 +5224,8 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"left": -19,
|
"left": -17,
|
||||||
"top": -9,
|
"top": -7,
|
||||||
},
|
},
|
||||||
"currentChartType": "bar",
|
"currentChartType": "bar",
|
||||||
"currentItemBackgroundColor": "transparent",
|
"currentItemBackgroundColor": "transparent",
|
||||||
@@ -5342,14 +5342,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 449462985,
|
"seed": 453191,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 1014066025,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -5376,16 +5376,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 3,
|
||||||
},
|
},
|
||||||
"seed": 1014066025,
|
"seed": 400692809,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1604849351,
|
"versionNonce": 23633383,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -5493,7 +5493,7 @@ History {
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -6349,8 +6349,8 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"left": -19,
|
"left": -17,
|
||||||
"top": -9,
|
"top": -7,
|
||||||
},
|
},
|
||||||
"currentChartType": "bar",
|
"currentChartType": "bar",
|
||||||
"currentItemBackgroundColor": "transparent",
|
"currentItemBackgroundColor": "transparent",
|
||||||
@@ -6516,7 +6516,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 747212839,
|
"versionNonce": 747212839,
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -6624,7 +6624,7 @@ History {
|
|||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"width": 10,
|
"width": 10,
|
||||||
"x": 10,
|
"x": 12,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
@@ -8181,8 +8181,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"left": -19,
|
"left": -17,
|
||||||
"top": -9,
|
"top": -7,
|
||||||
},
|
},
|
||||||
"currentChartType": "bar",
|
"currentChartType": "bar",
|
||||||
"currentItemBackgroundColor": "transparent",
|
"currentItemBackgroundColor": "transparent",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1400,9 +1400,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
|||||||
"penDetected": false,
|
"penDetected": false,
|
||||||
"penMode": false,
|
"penMode": false,
|
||||||
"pendingImageElementId": null,
|
"pendingImageElementId": null,
|
||||||
"previousSelectedElementIds": {
|
"previousSelectedElementIds": {},
|
||||||
"id0": true,
|
|
||||||
},
|
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
@@ -1522,7 +1520,7 @@ History {
|
|||||||
|
|
||||||
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
|
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of elements 1`] = `0`;
|
||||||
|
|
||||||
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `9`;
|
exports[`regression tests > Drags selected element when hitting only bounding box and keeps element selected > [end of test] number of renders 1`] = `11`;
|
||||||
|
|
||||||
exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
|
exports[`regression tests > adjusts z order when grouping > [end of test] appState 1`] = `
|
||||||
{
|
{
|
||||||
@@ -6570,12 +6568,27 @@ History {
|
|||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
"selectedLinearElementId": null,
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"elementsChange": ElementsChange {
|
||||||
|
"added": Map {},
|
||||||
|
"removed": Map {},
|
||||||
|
"updated": Map {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
HistoryEntry {
|
||||||
|
"appStateChange": AppStateChange {
|
||||||
|
"delta": Delta {
|
||||||
|
"deleted": {
|
||||||
|
"selectedLinearElementId": null,
|
||||||
|
},
|
||||||
|
"inserted": {
|
||||||
"selectedLinearElementId": "id6",
|
"selectedLinearElementId": "id6",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe("element binding", () => {
|
|||||||
const rect = API.createElement({
|
const rect = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
x: 0,
|
x: 0,
|
||||||
|
y: 0,
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
});
|
});
|
||||||
@@ -39,31 +40,43 @@ describe("element binding", () => {
|
|||||||
h.elements = [rect, arrow];
|
h.elements = [rect, arrow];
|
||||||
expect(arrow.startBinding).toBe(null);
|
expect(arrow.startBinding).toBe(null);
|
||||||
|
|
||||||
API.setSelectedElements([arrow]);
|
// select arrow
|
||||||
|
mouse.clickAt(150, 0);
|
||||||
|
|
||||||
expect(API.getSelectedElements()).toEqual([arrow]);
|
// move arrow start to potential binding position
|
||||||
mouse.downAt(100, 0);
|
mouse.downAt(100, 0);
|
||||||
mouse.moveTo(55, 0);
|
mouse.moveTo(55, 0);
|
||||||
mouse.up(0, 0);
|
mouse.up(0, 0);
|
||||||
expect(arrow.startBinding).toEqual({
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: expect.toBeNonNaNNumber(),
|
|
||||||
gap: expect.toBeNonNaNNumber(),
|
|
||||||
});
|
|
||||||
|
|
||||||
mouse.downAt(100, 0);
|
// Point selection is evaluated like the points are rendered,
|
||||||
mouse.move(-45, 0);
|
// from right to left. So clicking on the first point should move the joint,
|
||||||
mouse.up();
|
// not the start point.
|
||||||
expect(arrow.startBinding).toEqual({
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: expect.toBeNonNaNNumber(),
|
|
||||||
gap: expect.toBeNonNaNNumber(),
|
|
||||||
});
|
|
||||||
|
|
||||||
mouse.down();
|
|
||||||
mouse.move(-50, 0);
|
|
||||||
mouse.up();
|
|
||||||
expect(arrow.startBinding).toBe(null);
|
expect(arrow.startBinding).toBe(null);
|
||||||
|
|
||||||
|
// Now that the start point is free, move it into overlapping position
|
||||||
|
mouse.downAt(100, 0);
|
||||||
|
mouse.moveTo(55, 0);
|
||||||
|
mouse.up(0, 0);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()).toEqual([arrow]);
|
||||||
|
|
||||||
|
expect(arrow.startBinding).toEqual({
|
||||||
|
elementId: rect.id,
|
||||||
|
focus: expect.toBeNonNaNNumber(),
|
||||||
|
gap: expect.toBeNonNaNNumber(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move the end point to the overlapping binding position
|
||||||
|
mouse.downAt(200, 0);
|
||||||
|
mouse.moveTo(55, 0);
|
||||||
|
mouse.up(0, 0);
|
||||||
|
|
||||||
|
// Both the start and the end points should be bound
|
||||||
|
expect(arrow.startBinding).toEqual({
|
||||||
|
elementId: rect.id,
|
||||||
|
focus: expect.toBeNonNaNNumber(),
|
||||||
|
gap: expect.toBeNonNaNNumber(),
|
||||||
|
});
|
||||||
expect(arrow.endBinding).toEqual({
|
expect(arrow.endBinding).toEqual({
|
||||||
elementId: rect.id,
|
elementId: rect.id,
|
||||||
focus: expect.toBeNonNaNNumber(),
|
focus: expect.toBeNonNaNNumber(),
|
||||||
@@ -143,7 +156,7 @@ describe("element binding", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("should bind/unbind arrow when moving it with keyboard", () => {
|
it("should unbind arrow when moving it with keyboard", () => {
|
||||||
const rectangle = UI.createElement("rectangle", {
|
const rectangle = UI.createElement("rectangle", {
|
||||||
x: 75,
|
x: 75,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -159,11 +172,22 @@ describe("element binding", () => {
|
|||||||
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
|
|
||||||
|
mouse.downAt(50, 50);
|
||||||
|
mouse.moveTo(51, 0);
|
||||||
|
mouse.up(0, 0);
|
||||||
|
|
||||||
|
// Test sticky connection
|
||||||
expect(API.getSelectedElement().type).toBe("arrow");
|
expect(API.getSelectedElement().type).toBe("arrow");
|
||||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||||
|
|
||||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||||
|
|
||||||
|
// Sever connection
|
||||||
|
expect(API.getSelectedElement().type).toBe("arrow");
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||||
|
expect(arrow.endBinding).toBe(null);
|
||||||
|
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,4 +393,44 @@ describe("element binding", () => {
|
|||||||
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
expect(arrow2.startBinding?.elementId).toBe(container.id);
|
||||||
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #6459
|
||||||
|
it("should unbind arrow only from the latest element", () => {
|
||||||
|
const rectLeft = UI.createElement("rectangle", {
|
||||||
|
x: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 500,
|
||||||
|
});
|
||||||
|
const rectRight = UI.createElement("rectangle", {
|
||||||
|
x: 400,
|
||||||
|
width: 200,
|
||||||
|
height: 500,
|
||||||
|
});
|
||||||
|
const arrow = UI.createElement("arrow", {
|
||||||
|
x: 210,
|
||||||
|
y: 250,
|
||||||
|
width: 180,
|
||||||
|
height: 1,
|
||||||
|
});
|
||||||
|
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||||
|
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||||
|
|
||||||
|
// Drag arrow off of bound rectangle range
|
||||||
|
const handles = getTransformHandles(
|
||||||
|
arrow,
|
||||||
|
h.state.zoom,
|
||||||
|
arrayToMap(h.elements),
|
||||||
|
"mouse",
|
||||||
|
).se!;
|
||||||
|
|
||||||
|
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||||
|
const elX = handles[0] + handles[2] / 2;
|
||||||
|
const elY = handles[1] + handles[3] / 2;
|
||||||
|
mouse.downAt(elX, elY);
|
||||||
|
mouse.moveTo(300, 400);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
|
expect(arrow.endBinding).toBe(null);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
const contextMenuOptions =
|
const contextMenuOptions =
|
||||||
@@ -188,19 +188,19 @@ describe("contextMenu element", () => {
|
|||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, -10);
|
mouse.down(12, -10);
|
||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.click(10, 10);
|
mouse.click(10, 10);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click(20, 0);
|
mouse.click(22, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
@@ -240,13 +240,13 @@ describe("contextMenu element", () => {
|
|||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
mouse.down(10, -10);
|
mouse.down(12, -10);
|
||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.click(10, 10);
|
mouse.click(10, 10);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click(20, 0);
|
mouse.click(22, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
@@ -255,8 +255,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
@@ -297,8 +297,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
expect(copiedStyles).toBe("{}");
|
expect(copiedStyles).toBe("{}");
|
||||||
@@ -382,8 +382,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
|
fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
|
||||||
@@ -398,8 +398,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
|
fireEvent.click(queryByText(contextMenu!, "Add to library")!);
|
||||||
@@ -417,8 +417,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
|
||||||
@@ -548,8 +548,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
fireEvent.click(queryByText(contextMenu!, "Group selection")!);
|
fireEvent.click(queryByText(contextMenu!, "Group selection")!);
|
||||||
@@ -578,8 +578,8 @@ describe("contextMenu element", () => {
|
|||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
button: 2,
|
button: 2,
|
||||||
clientX: 1,
|
clientX: 3,
|
||||||
clientY: 1,
|
clientY: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = UI.queryContextMenu();
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ const transform = (
|
|||||||
h.state.zoom,
|
h.state.zoom,
|
||||||
arrayToMap(h.elements),
|
arrayToMap(h.elements),
|
||||||
"mouse",
|
"mouse",
|
||||||
|
{},
|
||||||
)[handle];
|
)[handle];
|
||||||
} else {
|
} else {
|
||||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
|||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||||
import { AppState, ExcalidrawImperativeAPI } from "../types";
|
import { AppState, ExcalidrawImperativeAPI } from "../types";
|
||||||
import { arrayToMap, resolvablePromise } from "../utils";
|
import { arrayToMap, resolvablePromise } from "../utils";
|
||||||
import { COLOR_PALETTE } from "../colors";
|
import {
|
||||||
|
COLOR_PALETTE,
|
||||||
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||||
|
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||||
|
} from "../colors";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +39,7 @@ import { vi } from "vitest";
|
|||||||
import { queryByText } from "@testing-library/react";
|
import { queryByText } from "@testing-library/react";
|
||||||
import { HistoryEntry } from "../history";
|
import { HistoryEntry } from "../history";
|
||||||
import { AppStateChange, ElementsChange } from "../change";
|
import { AppStateChange, ElementsChange } from "../change";
|
||||||
import { Snapshot } from "../store";
|
import { Snapshot, StoreAction } from "../store";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -67,10 +71,11 @@ const checkpoint = (name: string) => {
|
|||||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||||
|
|
||||||
const transparent = COLOR_PALETTE.transparent;
|
const transparent = COLOR_PALETTE.transparent;
|
||||||
const red = COLOR_PALETTE.red[1];
|
const black = COLOR_PALETTE.black;
|
||||||
const blue = COLOR_PALETTE.blue[1];
|
const red = COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
const yellow = COLOR_PALETTE.yellow[1];
|
const blue = COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
const violet = COLOR_PALETTE.violet[1];
|
const yellow = COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
|
const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
|
||||||
|
|
||||||
describe("history", () => {
|
describe("history", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -176,7 +181,7 @@ describe("history", () => {
|
|||||||
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
@@ -188,7 +193,7 @@ describe("history", () => {
|
|||||||
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
commitToStore: true, // even though the flag is on, same elements are passed, nothing to commit
|
storeAction: StoreAction.CAPTURE, // even though the flag is on, same elements are passed, nothing to commit
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
@@ -556,7 +561,7 @@ describe("history", () => {
|
|||||||
appState: {
|
appState: {
|
||||||
name: "New name",
|
name: "New name",
|
||||||
},
|
},
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
@@ -567,7 +572,7 @@ describe("history", () => {
|
|||||||
appState: {
|
appState: {
|
||||||
viewBackgroundColor: "#000",
|
viewBackgroundColor: "#000",
|
||||||
},
|
},
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
@@ -580,7 +585,7 @@ describe("history", () => {
|
|||||||
name: "New name",
|
name: "New name",
|
||||||
viewBackgroundColor: "#000",
|
viewBackgroundColor: "#000",
|
||||||
},
|
},
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
@@ -973,6 +978,69 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create entry when selecting freedraw", async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(-10, -10);
|
||||||
|
mouse.up(10, 10);
|
||||||
|
|
||||||
|
UI.clickTool("freedraw");
|
||||||
|
mouse.down(40, -20);
|
||||||
|
mouse.up(50, 10);
|
||||||
|
|
||||||
|
const rectangle = h.elements[0];
|
||||||
|
const freedraw1 = h.elements[1];
|
||||||
|
|
||||||
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: rectangle.id }),
|
||||||
|
expect.objectContaining({ id: freedraw1.id, strokeColor: black }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: rectangle.id }),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: freedraw1.id,
|
||||||
|
strokeColor: black,
|
||||||
|
isDeleted: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
togglePopover("Stroke");
|
||||||
|
UI.clickOnTestId("color-red");
|
||||||
|
mouse.down(40, -20);
|
||||||
|
mouse.up(50, 10);
|
||||||
|
|
||||||
|
const freedraw2 = h.elements[2];
|
||||||
|
|
||||||
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: rectangle.id }),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: freedraw1.id,
|
||||||
|
strokeColor: black,
|
||||||
|
isDeleted: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: freedraw2.id,
|
||||||
|
strokeColor: COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ensure we don't end up with duplicated entries
|
||||||
|
UI.clickTool("freedraw");
|
||||||
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should support duplication of groups, appstate group selection and editing group", async () => {
|
it("should support duplication of groups, appstate group selection and editing group", async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
const rect1 = API.createElement({
|
const rect1 = API.createElement({
|
||||||
@@ -1235,7 +1303,7 @@ describe("history", () => {
|
|||||||
|
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, text, rect2],
|
elements: [rect1, text, rect2],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// bind text1 to rect1
|
// bind text1 to rect1
|
||||||
@@ -1638,6 +1706,7 @@ describe("history", () => {
|
|||||||
<Excalidraw
|
<Excalidraw
|
||||||
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
excalidrawAPI={(api) => excalidrawAPIPromise.resolve(api as any)}
|
||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
|
isCollaborating={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
excalidrawAPI = await excalidrawAPIPromise;
|
excalidrawAPI = await excalidrawAPIPromise;
|
||||||
@@ -1663,6 +1732,7 @@ describe("history", () => {
|
|||||||
strokeColor: blue,
|
strokeColor: blue,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -1700,6 +1770,7 @@ describe("history", () => {
|
|||||||
strokeColor: yellow,
|
strokeColor: yellow,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -1747,6 +1818,7 @@ describe("history", () => {
|
|||||||
backgroundColor: yellow,
|
backgroundColor: yellow,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
// At this point our entry gets updated from `red` -> `blue` into `red` -> `yellow`
|
||||||
@@ -1762,6 +1834,7 @@ describe("history", () => {
|
|||||||
backgroundColor: violet,
|
backgroundColor: violet,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
// At this point our (inversed) entry gets updated from `red` -> `yellow` into `violet` -> `yellow`
|
||||||
@@ -1790,6 +1863,7 @@ describe("history", () => {
|
|||||||
// Initialize scene
|
// Initialize scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -1798,7 +1872,7 @@ describe("history", () => {
|
|||||||
newElementWith(h.elements[0], { groupIds: ["A"] }),
|
newElementWith(h.elements[0], { groupIds: ["A"] }),
|
||||||
newElementWith(h.elements[1], { groupIds: ["A"] }),
|
newElementWith(h.elements[1], { groupIds: ["A"] }),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
|
const rect3 = API.createElement({ type: "rectangle", groupIds: ["B"] });
|
||||||
@@ -1812,6 +1886,7 @@ describe("history", () => {
|
|||||||
rect3,
|
rect3,
|
||||||
rect4,
|
rect4,
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -1857,6 +1932,7 @@ describe("history", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo(); // undo `actionFinalize`
|
Keyboard.undo(); // undo `actionFinalize`
|
||||||
@@ -1951,6 +2027,7 @@ describe("history", () => {
|
|||||||
isDeleted: false, // undeletion might happen due to concurrency between clients
|
isDeleted: false, // undeletion might happen due to concurrency between clients
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getSelectedElements()).toEqual([]);
|
expect(API.getSelectedElements()).toEqual([]);
|
||||||
@@ -2027,6 +2104,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@@ -2088,6 +2166,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2163,6 +2242,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2201,6 +2281,7 @@ describe("history", () => {
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
@@ -2246,6 +2327,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
@@ -2255,6 +2337,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
@@ -2275,6 +2358,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2299,6 +2383,7 @@ describe("history", () => {
|
|||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
@@ -2309,6 +2394,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
elements: [h.elements[0], h.elements[1], rect3, rect4],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
@@ -2354,6 +2440,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2374,6 +2461,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2416,6 +2504,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2458,6 +2547,7 @@ describe("history", () => {
|
|||||||
h.elements[0],
|
h.elements[0],
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
@@ -2496,6 +2586,7 @@ describe("history", () => {
|
|||||||
h.elements[0],
|
h.elements[0],
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
@@ -2546,6 +2637,7 @@ describe("history", () => {
|
|||||||
h.elements[0], // rect2
|
h.elements[0], // rect2
|
||||||
h.elements[1], // rect1
|
h.elements[1], // rect1
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2575,6 +2667,7 @@ describe("history", () => {
|
|||||||
h.elements[0], // rect3
|
h.elements[0], // rect3
|
||||||
h.elements[2], // rect1
|
h.elements[2], // rect1
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2604,6 +2697,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [...h.elements, rect],
|
elements: [...h.elements, rect],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(60, 60);
|
mouse.moveTo(60, 60);
|
||||||
@@ -2655,6 +2749,7 @@ describe("history", () => {
|
|||||||
// // Simulate remote update
|
// // Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [...h.elements, rect3],
|
elements: [...h.elements, rect3],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
@@ -2744,6 +2839,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [...h.elements, rect3],
|
elements: [...h.elements, rect3],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.moveTo(100, 100);
|
mouse.moveTo(100, 100);
|
||||||
@@ -2920,6 +3016,7 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -2932,7 +3029,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -2963,6 +3060,7 @@ describe("history", () => {
|
|||||||
x: h.elements[1].x + 10,
|
x: h.elements[1].x + 10,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3005,6 +3103,7 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -3017,7 +3116,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3051,6 +3150,7 @@ describe("history", () => {
|
|||||||
remoteText,
|
remoteText,
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3106,6 +3206,7 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container, text],
|
elements: [container, text],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -3118,7 +3219,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3155,6 +3256,7 @@ describe("history", () => {
|
|||||||
containerId: remoteContainer.id,
|
containerId: remoteContainer.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3212,7 +3314,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3223,6 +3325,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3272,7 +3375,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3283,6 +3386,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3331,7 +3435,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3344,6 +3448,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3380,6 +3485,7 @@ describe("history", () => {
|
|||||||
// rebinding the container with a new text element!
|
// rebinding the container with a new text element!
|
||||||
remoteText,
|
remoteText,
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3436,7 +3542,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3449,6 +3555,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3485,6 +3592,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3540,7 +3648,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3554,6 +3662,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3596,7 +3705,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
@@ -3610,6 +3719,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -3652,6 +3762,7 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [container],
|
elements: [container],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -3663,7 +3774,7 @@ describe("history", () => {
|
|||||||
angle: 90,
|
angle: 90,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3676,6 +3787,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
newElementWith(text, { containerId: container.id }),
|
newElementWith(text, { containerId: container.id }),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@@ -3768,6 +3880,7 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [text],
|
elements: [text],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -3779,7 +3892,7 @@ describe("history", () => {
|
|||||||
angle: 90,
|
angle: 90,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -3794,6 +3907,7 @@ describe("history", () => {
|
|||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
@@ -3884,7 +3998,7 @@ describe("history", () => {
|
|||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect1, rect2],
|
elements: [rect1, rect2],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
@@ -3900,12 +4014,18 @@ describe("history", () => {
|
|||||||
|
|
||||||
const arrowId = h.elements[2].id;
|
const arrowId = h.elements[2].id;
|
||||||
|
|
||||||
// create binding
|
// create start binding
|
||||||
mouse.downAt(0, 0);
|
mouse.downAt(0, 0);
|
||||||
mouse.moveTo(0, 1);
|
mouse.moveTo(0, 1);
|
||||||
mouse.moveTo(0, 0);
|
mouse.moveTo(0, 0);
|
||||||
mouse.up();
|
mouse.up();
|
||||||
|
|
||||||
|
// create end binding
|
||||||
|
mouse.downAt(100, 0);
|
||||||
|
mouse.moveTo(100, 1);
|
||||||
|
mouse.moveTo(100, 0);
|
||||||
|
mouse.up();
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -3930,9 +4050,10 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo(); // undo start binding
|
||||||
|
Keyboard.undo(); // undo end binding
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(2);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -3962,11 +4083,13 @@ describe("history", () => {
|
|||||||
x: h.elements[1].x + 50,
|
x: h.elements[1].x + 50,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(3);
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(4);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -3992,9 +4115,10 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(2);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -4020,11 +4144,16 @@ describe("history", () => {
|
|||||||
|
|
||||||
const arrowId = h.elements[2].id;
|
const arrowId = h.elements[2].id;
|
||||||
|
|
||||||
// create binding
|
// create start binding
|
||||||
mouse.downAt(0, 0);
|
mouse.downAt(0, 0);
|
||||||
mouse.moveTo(0, 1);
|
mouse.moveTo(0, 1);
|
||||||
mouse.upAt(0, 0);
|
mouse.upAt(0, 0);
|
||||||
|
|
||||||
|
// create end binding
|
||||||
|
mouse.downAt(100, 0);
|
||||||
|
mouse.moveTo(100, 1);
|
||||||
|
mouse.upAt(100, 0);
|
||||||
|
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -4049,9 +4178,10 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(2);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -4082,11 +4212,13 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
remoteContainer,
|
remoteContainer,
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(3);
|
Keyboard.redo();
|
||||||
|
expect(API.getUndoStack().length).toBe(4);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -4117,9 +4249,10 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Keyboard.undo();
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(2);
|
expect(API.getUndoStack().length).toBe(2);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
expect(API.getRedoStack().length).toBe(2);
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: rect1.id,
|
id: rect1.id,
|
||||||
@@ -4166,6 +4299,7 @@ describe("history", () => {
|
|||||||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -4230,7 +4364,10 @@ describe("history", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({ elements: [arrow], commitToStore: true });
|
excalidrawAPI.updateScene({
|
||||||
|
elements: [arrow],
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
});
|
||||||
|
|
||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
@@ -4246,6 +4383,7 @@ describe("history", () => {
|
|||||||
boundElements: [{ id: arrow.id, type: "arrow" }],
|
boundElements: [{ id: arrow.id, type: "arrow" }],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
runTwice(() => {
|
runTwice(() => {
|
||||||
@@ -4357,6 +4495,7 @@ describe("history", () => {
|
|||||||
newElementWith(h.elements[1], { x: 500, y: -500 }),
|
newElementWith(h.elements[1], { x: 500, y: -500 }),
|
||||||
h.elements[2],
|
h.elements[2],
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
@@ -4424,12 +4563,13 @@ describe("history", () => {
|
|||||||
// Initialize the scene
|
// Initialize the scene
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [frame],
|
elements: [frame],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
excalidrawAPI.updateScene({
|
excalidrawAPI.updateScene({
|
||||||
elements: [rect, h.elements[0]],
|
elements: [rect, h.elements[0]],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate local update
|
// Simulate local update
|
||||||
@@ -4440,7 +4580,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
h.elements[1],
|
h.elements[1],
|
||||||
],
|
],
|
||||||
commitToStore: true,
|
storeAction: StoreAction.CAPTURE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
@@ -4484,6 +4624,7 @@ describe("history", () => {
|
|||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
import { Excalidraw } from "../../index";
|
import { Excalidraw, StoreAction } from "../../index";
|
||||||
import { ExcalidrawImperativeAPI } from "../../types";
|
import { ExcalidrawImperativeAPI } from "../../types";
|
||||||
import { resolvablePromise } from "../../utils";
|
import { resolvablePromise } from "../../utils";
|
||||||
import { render } from "../test-utils";
|
import { render } from "../test-utils";
|
||||||
@@ -27,7 +27,10 @@ describe("event callbacks", () => {
|
|||||||
|
|
||||||
const origBackgroundColor = h.state.viewBackgroundColor;
|
const origBackgroundColor = h.state.viewBackgroundColor;
|
||||||
excalidrawAPI.onChange(onChange);
|
excalidrawAPI.onChange(onChange);
|
||||||
excalidrawAPI.updateScene({ appState: { viewBackgroundColor: "red" } });
|
excalidrawAPI.updateScene({
|
||||||
|
appState: { viewBackgroundColor: "red" },
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
});
|
||||||
expect(onChange).toHaveBeenCalledWith(
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
// elements
|
// elements
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ describe("regression tests", () => {
|
|||||||
expect(
|
expect(
|
||||||
h.elements.filter((element) => element.type === "rectangle").length,
|
h.elements.filter((element) => element.type === "rectangle").length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||||
mouse.down(-8, -8);
|
mouse.down(-8, -8);
|
||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
@@ -725,7 +724,7 @@ describe("regression tests", () => {
|
|||||||
mouse.up(10, 10);
|
mouse.up(10, 10);
|
||||||
|
|
||||||
const { x: prevX, y: prevY } = API.getSelectedElement();
|
const { x: prevX, y: prevY } = API.getSelectedElement();
|
||||||
|
API.clearSelection();
|
||||||
// drag element from point on bounding box that doesn't hit element
|
// drag element from point on bounding box that doesn't hit element
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
mouse.down(8, 8);
|
mouse.down(8, 8);
|
||||||
@@ -1015,12 +1014,22 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("single-clicking on a subgroup of a selected group should not alter selection", () => {
|
it("single-clicking on a subgroup of a selected group should not alter selection", () => {
|
||||||
const rect1 = UI.createElement("rectangle", { x: 10 });
|
const rect1 = UI.createElement("rectangle", {
|
||||||
const rect2 = UI.createElement("rectangle", { x: 50 });
|
x: 10,
|
||||||
|
});
|
||||||
|
const rect2 = UI.createElement("rectangle", {
|
||||||
|
x: 50,
|
||||||
|
});
|
||||||
UI.group([rect1, rect2]);
|
UI.group([rect1, rect2]);
|
||||||
|
|
||||||
const rect3 = UI.createElement("rectangle", { x: 10, y: 50 });
|
const rect3 = UI.createElement("rectangle", {
|
||||||
const rect4 = UI.createElement("rectangle", { x: 50, y: 50 });
|
x: 10,
|
||||||
|
y: 50,
|
||||||
|
});
|
||||||
|
const rect4 = UI.createElement("rectangle", {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
});
|
||||||
UI.group([rect3, rect4]);
|
UI.group([rect3, rect4]);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
@@ -1079,8 +1088,9 @@ describe("regression tests", () => {
|
|||||||
UI.group([rect1, rect3]);
|
UI.group([rect1, rect3]);
|
||||||
assertSelectedElements(rect1, rect2, rect3);
|
assertSelectedElements(rect1, rect2, rect3);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
mouse.clickOn(rect1);
|
mouse.click(10, 5);
|
||||||
});
|
});
|
||||||
assertSelectedElements(rect1);
|
assertSelectedElements(rect1);
|
||||||
|
|
||||||
|
|||||||
@@ -544,7 +544,9 @@ describe("multiple selection", () => {
|
|||||||
1 + move[1] / selectionHeight,
|
1 + move[1] / selectionHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
UI.resize([rectangle, diamond, ellipse], "se", move);
|
UI.resize([rectangle, diamond, ellipse], "se", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(rectangle.x).toBeCloseTo(0);
|
expect(rectangle.x).toBeCloseTo(0);
|
||||||
expect(rectangle.y).toBeCloseTo(0);
|
expect(rectangle.y).toBeCloseTo(0);
|
||||||
@@ -613,7 +615,9 @@ describe("multiple selection", () => {
|
|||||||
1 + move[1] / selectionHeight,
|
1 + move[1] / selectionHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
UI.resize([line, freedraw], "se", move);
|
UI.resize([line, freedraw], "se", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(line.x).toBeCloseTo(60 * scale);
|
expect(line.x).toBeCloseTo(60 * scale);
|
||||||
expect(line.y).toBeCloseTo(40 * scale);
|
expect(line.y).toBeCloseTo(40 * scale);
|
||||||
@@ -653,7 +657,9 @@ describe("multiple selection", () => {
|
|||||||
1 - move[1] / selectionHeight,
|
1 - move[1] / selectionHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
UI.resize([horizLine, vertLine, diagLine], "nw", move);
|
UI.resize([horizLine, vertLine, diagLine], "nw", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
|
expect(horizLine.x).toBeCloseTo(selectionWidth * (1 - scale));
|
||||||
expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
|
expect(horizLine.y).toBeCloseTo(selectionHeight * (1 - scale));
|
||||||
@@ -703,7 +709,9 @@ describe("multiple selection", () => {
|
|||||||
const rightArrowBinding = { ...rightBoundArrow.endBinding };
|
const rightArrowBinding = { ...rightBoundArrow.endBinding };
|
||||||
delete rightArrowBinding.gap;
|
delete rightArrowBinding.gap;
|
||||||
|
|
||||||
UI.resize([rectangle, rightBoundArrow], "nw", move);
|
UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
@@ -751,7 +759,9 @@ describe("multiple selection", () => {
|
|||||||
const move = [80, 0] as [number, number];
|
const move = [80, 0] as [number, number];
|
||||||
const scale = move[0] / selectionWidth + 1;
|
const scale = move[0] / selectionWidth + 1;
|
||||||
const elementsMap = arrayToMap(h.elements);
|
const elementsMap = arrayToMap(h.elements);
|
||||||
UI.resize([topArrow.get(), bottomArrow.get()], "se", move);
|
UI.resize([topArrow.get(), bottomArrow.get()], "se", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||||
topArrow,
|
topArrow,
|
||||||
topArrowLabel,
|
topArrowLabel,
|
||||||
@@ -815,7 +825,7 @@ describe("multiple selection", () => {
|
|||||||
1 - move[1] / selectionHeight,
|
1 - move[1] / selectionHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
UI.resize([topText, bottomText], "ne", move);
|
UI.resize([topText, bottomText], "ne", move, { shift: true });
|
||||||
|
|
||||||
expect(topText.x).toBeCloseTo(0);
|
expect(topText.x).toBeCloseTo(0);
|
||||||
expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
|
expect(topText.y).toBeCloseTo(-selectionHeight * (scale - 1));
|
||||||
@@ -828,7 +838,7 @@ describe("multiple selection", () => {
|
|||||||
expect(bottomText.angle).toEqual(0);
|
expect(bottomText.angle).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resizes with images", () => {
|
it("resizes with images (proportional)", () => {
|
||||||
const topImage = API.createElement({
|
const topImage = API.createElement({
|
||||||
type: "image",
|
type: "image",
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -891,7 +901,7 @@ describe("multiple selection", () => {
|
|||||||
1 + (2 * move[1]) / selectionHeight,
|
1 + (2 * move[1]) / selectionHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
UI.resize([rectangle, ellipse], "se", move, { alt: true });
|
UI.resize([rectangle, ellipse], "se", move, { shift: true, alt: true });
|
||||||
|
|
||||||
expect(rectangle.x).toBeCloseTo(-200 * scale);
|
expect(rectangle.x).toBeCloseTo(-200 * scale);
|
||||||
expect(rectangle.y).toBeCloseTo(-140 * scale);
|
expect(rectangle.y).toBeCloseTo(-140 * scale);
|
||||||
@@ -954,7 +964,9 @@ describe("multiple selection", () => {
|
|||||||
const scaleY = -scaleX;
|
const scaleY = -scaleX;
|
||||||
const lineOrigBounds = getBoundsFromPoints(line);
|
const lineOrigBounds = getBoundsFromPoints(line);
|
||||||
const elementsMap = arrayToMap(h.elements);
|
const elementsMap = arrayToMap(h.elements);
|
||||||
UI.resize([line, image, rectangle, boundArrow], "se", move);
|
UI.resize([line, image, rectangle, boundArrow], "se", move, {
|
||||||
|
shift: true,
|
||||||
|
});
|
||||||
const lineNewBounds = getBoundsFromPoints(line);
|
const lineNewBounds = getBoundsFromPoints(line);
|
||||||
const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition(
|
||||||
boundArrow,
|
boundArrow,
|
||||||
@@ -979,7 +991,7 @@ describe("multiple selection", () => {
|
|||||||
expect(image.width).toBeCloseTo(100 * -scaleX);
|
expect(image.width).toBeCloseTo(100 * -scaleX);
|
||||||
expect(image.height).toBeCloseTo(100 * scaleY);
|
expect(image.height).toBeCloseTo(100 * scaleY);
|
||||||
expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
|
expect(image.angle).toBeCloseTo((Math.PI * 5) / 6);
|
||||||
expect(image.scale).toEqual([1, 1]);
|
expect(image.scale).toEqual([-1, 1]);
|
||||||
|
|
||||||
expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
|
expect(rectangle.x).toBeCloseTo((180 + 160) * scaleX);
|
||||||
expect(rectangle.y).toBeCloseTo(60 * scaleY);
|
expect(rectangle.y).toBeCloseTo(60 * scaleY);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
|||||||
import { ContextMenuItems } from "./components/ContextMenu";
|
import { ContextMenuItems } from "./components/ContextMenu";
|
||||||
import { SnapLine } from "./snapping";
|
import { SnapLine } from "./snapping";
|
||||||
import { Merge, MaybePromise, ValueOf } from "./utility-types";
|
import { Merge, MaybePromise, ValueOf } from "./utility-types";
|
||||||
|
import { StoreActionType } from "./store";
|
||||||
|
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
|
|
||||||
@@ -507,7 +508,7 @@ export type SceneData = {
|
|||||||
elements?: ImportedDataState["elements"];
|
elements?: ImportedDataState["elements"];
|
||||||
appState?: ImportedDataState["appState"];
|
appState?: ImportedDataState["appState"];
|
||||||
collaborators?: Map<SocketId, Collaborator>;
|
collaborators?: Map<SocketId, Collaborator>;
|
||||||
commitToStore?: boolean;
|
storeAction?: StoreActionType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum UserIdleState {
|
export enum UserIdleState {
|
||||||
|
|||||||
Reference in New Issue
Block a user