Compare commits
28 Commits
master
...
mtolmacs/f
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5af4500bbc | ||
![]() |
ff57dd60d8 | ||
![]() |
05d8ce55e4 | ||
![]() |
4184103eb0 | ||
![]() |
22696dc8f2 | ||
![]() |
8d28b47989 | ||
![]() |
1eecd9a56b | ||
![]() |
55ba55fbbb | ||
![]() |
b6dea75d57 | ||
![]() |
979fff566c | ||
![]() |
b33cc74183 | ||
![]() |
5947af5b50 | ||
![]() |
40f25180ea | ||
![]() |
11fe608f9a | ||
![]() |
ad8220c529 | ||
![]() |
ae0fdf2d21 | ||
![]() |
2cf53200ac | ||
![]() |
df1f89efcd | ||
![]() |
8e4fd83f5c | ||
![]() |
d3a41cb453 | ||
![]() |
3fa818b0ce | ||
![]() |
2af0336466 | ||
![]() |
fe58962bfd | ||
![]() |
154855916b | ||
![]() |
8de0a037fd | ||
![]() |
b2799d0a15 | ||
![]() |
fbc5e4a03d | ||
![]() |
ca5e9c3ad9 |
@ -48,6 +48,3 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
|
||||
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
|
||||
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
|
||||
HQIDAQAB'
|
||||
|
||||
# set to true in .env.development.local to disable the prevent unload dialog
|
||||
VITE_APP_DISABLE_PREVENT_UNLOAD=
|
||||
|
@ -1,5 +1,5 @@
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
@ -32,12 +32,6 @@
|
||||
"name": "jotai",
|
||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||
}
|
||||
],
|
||||
"react/jsx-no-target-blank": [
|
||||
"error",
|
||||
{
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
||||
- 🏗️ Customizable.
|
||||
- 📷 Image support.
|
||||
- 😀 Shape libraries support.
|
||||
- 🌐 Localization (i18n) support.
|
||||
- 👅 Localization (i18n) support.
|
||||
- 🖼️ Export to PNG, SVG & clipboard.
|
||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
|
||||
|
||||
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
|
||||
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node.
|
||||
|
||||
**Usage**
|
||||
|
||||
@ -25,7 +25,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
This will only work for `Desktop` devices.
|
||||
This will only for `Desktop` devices.
|
||||
|
||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||
|
||||
|
@ -31,7 +31,6 @@ All `props` are _optional_.
|
||||
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
|
||||
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean | undefined)</code> | \_ | use for custom src url validation |
|
||||
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
|
||||
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
|
||||
|
||||
### Storing custom data on Excalidraw elements
|
||||
|
||||
|
@ -19,7 +19,7 @@ services:
|
||||
- ./:/opt/node_app/app:delegated
|
||||
- ./package.json:/opt/node_app/package.json
|
||||
- ./yarn.lock:/opt/node_app/yarn.lock
|
||||
# - notused:/opt/node_app/app/node_modules
|
||||
- notused:/opt/node_app/app/node_modules
|
||||
|
||||
# volumes:
|
||||
# notused:
|
||||
volumes:
|
||||
notused:
|
||||
|
@ -52,7 +52,7 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.excalidraw .selected-shape-actions {
|
||||
.excalidraw .panelColumn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
@ -104,7 +104,6 @@ export default function ExampleApp({
|
||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
||||
const [renderScrollbars, setRenderScrollbars] = useState(false);
|
||||
const [blobUrl, setBlobUrl] = useState<string>("");
|
||||
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
||||
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
||||
@ -193,7 +192,6 @@ export default function ExampleApp({
|
||||
}) => setPointerData(payload),
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
renderScrollbars,
|
||||
gridModeEnabled,
|
||||
theme,
|
||||
name: "Custom name of drawing",
|
||||
@ -712,14 +710,6 @@ export default function ExampleApp({
|
||||
/>
|
||||
Grid mode
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={renderScrollbars}
|
||||
onChange={() => setRenderScrollbars(!renderScrollbars)}
|
||||
/>
|
||||
Render scrollbars
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
@ -15,8 +15,7 @@
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"build:preview": "yarn build && yarn preview",
|
||||
"build:preview": "yarn build && vite preview --port 5002",
|
||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||
}
|
||||
}
|
||||
|
@ -47,10 +47,10 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { isElementLink } from "@excalidraw/element";
|
||||
import { isElementLink } from "@excalidraw/element/elementLink";
|
||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@ -608,13 +608,7 @@ const ExcalidrawWrapper = () => {
|
||||
excalidrawAPI.getSceneElements(),
|
||||
)
|
||||
) {
|
||||
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
|
||||
preventUnload(event);
|
||||
} else {
|
||||
console.warn(
|
||||
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||
@ -926,22 +920,17 @@ const ExcalidrawWrapper = () => {
|
||||
<ShareDialog
|
||||
collabAPI={collabAPI}
|
||||
onExportToBackend={async () => {
|
||||
if (!excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
await onExportToBackend(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
setLatestShareableLink(url);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -19,9 +19,12 @@ import {
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
@ -298,13 +301,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
// the purpose is to run in immediately after user decides to stay
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
|
||||
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
|
||||
preventUnload(event);
|
||||
} else {
|
||||
console.warn(
|
||||
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
|
@ -73,7 +73,7 @@ export const AIComponents = ({
|
||||
</br>
|
||||
<div>You can also try <a href="${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
|
@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
|
@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noreferrer"
|
||||
className="plus-button"
|
||||
>
|
||||
Go to Excalidraw+
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
generateEncryptionKey,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
@ -9,14 +9,14 @@ import {
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { bytesToHexString } from "@excalidraw/common";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { SceneBounds } from "@excalidraw/element";
|
||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
|
@ -41,8 +41,8 @@
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "vite build",
|
||||
"build:app": "vite build",
|
||||
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
|
||||
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
|
||||
"build:version": "node ../scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"start": "yarn && vite",
|
||||
|
@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<a
|
||||
class="welcome-screen-menu-item "
|
||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
rel="noopener"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
|
@ -3,15 +3,11 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||
import { syncInvalidIndices } from "@excalidraw/element";
|
||||
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { StoreIncrement } from "@excalidraw/element";
|
||||
|
||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
const { h } = window;
|
||||
@ -69,79 +65,6 @@ vi.mock("socket.io-client", () => {
|
||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||
*/
|
||||
describe("collaboration", () => {
|
||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||
const durableIncrements: DurableIncrement[] = [];
|
||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
durableIncrements.push(increment);
|
||||
} else {
|
||||
ephemeralIncrements.push(increment);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
expect(durableIncrements.length).toBe(0);
|
||||
expect(ephemeralIncrements.length).toBe(0);
|
||||
|
||||
const rectProps = {
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
height: 200,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as const;
|
||||
|
||||
const rect = API.createElement({ ...rectProps });
|
||||
|
||||
API.updateScene({
|
||||
elements: [rect],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(durableIncrements.length).toBe(1);
|
||||
});
|
||||
|
||||
// simulate two batched remote updates
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// we scheduled two micro actions,
|
||||
// which confirms they are going to be executed as part of one batched component update
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// altough the updates get batched,
|
||||
// we expect two ephemeral increments for each update,
|
||||
// and each such update should have the expected change
|
||||
expect(ephemeralIncrements.length).toBe(2);
|
||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 100 }),
|
||||
);
|
||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 200 }),
|
||||
);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
const rect1Props = {
|
||||
@ -199,7 +122,7 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||
@ -231,7 +154,7 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const redoAction = createRedoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||
|
@ -25,10 +25,7 @@ export default defineConfig(({ mode }) => {
|
||||
alias: [
|
||||
{
|
||||
find: /^@excalidraw\/common$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/common/src/index.ts",
|
||||
),
|
||||
replacement: path.resolve(__dirname, "../packages/common/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/common\/(.*?)/,
|
||||
@ -36,10 +33,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/element$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/element/src/index.ts",
|
||||
),
|
||||
replacement: path.resolve(__dirname, "../packages/element/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/element\/(.*?)/,
|
||||
@ -47,10 +41,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/excalidraw$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/excalidraw/index.tsx",
|
||||
),
|
||||
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/excalidraw\/(.*?)/,
|
||||
@ -66,10 +57,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/utils$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
"../packages/utils/src/index.ts",
|
||||
),
|
||||
replacement: path.resolve(__dirname, "../packages/utils/src/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/utils\/(.*?)/,
|
||||
@ -225,7 +213,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
id: "excalidraw",
|
||||
id:"excalidraw",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
|
@ -33,7 +33,6 @@
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
@ -79,8 +78,8 @@
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
|
@ -13,7 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts"
|
||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@ -50,7 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,6 @@ import oc from "open-color";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
source: R,
|
||||
|
@ -10,7 +10,6 @@ export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
@ -113,14 +112,12 @@ export const YOUTUBE_STATES = {
|
||||
export const ENV = {
|
||||
TEST: "test",
|
||||
DEVELOPMENT: "development",
|
||||
PRODUCTION: "production",
|
||||
};
|
||||
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
};
|
||||
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
@ -144,7 +141,6 @@ export const FONT_FAMILY = {
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
Assistant: 10,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_FALLBACKS = {
|
||||
@ -256,7 +252,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const getExportSource = () =>
|
||||
export const EXPORT_SOURCE =
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
@ -322,9 +318,6 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
`;
|
||||
|
||||
export const ENCRYPTION_KEY_BITS = 128;
|
||||
|
||||
@ -426,7 +419,6 @@ export const LIBRARY_DISABLED_TYPES = new Set([
|
||||
// use these constants to easily identify reference sites
|
||||
export const TOOL_TYPE = {
|
||||
selection: "selection",
|
||||
lasso: "lasso",
|
||||
rectangle: "rectangle",
|
||||
diamond: "diamond",
|
||||
ellipse: "ellipse",
|
||||
|
@ -22,10 +22,8 @@ export interface FontMetadata {
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/**
|
||||
* whether this is a font that users can use (= shown in font picker)
|
||||
*/
|
||||
private?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
serverSide?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
@ -46,7 +44,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.35,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
@ -100,23 +98,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
descender: -434,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1021,
|
||||
descender: -287,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
private: true,
|
||||
serverSide: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
|
@ -9,4 +9,3 @@ export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
|
@ -68,12 +68,3 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
/** Strip all the methods or functions from a type */
|
||||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||
? [K, V]
|
||||
: never;
|
||||
|
@ -1,82 +0,0 @@
|
||||
import {
|
||||
isTransparent,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToCommonValue()", () => {
|
||||
it("should return the common value when all values are the same", () => {
|
||||
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||
expect(reduceToCommonValue([""])).toEqual("");
|
||||
expect(reduceToCommonValue([0])).toEqual(0);
|
||||
|
||||
const o = {};
|
||||
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||
|
||||
expect(
|
||||
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||
).toEqual(1);
|
||||
expect(
|
||||
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return `null` when values are different", () => {
|
||||
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return `null` when some values are nullable", () => {
|
||||
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFind()", () => {
|
||||
it("should return the first mapped non-null element", () => {
|
||||
{
|
||||
let counter = 0;
|
||||
|
||||
const result = mapFind(["a", "b", "c"], (value) => {
|
||||
counter++;
|
||||
return value === "b" ? 42 : null;
|
||||
});
|
||||
expect(result).toEqual(42);
|
||||
expect(counter).toBe(2);
|
||||
}
|
||||
|
||||
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||
expect(mapFind([1, 2], () => "")).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined if no mapped element is found", () => {
|
||||
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,10 +1,15 @@
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import {
|
||||
average,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
pointTranslate,
|
||||
vector,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
@ -386,7 +391,7 @@ export const updateActiveTool = (
|
||||
type: ToolType;
|
||||
}
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean; fromSelection?: boolean }) & {
|
||||
) & { locked?: boolean }) & {
|
||||
lastActiveToolBeforeEraser?: ActiveTool | null;
|
||||
},
|
||||
): AppState["activeTool"] => {
|
||||
@ -408,7 +413,6 @@ export const updateActiveTool = (
|
||||
type: data.type,
|
||||
customType: null,
|
||||
locked: data.locked ?? appState.activeTool.locked,
|
||||
fromSelection: data.fromSelection ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
@ -544,20 +548,6 @@ export const findLastIndex = <T>(
|
||||
return -1;
|
||||
};
|
||||
|
||||
/** returns the first non-null mapped value */
|
||||
export const mapFind = <T, K>(
|
||||
collection: readonly T[],
|
||||
iteratee: (value: T, index: number) => K | undefined | null,
|
||||
): K | undefined => {
|
||||
for (let idx = 0; idx < collection.length; idx++) {
|
||||
const result = iteratee(collection[idx], idx);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
@ -568,9 +558,6 @@ export const isTransparent = (color: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@ -694,7 +681,7 @@ export const arrayToMap = <T extends { id: string } | string>(
|
||||
return items.reduce((acc: Map<string, T>, element) => {
|
||||
acc.set(typeof element === "string" ? element : element.id, element);
|
||||
return acc;
|
||||
}, new Map() as Map<string, T>);
|
||||
}, new Map());
|
||||
};
|
||||
|
||||
export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
@ -749,31 +736,10 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
||||
return acc;
|
||||
}, [] as Node<T>[]);
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an iterable.
|
||||
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||
*/
|
||||
export const toIterable = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): Iterable<T> => {
|
||||
return Array.isArray(values) ? values : values.values();
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an array.
|
||||
*/
|
||||
export const toArray = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): T[] => {
|
||||
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||
};
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||
|
||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
|
||||
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
|
||||
|
||||
export const isServerEnv = () =>
|
||||
typeof process !== "undefined" && !!process?.env?.NODE_ENV;
|
||||
|
||||
@ -1238,59 +1204,5 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const { x, y, width, height } = element;
|
||||
|
||||
const centerXPoint = x + width / 2 + xOffset;
|
||||
|
||||
const centerYPoint = y + height / 2 + yOffset;
|
||||
|
||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||
};
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
export const sizeOf = (
|
||||
value:
|
||||
| readonly unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map || value instanceof Set
|
||||
? value.size
|
||||
: Object.keys(value).length;
|
||||
};
|
||||
|
||||
export const reduceToCommonValue = <T, R = T>(
|
||||
collection: readonly T[] | ReadonlySet<T>,
|
||||
getValue?: (item: T) => R,
|
||||
): R | null => {
|
||||
if (sizeOf(collection) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||
|
||||
let commonValue: R | null = null;
|
||||
|
||||
for (const item of collection) {
|
||||
const value = valueExtractor(item);
|
||||
if ((commonValue === null || commonValue === value) && value != null) {
|
||||
commonValue = value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
export const toLocalPoint = (p: GlobalPoint, element: ExcalidrawElement) =>
|
||||
pointTranslate<GlobalPoint, LocalPoint>(p, vector(-element.x, -element.y));
|
||||
|
@ -13,7 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/element/src/*.d.ts"
|
||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@ -50,7 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@ -14,10 +15,10 @@ export interface Alignment {
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
@ -32,13 +33,12 @@ export const alignElements = (
|
||||
);
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = scene.mutateElement(element, {
|
||||
const updatedEle = mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
});
|
||||
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene, {
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,27 +1,19 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
} from "@excalidraw/common";
|
||||
import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
@ -33,8 +25,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@ -45,27 +37,17 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type {
|
||||
Arrowhead,
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@ -272,82 +254,50 @@ export const getElementAbsoluteCoords = (
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
*/
|
||||
/**
|
||||
* Given an element, return the line segments that make up the element.
|
||||
*
|
||||
* Uses helpers from /math
|
||||
*/
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
const center: GlobalPoint = pointFrom(cx, cy);
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
pointRotateRads(
|
||||
pointFrom(
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom(
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
} else if (shape.type === "polyline") {
|
||||
return shape.data as LineSegment<GlobalPoint>[];
|
||||
} else if (_isRectanguloidElement(element)) {
|
||||
const [sides, corners] = deconstructRectanguloidElement(element);
|
||||
const cornerSegments: LineSegment<GlobalPoint>[] = corners
|
||||
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
|
||||
.flat();
|
||||
const rotatedSides = getRotatedSides(sides, center, element.angle);
|
||||
return [...rotatedSides, ...cornerSegments];
|
||||
} else if (element.type === "diamond") {
|
||||
const [sides, corners] = deconstructDiamondElement(element);
|
||||
const cornerSegments = corners
|
||||
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
|
||||
.flat();
|
||||
const rotatedSides = getRotatedSides(sides, center, element.angle);
|
||||
|
||||
return [...rotatedSides, ...cornerSegments];
|
||||
} else if (shape.type === "polygon") {
|
||||
if (isTextElement(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (container && isLinearElement(container)) {
|
||||
const segments: LineSegment<GlobalPoint>[] = [
|
||||
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
|
||||
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
|
||||
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
|
||||
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
|
||||
];
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
const points = shape.data as GlobalPoint[];
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
segments.push(lineSegment(points[i], points[i + 1]));
|
||||
}
|
||||
return segments;
|
||||
} else if (shape.type === "ellipse") {
|
||||
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
|
||||
}
|
||||
|
||||
const [nw, ne, sw, se, , , w, e] = (
|
||||
const [nw, ne, sw, se, n, s, w, e] = (
|
||||
[
|
||||
[x1, y1],
|
||||
[x2, y1],
|
||||
@ -360,6 +310,28 @@ export const getElementLineSegments = (
|
||||
] as GlobalPoint[]
|
||||
).map((point) => pointRotateRads(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
lineSegment(nw, ne),
|
||||
lineSegment(sw, se),
|
||||
@ -372,94 +344,6 @@ export const getElementLineSegments = (
|
||||
];
|
||||
};
|
||||
|
||||
const _isRectanguloidElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawRectanguloidElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
const getRotatedSides = (
|
||||
sides: LineSegment<GlobalPoint>[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
) => {
|
||||
return sides.map((side) => {
|
||||
return lineSegment(
|
||||
pointRotateRads<GlobalPoint>(side[0], center, angle),
|
||||
pointRotateRads<GlobalPoint>(side[1], center, angle),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getSegmentsOnCurve = (
|
||||
curve: Curve<GlobalPoint>,
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const points = pointsOnBezierCurves(curve, 10);
|
||||
let i = 0;
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
center,
|
||||
angle,
|
||||
),
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
center,
|
||||
angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
};
|
||||
|
||||
const getSegmentsOnEllipse = (
|
||||
ellipse: ExcalidrawEllipseElement,
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
ellipse.x + ellipse.width / 2,
|
||||
ellipse.y + ellipse.height / 2,
|
||||
);
|
||||
|
||||
const a = ellipse.width / 2;
|
||||
const b = ellipse.height / 2;
|
||||
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
const points: GlobalPoint[] = [];
|
||||
const n = 90;
|
||||
const deltaT = (Math.PI * 2) / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = i * deltaT;
|
||||
const x = center[0] + a * Math.cos(t);
|
||||
const y = center[1] + b * Math.sin(t);
|
||||
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
|
||||
}
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
segments.push(lineSegment(points[i], points[i + 1]));
|
||||
}
|
||||
|
||||
segments.push(lineSegment(points[points.length - 1], points[0]));
|
||||
return segments;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
*
|
||||
@ -944,10 +828,10 @@ export const getElementBounds = (
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: ElementsMapOrArray,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
elementsMap?: ElementsMap,
|
||||
): Bounds => {
|
||||
if (!sizeOf(elements)) {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
@ -16,7 +16,7 @@ import {
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
import { getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
GlobalPoint,
|
||||
@ -26,6 +26,8 @@ import type {
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GeometricShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
@ -189,7 +191,10 @@ const intersectRectanguloidWithLineSegment = (
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
@ -248,7 +253,10 @@ const intersectDiamondWithLineSegment = (
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
@ -296,7 +304,10 @@ const intersectEllipseWithLineSegment = (
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
@ -14,8 +14,6 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
@ -63,7 +61,7 @@ export const cropElement = (
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
elementCenterPoint(element),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@ -54,7 +53,10 @@ const distanceToRectanguloidElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
@ -82,7 +84,10 @@ const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
@ -110,7 +115,10 @@ const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
|
@ -11,10 +11,13 @@ import type {
|
||||
PointerDownState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
@ -26,8 +29,6 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
@ -103,7 +104,7 @@ export const dragSelectedElements = (
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||
if (!isArrowElement(element)) {
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
@ -111,14 +112,9 @@ export const dragSelectedElements = (
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (textElement) {
|
||||
updateElementCoords(
|
||||
pointerDownState,
|
||||
textElement,
|
||||
scene,
|
||||
adjustedOffset,
|
||||
);
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
updateBoundElements(element, scene, {
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
}
|
||||
@ -159,7 +155,6 @@ const calculateOffset = (
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const originalElement =
|
||||
@ -168,7 +163,7 @@ const updateElementCoords = (
|
||||
const nextX = originalElement.x + dragOffset.x;
|
||||
const nextY = originalElement.y + dragOffset.y;
|
||||
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
@ -195,7 +190,6 @@ export const dragNewElement = ({
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
zoom,
|
||||
scene,
|
||||
widthAspectRatio = null,
|
||||
originOffset = null,
|
||||
informMutation = true,
|
||||
@ -211,7 +205,6 @@ export const dragNewElement = ({
|
||||
shouldMaintainAspectRatio: boolean;
|
||||
shouldResizeFromCenter: boolean;
|
||||
zoom: NormalizedZoomValue;
|
||||
scene: Scene;
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null;
|
||||
@ -292,7 +285,7 @@ export const dragNewElement = ({
|
||||
};
|
||||
}
|
||||
|
||||
scene.mutateElement(
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
@ -302,7 +295,7 @@ export const dragNewElement = ({
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
{ informMutation, isDragging: false },
|
||||
informMutation,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -36,7 +36,7 @@ import {
|
||||
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
|
||||
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
||||
import { fixBindingsAfterDuplication } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
@ -57,14 +57,16 @@ import type {
|
||||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
randomizeSeed?: boolean,
|
||||
): Readonly<TElement> => {
|
||||
const copy = deepCopyElement(element);
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(copy, element.id);
|
||||
@ -87,6 +89,9 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
@ -94,14 +99,9 @@ export const duplicateElements = (
|
||||
opts: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
randomizeSeed?: boolean;
|
||||
overrides?: (data: {
|
||||
duplicateElement: ExcalidrawElement;
|
||||
origElement: ExcalidrawElement;
|
||||
origIdToDuplicateId: Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>;
|
||||
}) => Partial<ExcalidrawElement>;
|
||||
overrides?: (
|
||||
originalElement: ExcalidrawElement,
|
||||
) => Partial<ExcalidrawElement>;
|
||||
} & (
|
||||
| {
|
||||
/**
|
||||
@ -129,6 +129,14 @@ export const duplicateElements = (
|
||||
editingGroupId: AppState["editingGroupId"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
};
|
||||
/**
|
||||
* If true, duplicated elements are inserted _before_ specified
|
||||
* elements. Case: alt-dragging elements to duplicate them.
|
||||
*
|
||||
* TODO: remove this once (if) we stop replacing the original element
|
||||
* with the duplicated one in the scene array.
|
||||
*/
|
||||
reverseOrder: boolean;
|
||||
}
|
||||
),
|
||||
) => {
|
||||
@ -142,6 +150,8 @@ export const duplicateElements = (
|
||||
selectedGroupIds: {},
|
||||
} as const);
|
||||
|
||||
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false;
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
// into the array twice if we end up backtracking when retrieving
|
||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
||||
@ -154,17 +164,10 @@ export const duplicateElements = (
|
||||
// loop over them.
|
||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
||||
const groupIdMap = new Map();
|
||||
const duplicatedElements: ExcalidrawElement[] = [];
|
||||
const origElements: ExcalidrawElement[] = [];
|
||||
const origIdToDuplicateId = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement["id"]
|
||||
>();
|
||||
const duplicateIdToOrigElement = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>();
|
||||
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const newElements: ExcalidrawElement[] = [];
|
||||
const oldElements: ExcalidrawElement[] = [];
|
||||
const oldIdToDuplicatedId = new Map();
|
||||
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
|
||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
||||
const _idsOfElementsToDuplicate =
|
||||
opts.type === "in-place"
|
||||
@ -182,7 +185,7 @@ export const duplicateElements = (
|
||||
|
||||
elements = normalizeElementOrder(elements);
|
||||
|
||||
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
|
||||
const elementsWithClones: ExcalidrawElement[] = elements.slice();
|
||||
|
||||
// helper functions
|
||||
// -------------------------------------------------------------------------
|
||||
@ -208,17 +211,17 @@ export const duplicateElements = (
|
||||
appState.editingGroupId,
|
||||
groupIdMap,
|
||||
element,
|
||||
opts.overrides?.(element),
|
||||
opts.randomizeSeed,
|
||||
);
|
||||
|
||||
processedIds.set(newElement.id, true);
|
||||
|
||||
duplicateElementsMap.set(newElement.id, newElement);
|
||||
origIdToDuplicateId.set(element.id, newElement.id);
|
||||
duplicateIdToOrigElement.set(newElement.id, element);
|
||||
duplicatedElementsMap.set(newElement.id, newElement);
|
||||
oldIdToDuplicatedId.set(element.id, newElement.id);
|
||||
|
||||
origElements.push(element);
|
||||
duplicatedElements.push(newElement);
|
||||
oldElements.push(element);
|
||||
newElements.push(newElement);
|
||||
|
||||
acc.push(newElement);
|
||||
return acc;
|
||||
@ -242,12 +245,21 @@ export const duplicateElements = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (index > elementsWithDuplicates.length - 1) {
|
||||
elementsWithDuplicates.push(...castArray(elements));
|
||||
if (reverseOrder && index < 1) {
|
||||
elementsWithClones.unshift(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
||||
if (!reverseOrder && index > elementsWithClones.length - 1) {
|
||||
elementsWithClones.push(...castArray(elements));
|
||||
return;
|
||||
}
|
||||
|
||||
elementsWithClones.splice(
|
||||
index + (reverseOrder ? 0 : 1),
|
||||
0,
|
||||
...castArray(elements),
|
||||
);
|
||||
};
|
||||
|
||||
const frameIdsToDuplicate = new Set(
|
||||
@ -279,7 +291,11 @@ export const duplicateElements = (
|
||||
: [element],
|
||||
);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
const targetIndex = reverseOrder
|
||||
? elementsWithClones.findIndex((el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
})
|
||||
: findLastIndex(elementsWithClones, (el) => {
|
||||
return el.groupIds?.includes(groupId);
|
||||
});
|
||||
|
||||
@ -299,7 +315,7 @@ export const duplicateElements = (
|
||||
|
||||
const frameChildren = getFrameChildren(elements, frameId);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.frameId === frameId || el.id === frameId;
|
||||
});
|
||||
|
||||
@ -316,7 +332,7 @@ export const duplicateElements = (
|
||||
if (hasBoundTextElement(element)) {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return (
|
||||
el.id === element.id ||
|
||||
("containerId" in el && el.containerId === element.id)
|
||||
@ -325,7 +341,7 @@ export const duplicateElements = (
|
||||
|
||||
if (boundTextElement) {
|
||||
insertBeforeOrAfterIndex(
|
||||
targetIndex,
|
||||
targetIndex + (reverseOrder ? -1 : 0),
|
||||
copyElements([element, boundTextElement]),
|
||||
);
|
||||
} else {
|
||||
@ -338,7 +354,7 @@ export const duplicateElements = (
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
|
||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
||||
const targetIndex = findLastIndex(elementsWithClones, (el) => {
|
||||
return el.id === element.id || el.id === container?.id;
|
||||
});
|
||||
|
||||
@ -358,46 +374,28 @@ export const duplicateElements = (
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
insertBeforeOrAfterIndex(
|
||||
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
|
||||
findLastIndex(elementsWithClones, (el) => el.id === element.id),
|
||||
copyElements(element),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fixDuplicatedBindingsAfterDuplication(
|
||||
duplicatedElements,
|
||||
origIdToDuplicateId,
|
||||
duplicateElementsMap as NonDeletedSceneElementsMap,
|
||||
fixBindingsAfterDuplication(
|
||||
newElements,
|
||||
oldIdToDuplicatedId,
|
||||
duplicatedElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
bindElementsToFramesAfterDuplication(
|
||||
elementsWithDuplicates,
|
||||
origElements,
|
||||
origIdToDuplicateId,
|
||||
elementsWithClones,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
if (opts.overrides) {
|
||||
for (const duplicateElement of duplicatedElements) {
|
||||
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
||||
if (origElement) {
|
||||
Object.assign(
|
||||
duplicateElement,
|
||||
opts.overrides({
|
||||
duplicateElement,
|
||||
origElement,
|
||||
origIdToDuplicateId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
duplicatedElements,
|
||||
duplicateElementsMap,
|
||||
elementsWithDuplicates,
|
||||
origIdToDuplicateId,
|
||||
newElements,
|
||||
elementsWithClones,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -50,6 +50,7 @@ import { isBindableElement } from "./typeChecks";
|
||||
import {
|
||||
type ExcalidrawElbowArrowElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
@ -886,7 +887,7 @@ export const updateElbowArrowPoints = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
updates: {
|
||||
points?: readonly LocalPoint[];
|
||||
fixedSegments?: readonly FixedSegment[] | null;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
},
|
||||
@ -1253,6 +1254,7 @@ const getElbowArrowData = (
|
||||
"start",
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredStartElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
@ -1266,18 +1268,21 @@ const getElbowArrowData = (
|
||||
"end",
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredEndElement,
|
||||
options?.isDragging,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
startGlobalPoint,
|
||||
endGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
elementsMap,
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
);
|
||||
@ -2209,6 +2214,7 @@ const getGlobalPoint = (
|
||||
startOrEnd: "start" | "end",
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
@ -2218,6 +2224,7 @@ const getGlobalPoint = (
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return snapToMid(element, snapPoint);
|
||||
@ -2237,7 +2244,7 @@ const getGlobalPoint = (
|
||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
||||
FIXED_BINDING_DISTANCE,
|
||||
) > 0.01
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
|
||||
: fixedGlobalPoint;
|
||||
}
|
||||
|
||||
@ -2247,6 +2254,7 @@ const getGlobalPoint = (
|
||||
const getBindPointHeading = (
|
||||
p: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
): Heading =>
|
||||
|
@ -33,8 +33,6 @@ const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||
|
||||
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER =
|
||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||
@ -71,7 +69,6 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
@ -85,7 +82,6 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
"forms.microsoft.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
@ -210,10 +206,6 @@ export const getEmbedLink = (
|
||||
};
|
||||
}
|
||||
|
||||
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
|
||||
link += link.includes("?") ? "&embed=true" : "?embed=true";
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
const postId = link.match(RE_TWITTER)![1];
|
||||
// the embed srcdoc still supports twitter.com domain only.
|
||||
|
@ -39,8 +39,6 @@ import {
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
const VERTICAL_OFFSET = 100;
|
||||
@ -238,11 +236,10 @@ const getOffsets = (
|
||||
|
||||
const addNewNode = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const successors = getSuccessors(element, elementsMap, direction);
|
||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||
|
||||
@ -277,9 +274,9 @@ const addNewNode = (
|
||||
const bindingArrow = createBindingArrow(
|
||||
element,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
scene,
|
||||
);
|
||||
|
||||
return {
|
||||
@ -290,9 +287,9 @@ const addNewNode = (
|
||||
|
||||
export const addNewNodes = (
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
numberOfNodes: number,
|
||||
) => {
|
||||
// always start from 0 and distribute evenly
|
||||
@ -355,9 +352,9 @@ export const addNewNodes = (
|
||||
const bindingArrow = createBindingArrow(
|
||||
startNode,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
scene,
|
||||
);
|
||||
|
||||
newNodes.push(nextNode);
|
||||
@ -370,9 +367,9 @@ export const addNewNodes = (
|
||||
const createBindingArrow = (
|
||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
@ -443,10 +440,18 @@ const createBindingArrow = (
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"start",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"end",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
@ -462,18 +467,12 @@ const createBindingArrow = (
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
bindingArrow,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
1,
|
||||
LinearElementEditor.movePoints(bindingArrow, [
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
]);
|
||||
|
||||
const update = updateElbowArrowPoints(
|
||||
bindingArrow,
|
||||
@ -633,17 +632,16 @@ export class FlowChartCreator {
|
||||
|
||||
createNodes(
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
scene: Scene,
|
||||
) {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
if (direction !== this.direction) {
|
||||
const { nextNode, bindingArrow } = addNewNode(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
);
|
||||
|
||||
this.numberOfNodes = 1;
|
||||
@ -654,9 +652,9 @@ export class FlowChartCreator {
|
||||
this.numberOfNodes += 1;
|
||||
const newNodes = addNewNodes(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
scene,
|
||||
this.numberOfNodes,
|
||||
);
|
||||
|
||||
@ -684,9 +682,13 @@ export class FlowChartCreator {
|
||||
)
|
||||
) {
|
||||
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||
mutateElement(node, elementsMap, {
|
||||
mutateElement(
|
||||
node,
|
||||
{
|
||||
frameId: startNode.frameId,
|
||||
}),
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
@ -153,10 +152,9 @@ export const orderByFractionalIndex = (
|
||||
*/
|
||||
export const syncMovedIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: ElementsMap,
|
||||
movedElements: Map<string, ExcalidrawElement>,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
try {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
@ -178,7 +176,7 @@ export const syncMovedIndices = (
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default sync
|
||||
@ -196,12 +194,10 @@ export const syncMovedIndices = (
|
||||
export const syncInvalidIndices = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): OrderedExcalidrawElement[] => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
mutateElement(element, update, false);
|
||||
}
|
||||
|
||||
return elements as OrderedExcalidrawElement[];
|
||||
@ -214,7 +210,7 @@ export const syncInvalidIndices = (
|
||||
*/
|
||||
const getMovedIndicesGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
movedElements: ElementsMap,
|
||||
movedElements: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
const indicesGroups: number[][] = [];
|
||||
|
||||
|
@ -3,6 +3,8 @@ import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
@ -27,8 +29,6 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
@ -41,24 +41,30 @@ import type {
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
nextElements: readonly ExcalidrawElement[],
|
||||
origElements: readonly ExcalidrawElement[],
|
||||
origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
|
||||
for (const element of origElements) {
|
||||
for (const element of oldElements) {
|
||||
if (element.frameId) {
|
||||
// use its frameId to get the new frameId
|
||||
const nextElementId = origIdToDuplicateId.get(element.id);
|
||||
const nextFrameId = origIdToDuplicateId.get(element.frameId);
|
||||
const nextElement = nextElementId && nextElementMap.get(nextElementId);
|
||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
||||
if (nextElementId) {
|
||||
const nextElement = nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(nextElement, nextElementMap, {
|
||||
frameId: nextFrameId ?? null,
|
||||
});
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? element.frameId,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -561,9 +567,13 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
||||
}
|
||||
|
||||
for (const element of finalElementsToAdd) {
|
||||
mutateElement(element, elementsMap, {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return allElements;
|
||||
@ -601,9 +611,13 @@ export const removeElementsFromFrame = (
|
||||
}
|
||||
|
||||
for (const [, element] of _elementsToRemove) {
|
||||
mutateElement(element, elementsMap, {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: null,
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -905,16 +919,13 @@ export const shouldApplyFrameClip = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const DEFAULT_FRAME_NAME = "Frame";
|
||||
const DEFAULT_AI_FRAME_NAME = "AI Frame";
|
||||
|
||||
export const getDefaultFrameName = (element: ExcalidrawFrameLikeElement) => {
|
||||
// TODO name frames "AI" only if specific to AI frames
|
||||
return isFrameElement(element) ? DEFAULT_FRAME_NAME : DEFAULT_AI_FRAME_NAME;
|
||||
};
|
||||
|
||||
export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
return element.name === null ? getDefaultFrameName(element) : element.name;
|
||||
// TODO name frames "AI" only if specific to AI frames
|
||||
return element.name === null
|
||||
? isFrameElement(element)
|
||||
? "Frame"
|
||||
: "AI Frame"
|
||||
: element.name;
|
||||
};
|
||||
|
||||
export const getElementsOverlappingFrame = (
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
pointsEqual,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
@ -17,6 +13,7 @@ import type {
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
@ -29,6 +26,24 @@ export const HEADING_LEFT = [-1, 0] as Heading;
|
||||
export const HEADING_UP = [0, -1] as Heading;
|
||||
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
||||
|
||||
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
) => {
|
||||
const angle = radiansToDegrees(
|
||||
normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
|
||||
);
|
||||
|
||||
if (angle >= 315 || angle < 45) {
|
||||
return HEADING_UP;
|
||||
} else if (angle >= 45 && angle < 135) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (angle >= 135 && angle < 225) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
return HEADING_LEFT;
|
||||
};
|
||||
|
||||
export const vectorToHeading = (vec: Vector): Heading => {
|
||||
const [x, y] = vec;
|
||||
const absX = Math.abs(x);
|
||||
@ -61,165 +76,6 @@ export const headingIsHorizontal = (a: Heading) =>
|
||||
|
||||
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||
|
||||
const headingForPointFromDiamondElement = (
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
point: Readonly<GlobalPoint>,
|
||||
): Heading => {
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (isDevEnv() || isTestEnv()) {
|
||||
invariant(
|
||||
element.width > 0 && element.height > 0,
|
||||
"Diamond element has no width or height",
|
||||
);
|
||||
invariant(
|
||||
!pointsEqual(midPoint, point),
|
||||
"The point is too close to the element mid point to determine heading",
|
||||
);
|
||||
}
|
||||
|
||||
const SHRINK = 0.95; // Rounded elements tolerance
|
||||
const top = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const right = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const bottom = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
const left = pointFromVector(
|
||||
vectorScale(
|
||||
vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
element.angle,
|
||||
),
|
||||
midPoint,
|
||||
),
|
||||
SHRINK,
|
||||
),
|
||||
midPoint,
|
||||
);
|
||||
|
||||
// Corners
|
||||
if (
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
|
||||
) {
|
||||
return headingForPoint(top, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, right),
|
||||
vectorFromPoint(right, bottom),
|
||||
) <= 0 &&
|
||||
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
|
||||
) {
|
||||
return headingForPoint(right, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, left),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, bottom),
|
||||
vectorFromPoint(bottom, right),
|
||||
) > 0
|
||||
) {
|
||||
return headingForPoint(bottom, midPoint);
|
||||
} else if (
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
|
||||
0 &&
|
||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
|
||||
) {
|
||||
return headingForPoint(left, midPoint);
|
||||
}
|
||||
|
||||
// Sides
|
||||
if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(top, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? top : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(right, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : right;
|
||||
return headingForPoint(p, midPoint);
|
||||
} else if (
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(bottom, midPoint),
|
||||
) <= 0 &&
|
||||
vectorCross(
|
||||
vectorFromPoint(point, midPoint),
|
||||
vectorFromPoint(left, midPoint),
|
||||
) > 0
|
||||
) {
|
||||
const p = element.width > element.height ? bottom : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
}
|
||||
|
||||
const p = element.width > element.height ? top : left;
|
||||
return headingForPoint(p, midPoint);
|
||||
};
|
||||
|
||||
// Gets the heading for the point by creating a bounding box around the rotated
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
@ -233,7 +89,74 @@ export const headingForPointFromElement = <Point extends GlobalPoint>(
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return headingForPointFromDiamondElement(element, aabb, p);
|
||||
if (p[0] < element.x) {
|
||||
return HEADING_LEFT;
|
||||
} else if (p[1] < element.y) {
|
||||
return HEADING_UP;
|
||||
} else if (p[0] > element.x + element.width) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (p[1] > element.y + element.height) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
if (
|
||||
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
|
||||
) {
|
||||
return headingForDiamond(top, right);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[right, bottom, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(right, bottom);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[bottom, left, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
|
||||
return headingForDiamond(left, top);
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
@ -7,7 +5,6 @@ import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ElementsMapOrArray,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
@ -19,10 +16,12 @@ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
/**
|
||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||
*/
|
||||
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
export const hashElementsVersion = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): number => {
|
||||
let hash = 5381;
|
||||
for (const element of toIterable(elements)) {
|
||||
hash = (hash << 5) + hash + element.versionNonce;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
@ -72,47 +71,3 @@ export const clearElementsForExport = (
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
export * from "./collision";
|
||||
export * from "./comparisons";
|
||||
export * from "./containerCache";
|
||||
export * from "./cropElement";
|
||||
export * from "./delta";
|
||||
export * from "./distance";
|
||||
export * from "./distribute";
|
||||
export * from "./dragElements";
|
||||
export * from "./duplicate";
|
||||
export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./groups";
|
||||
export * from "./heading";
|
||||
export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
export * from "./store";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
|
@ -20,7 +20,11 @@ import {
|
||||
tupleToCoors,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { Store } from "@excalidraw/element";
|
||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { Store } from "@excalidraw/excalidraw/store";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
@ -38,6 +42,7 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
getHoveredElementForBinding,
|
||||
getOutlineAvoidingPoint,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import {
|
||||
@ -46,8 +51,10 @@ import {
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { headingIsHorizontal, vectorToHeading } from "./heading";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import {
|
||||
isBindingElement,
|
||||
@ -67,8 +74,6 @@ import {
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
NonDeleted,
|
||||
@ -80,11 +85,16 @@ import type {
|
||||
ElementsMap,
|
||||
NonDeletedSceneElementsMap,
|
||||
FixedPointBinding,
|
||||
SceneElementsMap,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
version: number | null;
|
||||
points: (GlobalPoint | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@ -118,17 +128,14 @@ export class LinearElementEditor {
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
||||
}
|
||||
|
||||
this.selectedPointsIndices = null;
|
||||
this.lastUncommittedPoint = null;
|
||||
this.isDragging = false;
|
||||
@ -245,27 +252,28 @@ export class LinearElementEditor {
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
): LinearElementEditor | null {
|
||||
if (!linearElementEditor) {
|
||||
return null;
|
||||
}
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elbowed = isElbowArrow(element);
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
elbowed &&
|
||||
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
|
||||
linearElementEditor.pointerDownState.lastClickedPoint !== 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedPointsIndices = isElbowArrow(element)
|
||||
const selectedPointsIndices = elbowed
|
||||
? [
|
||||
!!linearElementEditor.selectedPointsIndices?.includes(0)
|
||||
? 0
|
||||
@ -275,7 +283,7 @@ export class LinearElementEditor {
|
||||
: undefined,
|
||||
].filter((idx): idx is number => idx !== undefined)
|
||||
: linearElementEditor.selectedPointsIndices;
|
||||
const lastClickedPoint = isElbowArrow(element)
|
||||
const lastClickedPoint = elbowed
|
||||
? linearElementEditor.pointerDownState.lastClickedPoint > 0
|
||||
? element.points.length - 1
|
||||
: 0
|
||||
@ -302,22 +310,16 @@ export class LinearElementEditor {
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedIndex,
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
]);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@ -332,39 +334,56 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map(
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD]
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
)
|
||||
: pointFrom(
|
||||
let newPointPosition = pointFrom<LocalPoint>(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
return [
|
||||
|
||||
// Check if point dragging is happening
|
||||
if (pointIndex === lastClickedPoint) {
|
||||
let globalNewPointPosition = pointFrom<GlobalPoint>(
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
);
|
||||
|
||||
if (
|
||||
pointIndex === 0 ||
|
||||
pointIndex === element.points.length - 1
|
||||
) {
|
||||
globalNewPointPosition = getOutlineAvoidingPoint(
|
||||
element,
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.points[pointIndex][0] + deltaX,
|
||||
element.y + element.points[pointIndex][1] + deltaY,
|
||||
),
|
||||
pointIndex,
|
||||
{
|
||||
app.scene,
|
||||
app.state.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
newPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
globalNewPointPosition[0],
|
||||
globalNewPointPosition[1],
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging: pointIndex === lastClickedPoint,
|
||||
},
|
||||
];
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, scene, false);
|
||||
handleBindTextResize(element, elementsMap, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
@ -459,21 +478,15 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
selectedPoint,
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedPoint,
|
||||
point:
|
||||
selectedPoint === 0
|
||||
? element.points[element.points.length - 1]
|
||||
: element.points[0],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
]);
|
||||
}
|
||||
|
||||
const bindingElement = isBindingEnabled(appState)
|
||||
@ -531,7 +544,7 @@ export class LinearElementEditor {
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): (GlobalPoint | null)[] => {
|
||||
): typeof editorMidPointsCache["points"] => {
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
@ -543,7 +556,25 @@ export class LinearElementEditor {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
editorMidPointsCache.version === element.version &&
|
||||
editorMidPointsCache.zoom === appState.zoom.value
|
||||
) {
|
||||
return editorMidPointsCache.points;
|
||||
}
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
return editorMidPointsCache.points!;
|
||||
};
|
||||
|
||||
static updateEditorMidPointsCache = (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
element,
|
||||
elementsMap,
|
||||
@ -575,8 +606,9 @@ export class LinearElementEditor {
|
||||
midpoints.push(segmentMidPoint);
|
||||
index++;
|
||||
}
|
||||
|
||||
return midpoints;
|
||||
editorMidPointsCache.points = midpoints;
|
||||
editorMidPointsCache.version = element.version;
|
||||
editorMidPointsCache.zoom = appState.zoom.value;
|
||||
};
|
||||
|
||||
static getSegmentMidpointHitCoords = (
|
||||
@ -630,11 +662,8 @@ export class LinearElementEditor {
|
||||
}
|
||||
}
|
||||
let index = 0;
|
||||
const midPoints = LinearElementEditor.getEditorMidPoints(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
);
|
||||
const midPoints: typeof editorMidPointsCache["points"] =
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
@ -791,7 +820,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
LinearElementEditor.createPointAt(
|
||||
@ -805,7 +834,7 @@ export class LinearElementEditor {
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
store.scheduleCapture();
|
||||
store.shouldCaptureIncrement();
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
@ -857,6 +886,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
@ -929,13 +959,13 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
): LinearElementEditor | null {
|
||||
const appState = app.state;
|
||||
if (!appState.editingLinearElement) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
@ -946,9 +976,7 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app.scene, [
|
||||
points.length - 1,
|
||||
]);
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@ -986,20 +1014,14 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
app.scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: newPoint,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
]);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||
LinearElementEditor.addPoints(element, [{ point: newPoint }]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@ -1163,26 +1185,23 @@ export class LinearElementEditor {
|
||||
y: element.y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
// element-mutating methods
|
||||
// ---------------------------------------------------------------------------
|
||||
static normalizePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizedPoints(element),
|
||||
);
|
||||
|
||||
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
mutateElement(element, LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
static duplicateSelectedPoints(
|
||||
appState: AppState,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
"Not currently editing a linear element",
|
||||
);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
@ -1225,22 +1244,18 @@ export class LinearElementEditor {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
scene.mutateElement(element, { points: nextPoints });
|
||||
mutateElement(element, { points: nextPoints });
|
||||
|
||||
// temp hack to ensure the line doesn't move when adding point to the end,
|
||||
// potentially expanding the bounding box
|
||||
if (pointAddedToEnd) {
|
||||
const lastPoint = element.points[element.points.length - 1];
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
new Map([
|
||||
[
|
||||
element.points.length - 1,
|
||||
{ point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30) },
|
||||
],
|
||||
]),
|
||||
);
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -1254,7 +1269,6 @@ export class LinearElementEditor {
|
||||
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
let offsetX = 0;
|
||||
@ -1285,41 +1299,28 @@ export class LinearElementEditor {
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
) {
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY);
|
||||
}
|
||||
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
pointUpdates: PointsPositionUpdates,
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
endBinding?: PointBinding | null;
|
||||
},
|
||||
sceneElementsMap?: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
@ -1329,7 +1330,8 @@ export class LinearElementEditor {
|
||||
// offset it. We do the same with actual element.x/y position, so
|
||||
// this hacks are completely transparent to the user.
|
||||
const [deltaX, deltaY] =
|
||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||
targetPoints.find(({ index }) => index === 0)?.point ??
|
||||
pointFrom<LocalPoint>(0, 0);
|
||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
@ -1337,12 +1339,12 @@ export class LinearElementEditor {
|
||||
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
pointUpdates.get(0)?.point ?? points[0],
|
||||
pointUpdates.get(points.length - 1)?.point ??
|
||||
targetPoints.find((t) => t.index === 0)?.point ?? points[0],
|
||||
targetPoints.find((t) => t.index === points.length - 1)?.point ??
|
||||
points[points.length - 1],
|
||||
]
|
||||
: points.map((p, idx) => {
|
||||
const current = pointUpdates.get(idx)?.point ?? p;
|
||||
const current = targetPoints.find((t) => t.index === idx)?.point ?? p;
|
||||
|
||||
return pointFrom<LocalPoint>(
|
||||
current[0] - offsetX,
|
||||
@ -1352,13 +1354,17 @@ export class LinearElementEditor {
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
otherUpdates,
|
||||
{
|
||||
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
|
||||
isDragging: targetPoints.reduce(
|
||||
(dragging, targetPoint): boolean =>
|
||||
dragging || targetPoint.isDragging === true,
|
||||
false,
|
||||
),
|
||||
sceneElementsMap,
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1413,9 +1419,8 @@ export class LinearElementEditor {
|
||||
pointerCoords: PointerCoords,
|
||||
app: AppClassProperties,
|
||||
snapToGrid: boolean,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
@ -1445,7 +1450,9 @@ export class LinearElementEditor {
|
||||
...element.points.slice(segmentMidpoint.index!),
|
||||
];
|
||||
|
||||
scene.mutateElement(element, { points });
|
||||
mutateElement(element, {
|
||||
points,
|
||||
});
|
||||
|
||||
ret.pointerDownState = {
|
||||
...linearElementEditor.pointerDownState,
|
||||
@ -1461,7 +1468,6 @@ export class LinearElementEditor {
|
||||
|
||||
private static _updatePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
@ -1498,10 +1504,28 @@ export class LinearElementEditor {
|
||||
|
||||
updates.points = Array.from(nextPoints);
|
||||
|
||||
scene.mutateElement(element, updates, {
|
||||
informMutation: true,
|
||||
isDragging: options?.isDragging ?? false,
|
||||
if (!options?.sceneElementsMap || Scene.getScene(element)) {
|
||||
mutateElement(element, updates, true, {
|
||||
isDragging: options?.isDragging,
|
||||
});
|
||||
} else {
|
||||
// The element is not in the scene, so we need to use the provided
|
||||
// scene map.
|
||||
Object.assign(element, {
|
||||
...updates,
|
||||
angle: 0 as Radians,
|
||||
|
||||
...updateElbowArrowPoints(
|
||||
element,
|
||||
options.sceneElementsMap,
|
||||
updates,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
bumpVersion(element);
|
||||
} else {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
@ -1516,7 +1540,7 @@ export class LinearElementEditor {
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
);
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
x: element.x + rotated[0],
|
||||
@ -1575,7 +1599,7 @@ export class LinearElementEditor {
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length < 2) {
|
||||
mutateElement(boundTextElement, elementsMap, { isDeleted: true });
|
||||
mutateElement(boundTextElement, { isDeleted: true });
|
||||
}
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@ -1590,14 +1614,23 @@ export class LinearElementEditor {
|
||||
y = midPoint[1] - boundTextElement.height / 2;
|
||||
} else {
|
||||
const index = element.points.length / 2 - 1;
|
||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
|
||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||
if (element.points.length === 2) {
|
||||
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
||||
}
|
||||
if (
|
||||
!midSegmentMidpoint ||
|
||||
editorMidPointsCache.version !== element.version
|
||||
) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
element,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
}
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
}
|
||||
@ -1773,9 +1806,8 @@ export class LinearElementEditor {
|
||||
index: number,
|
||||
x: number,
|
||||
y: number,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
): LinearElementEditor {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElement.elementId,
|
||||
elementsMap,
|
||||
@ -1818,7 +1850,7 @@ export class LinearElementEditor {
|
||||
.map((segment) => segment.index)
|
||||
.reduce((count, idx) => (idx < index ? count + 1 : count), 0);
|
||||
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
fixedSegments: nextFixedSegments,
|
||||
});
|
||||
|
||||
@ -1852,14 +1884,14 @@ export class LinearElementEditor {
|
||||
|
||||
static deleteFixedSegment(
|
||||
element: ExcalidrawElbowArrowElement,
|
||||
scene: Scene,
|
||||
index: number,
|
||||
): void {
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
fixedSegments: element.fixedSegments?.filter(
|
||||
(segment) => segment.index !== index,
|
||||
),
|
||||
});
|
||||
mutateElement(element, {}, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,13 @@ import {
|
||||
getSizeFromPoints,
|
||||
randomInteger,
|
||||
getUpdatedTimestamp,
|
||||
toBrandedType,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
// TODO: remove direct dependency on the scene, should be passed in or injected instead
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
@ -11,42 +16,35 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "version" | "versionNonce" | "updated"
|
||||
>;
|
||||
|
||||
/**
|
||||
* This function tracks updates of text elements for the purposes for collaboration.
|
||||
* The version is used to compare updates when more than one user is working in
|
||||
* the same drawing.
|
||||
*
|
||||
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
|
||||
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
|
||||
*/
|
||||
// This function tracks updates of text elements for the purposes for collaboration.
|
||||
// The version is used to compare updates when more than one user is working in
|
||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
elementsMap: ElementsMap,
|
||||
updates: ElementUpdate<TElement>,
|
||||
informMutation = true,
|
||||
options?: {
|
||||
// Currently only for elbow arrows.
|
||||
// If true, the elbow arrow tries to bind to the nearest element. If false
|
||||
// it tries to keep the same bound element, if any.
|
||||
isDragging?: boolean;
|
||||
},
|
||||
) => {
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||
const { points, fixedSegments, fileId, startBinding, endBinding } =
|
||||
updates as any;
|
||||
|
||||
if (
|
||||
@ -57,6 +55,10 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
) {
|
||||
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
||||
);
|
||||
|
||||
updates = {
|
||||
...updates,
|
||||
angle: 0 as Radians,
|
||||
@ -66,9 +68,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
x: updates.x || element.x,
|
||||
y: updates.y || element.y,
|
||||
},
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
|
||||
options,
|
||||
elementsMap,
|
||||
{
|
||||
fixedSegments,
|
||||
points,
|
||||
startBinding,
|
||||
endBinding,
|
||||
},
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (typeof points !== "undefined") {
|
||||
@ -141,6 +150,10 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
|
@ -44,6 +44,7 @@ import type {
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
|
||||
@ -97,28 +98,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||
// element position or sizing
|
||||
if (
|
||||
x < -1e6 ||
|
||||
x > 1e6 ||
|
||||
y < -1e6 ||
|
||||
y > 1e6 ||
|
||||
width < -1e6 ||
|
||||
width > 1e6 ||
|
||||
height < -1e6 ||
|
||||
height > 1e6
|
||||
) {
|
||||
console.error("New element size or position is too large", {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
// @ts-ignore
|
||||
points: rest.points,
|
||||
});
|
||||
}
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
id: rest.id || randomId(),
|
||||
@ -477,7 +456,7 @@ export const newArrowElement = <T extends boolean>(
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawArrowElement["points"];
|
||||
elbowed?: T;
|
||||
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
} & ElementConstructorOpts,
|
||||
): T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
|
@ -351,20 +351,12 @@ const generateElementCanvas = (
|
||||
|
||||
export const DEFAULT_LINK_SIZE = 14;
|
||||
|
||||
const IMAGE_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
|
||||
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
|
||||
)}`;
|
||||
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG =
|
||||
typeof document !== "undefined"
|
||||
? document.createElement("img")
|
||||
: ({ src: "" } as HTMLImageElement); // mock image element outside of browser
|
||||
|
||||
const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
|
||||
IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
`<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
|
||||
)}`;
|
||||
|
@ -17,6 +17,8 @@ import {
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
@ -30,6 +32,7 @@ import {
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
@ -57,8 +60,6 @@ import {
|
||||
|
||||
import { isInGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type {
|
||||
MaybeTransformHandleType,
|
||||
@ -73,6 +74,7 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
SceneElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
|
||||
@ -81,6 +83,7 @@ export const transformElements = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: SceneElementsMap,
|
||||
scene: Scene,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
@ -90,31 +93,31 @@ export const transformElements = (
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
): boolean => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
if (selectedElements.length === 1) {
|
||||
const [element] = selectedElements;
|
||||
if (transformHandleType === "rotation") {
|
||||
if (!isElbowArrow(element)) {
|
||||
rotateSingleElement(
|
||||
element,
|
||||
elementsMap,
|
||||
scene,
|
||||
pointerX,
|
||||
pointerY,
|
||||
shouldRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
updateBoundElements(element, elementsMap);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
scene,
|
||||
elementsMap,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
updateBoundElements(element, elementsMap);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
const elementId = selectedElements[0].id;
|
||||
@ -126,6 +129,8 @@ export const transformElements = (
|
||||
getNextSingleWidthAndHeightFromPointer(
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElements,
|
||||
transformHandleType,
|
||||
pointerX,
|
||||
pointerY,
|
||||
@ -140,8 +145,8 @@ export const transformElements = (
|
||||
nextHeight,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElements,
|
||||
scene,
|
||||
transformHandleType,
|
||||
{
|
||||
shouldMaintainAspectRatio,
|
||||
@ -156,6 +161,7 @@ export const transformElements = (
|
||||
rotateMultipleElements(
|
||||
originalElements,
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene,
|
||||
pointerX,
|
||||
pointerY,
|
||||
@ -204,15 +210,13 @@ export const transformElements = (
|
||||
|
||||
const rotateSingleElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
shouldRotateWithDiscreteAngle: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
|
||||
element,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle: Radians;
|
||||
@ -229,13 +233,13 @@ const rotateSingleElement = (
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
scene.mutateElement(element, { angle });
|
||||
mutateElement(element, { angle });
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
scene.mutateElement(textElement, { angle });
|
||||
mutateElement(textElement, { angle });
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -285,13 +289,12 @@ export const measureFontSizeFromWidth = (
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
@ -390,7 +393,7 @@ const resizeSingleTextElement = (
|
||||
);
|
||||
const [nextX, nextY] = newTopLeft;
|
||||
|
||||
scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
fontSize: metrics.size,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
@ -505,13 +508,14 @@ const resizeSingleTextElement = (
|
||||
autoResize: false,
|
||||
};
|
||||
|
||||
scene.mutateElement(element, resizedElement);
|
||||
mutateElement(element, resizedElement);
|
||||
}
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: SceneElementsMap,
|
||||
scene: Scene,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
@ -519,7 +523,6 @@ const rotateMultipleElements = (
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
let centerAngle =
|
||||
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
@ -540,30 +543,38 @@ const rotateMultipleElements = (
|
||||
(centerAngle + origAngle - element.angle) as Radians,
|
||||
);
|
||||
|
||||
const updates = isElbowArrow(element)
|
||||
? {
|
||||
if (isElbowArrow(element)) {
|
||||
// Needed to re-route the arrow
|
||||
mutateElement(element, {
|
||||
points: getArrowLocalFixedPoints(element, elementsMap),
|
||||
}
|
||||
: {
|
||||
});
|
||||
} else {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
};
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
scene.mutateElement(element, updates);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
updateBoundElements(element, elementsMap, {
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
scene.mutateElement(boundText, {
|
||||
mutateElement(
|
||||
boundText,
|
||||
{
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -808,8 +819,8 @@ export const resizeSingleElement = (
|
||||
nextHeight: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
handleDirection: TransformHandleDirection,
|
||||
{
|
||||
shouldInformMutation = true,
|
||||
@ -822,7 +833,6 @@ export const resizeSingleElement = (
|
||||
} = {},
|
||||
) => {
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
@ -922,7 +932,7 @@ export const resizeSingleElement = (
|
||||
}
|
||||
|
||||
if ("scale" in latestElement && "scale" in origElement) {
|
||||
scene.mutateElement(latestElement, {
|
||||
mutateElement(latestElement, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
|
||||
@ -957,33 +967,29 @@ export const resizeSingleElement = (
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
scene.mutateElement(latestElement, updates, {
|
||||
informMutation: shouldInformMutation,
|
||||
isDragging: false,
|
||||
});
|
||||
mutateElement(latestElement, updates, shouldInformMutation);
|
||||
|
||||
updateBoundElements(latestElement, elementsMap as SceneElementsMap);
|
||||
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
scene,
|
||||
elementsMap,
|
||||
handleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getNextSingleWidthAndHeightFromPointer = (
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
handleDirection: TransformHandleDirection,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
@ -1516,26 +1522,28 @@ export const resizeMultipleElements = (
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
const { angle } = update;
|
||||
|
||||
scene.mutateElement(element, update, {
|
||||
informMutation: true,
|
||||
mutateElement(element, update, false, {
|
||||
// needed for the fixed binding point udpate to take effect
|
||||
isDragging: true,
|
||||
});
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
updateBoundElements(element, elementsMap as SceneElementsMap, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
});
|
||||
handleBindTextResize(element, scene, handleDirection, true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, elementsMap, handleDirection, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||
import { isShallowEqual } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
@ -7,20 +7,13 @@ import type {
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
@ -169,6 +162,25 @@ export const isSomeElementSelected = (function () {
|
||||
return ret;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
||||
* elements. If elements don't share the same value, returns `null`.
|
||||
*/
|
||||
export const getCommonAttributeOfSelectedElements = <T>(
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Pick<AppState, "selectedElementIds">,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
): T | null => {
|
||||
const attributes = Array.from(
|
||||
new Set(
|
||||
getSelectedElements(elements, appState).map((element) =>
|
||||
getAttribute(element),
|
||||
),
|
||||
),
|
||||
);
|
||||
return attributes.length === 1 ? attributes[0] : null;
|
||||
};
|
||||
|
||||
export const getSelectedElements = (
|
||||
elements: ElementsMapOrArray,
|
||||
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
|
||||
@ -242,49 +254,3 @@ export const makeNextSelectedElementIds = (
|
||||
|
||||
return nextSelectedElementIds;
|
||||
};
|
||||
|
||||
const _getLinearElementEditor = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const linears = targetElements.filter(isLinearElement);
|
||||
if (linears.length === 1) {
|
||||
const linear = linears[0];
|
||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||
const onlySingleLinearSelected = targetElements.every(
|
||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||
);
|
||||
|
||||
if (onlySingleLinearSelected) {
|
||||
return new LinearElementEditor(linear, arrayToMap(allElements));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getSelectionStateForElements = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
allElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
return {
|
||||
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||
targetElements,
|
||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
allElements,
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
@ -298,7 +297,7 @@ export const aabbForElement = (
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom(bbox.midX, bbox.midY);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
|
@ -14,7 +14,6 @@ export const showSelectedShapeActions = (
|
||||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingTextElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "lasso" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
@ -169,6 +170,41 @@ export const getLockedLinearCursorAlignSize = (
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const resizePerfectLineForNWHandler = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const anchorX = element.x + element.width;
|
||||
const anchorY = element.y + element.height;
|
||||
const distanceToAnchorX = x - anchorX;
|
||||
const distanceToAnchorY = y - anchorY;
|
||||
if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
|
||||
mutateElement(element, {
|
||||
x: anchorX,
|
||||
width: 0,
|
||||
y,
|
||||
height: -distanceToAnchorY,
|
||||
});
|
||||
} else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
|
||||
mutateElement(element, {
|
||||
y: anchorY,
|
||||
height: 0,
|
||||
});
|
||||
} else {
|
||||
const nextHeight =
|
||||
Math.sign(distanceToAnchorY) *
|
||||
Math.sign(distanceToAnchorX) *
|
||||
element.width;
|
||||
mutateElement(element, {
|
||||
x,
|
||||
y: anchorY - nextHeight,
|
||||
width: -distanceToAnchorX,
|
||||
height: nextHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getNormalizedDimensions = (
|
||||
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
|
||||
): {
|
||||
|
@ -1,972 +0,0 @@
|
||||
import {
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
randomId,
|
||||
Emitter,
|
||||
toIterable,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type App from "@excalidraw/excalidraw/components/App";
|
||||
|
||||
import type { DTO, ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { AppState, ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { deepCopyElement } from "./duplicate";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||
|
||||
import { hashElementsVersion, hashString } from "./index";
|
||||
|
||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
||||
|
||||
export const CaptureUpdateAction = {
|
||||
/**
|
||||
* Immediately undoable.
|
||||
*
|
||||
* Use for updates which should be captured.
|
||||
* Should be used for most of the local updates, except ephemerals such as dragging or resizing.
|
||||
*
|
||||
* These updates will _immediately_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
IMMEDIATELY: "IMMEDIATELY",
|
||||
/**
|
||||
* Never undoable.
|
||||
*
|
||||
* Use for updates which should never be recorded, such as remote updates
|
||||
* or scene initialization.
|
||||
*
|
||||
* These updates will _never_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
NEVER: "NEVER",
|
||||
/**
|
||||
* Eventually undoable.
|
||||
*
|
||||
* Use for updates which should not be captured immediately - likely
|
||||
* exceptions which are part of some async multi-step process. Otherwise, all
|
||||
* such updates would end up being captured with the next
|
||||
* `CaptureUpdateAction.IMMEDIATELY` - triggered either by the next `updateScene`
|
||||
* or internally by the editor.
|
||||
*
|
||||
* These updates will _eventually_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
EVENTUALLY: "EVENTUALLY",
|
||||
} as const;
|
||||
|
||||
export type CaptureUpdateActionType = ValueOf<typeof CaptureUpdateAction>;
|
||||
|
||||
type MicroActionsQueue = (() => void)[];
|
||||
|
||||
/**
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
|
||||
private scheduledMacroActions: Set<CaptureUpdateActionType> = new Set();
|
||||
private scheduledMicroActions: MicroActionsQueue = [];
|
||||
|
||||
private _snapshot = StoreSnapshot.empty();
|
||||
|
||||
public get snapshot() {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
public set snapshot(snapshot: StoreSnapshot) {
|
||||
this._snapshot = snapshot;
|
||||
}
|
||||
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
public scheduleAction(action: CaptureUpdateActionType) {
|
||||
this.scheduledMacroActions.add(action);
|
||||
this.satisfiesScheduledActionsInvariant();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to schedule a delta calculation, which will consquentially be emitted as `DurableStoreIncrement` and pushed in the undo stack.
|
||||
*/
|
||||
// TODO: Suspicious that this is called so many places. Seems error-prone.
|
||||
public scheduleCapture() {
|
||||
this.scheduleAction(CaptureUpdateAction.IMMEDIATELY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule special "micro" actions, to-be executed before the next commit, before it executes a scheduled "macro" action.
|
||||
*/
|
||||
public scheduleMicroAction(
|
||||
params:
|
||||
| {
|
||||
action: CaptureUpdateActionType;
|
||||
elements: SceneElementsMap | undefined;
|
||||
appState: AppState | ObservedAppState | undefined;
|
||||
}
|
||||
| {
|
||||
action: typeof CaptureUpdateAction.IMMEDIATELY;
|
||||
change: StoreChange;
|
||||
delta: StoreDelta;
|
||||
}
|
||||
| {
|
||||
action:
|
||||
| typeof CaptureUpdateAction.NEVER
|
||||
| typeof CaptureUpdateAction.EVENTUALLY;
|
||||
change: StoreChange;
|
||||
},
|
||||
) {
|
||||
const { action } = params;
|
||||
|
||||
let change: StoreChange;
|
||||
|
||||
if ("change" in params) {
|
||||
change = params.change;
|
||||
} else {
|
||||
// immediately create an immutable change of the scheduled updates,
|
||||
// compared to the current state, so that they won't mutate later on during batching
|
||||
const currentSnapshot = StoreSnapshot.create(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
);
|
||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||
action,
|
||||
params.elements,
|
||||
params.appState,
|
||||
);
|
||||
|
||||
change = StoreChange.create(currentSnapshot, scheduledSnapshot);
|
||||
}
|
||||
|
||||
const delta = "delta" in params ? params.delta : undefined;
|
||||
|
||||
this.scheduledMicroActions.push(() =>
|
||||
this.processAction({
|
||||
action,
|
||||
change,
|
||||
delta,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the incoming `CaptureUpdateAction` and emits the corresponding `StoreIncrement`.
|
||||
* Emits `DurableStoreIncrement` when action is "capture", emits `EphemeralStoreIncrement` otherwise.
|
||||
*
|
||||
* @emits StoreIncrement
|
||||
*/
|
||||
public commit(
|
||||
elements: SceneElementsMap | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
): void {
|
||||
// execute all scheduled micro actions first
|
||||
// similar to microTasks, there can be many
|
||||
this.flushMicroActions();
|
||||
|
||||
try {
|
||||
// execute a single scheduled "macro" function
|
||||
// similar to macro tasks, there can be only one within a single commit (loop)
|
||||
const action = this.getScheduledMacroAction();
|
||||
this.processAction({ action, elements, appState });
|
||||
} finally {
|
||||
this.satisfiesScheduledActionsInvariant();
|
||||
// defensively reset all scheduled "macro" actions, possibly cleans up other runtime garbage
|
||||
this.scheduledMacroActions = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the store instance.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.snapshot = StoreSnapshot.empty();
|
||||
this.scheduledMacroActions = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs delta & change calculation and emits a durable increment.
|
||||
*
|
||||
* @emits StoreIncrement.
|
||||
*/
|
||||
private emitDurableIncrement(
|
||||
snapshot: StoreSnapshot,
|
||||
change: StoreChange | undefined = undefined,
|
||||
delta: StoreDelta | undefined = undefined,
|
||||
) {
|
||||
const prevSnapshot = this.snapshot;
|
||||
|
||||
let storeChange: StoreChange;
|
||||
let storeDelta: StoreDelta;
|
||||
|
||||
if (change) {
|
||||
storeChange = change;
|
||||
} else {
|
||||
storeChange = StoreChange.create(prevSnapshot, snapshot);
|
||||
}
|
||||
|
||||
if (delta) {
|
||||
// we might have the delta already (i.e. when applying history entry), thus we don't need to calculate it again
|
||||
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
||||
storeDelta = delta;
|
||||
} else {
|
||||
// calculate the deltas based on the previous and next snapshot
|
||||
const elementsDelta = snapshot.metadata.didElementsChange
|
||||
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
|
||||
: ElementsDelta.empty();
|
||||
|
||||
const appStateDelta = snapshot.metadata.didAppStateChange
|
||||
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
|
||||
: AppStateDelta.empty();
|
||||
|
||||
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
|
||||
}
|
||||
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs change calculation and emits an ephemeral increment.
|
||||
*
|
||||
* @emits EphemeralStoreIncrement
|
||||
*/
|
||||
private emitEphemeralIncrement(
|
||||
snapshot: StoreSnapshot,
|
||||
change: StoreChange | undefined = undefined,
|
||||
) {
|
||||
let storeChange: StoreChange;
|
||||
|
||||
if (change) {
|
||||
storeChange = change;
|
||||
} else {
|
||||
const prevSnapshot = this.snapshot;
|
||||
storeChange = StoreChange.create(prevSnapshot, snapshot);
|
||||
}
|
||||
|
||||
const increment = new EphemeralIncrement(storeChange);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
|
||||
private applyChangeToSnapshot(change: StoreChange) {
|
||||
const prevSnapshot = this.snapshot;
|
||||
const nextSnapshot = this.snapshot.applyChange(change);
|
||||
|
||||
if (prevSnapshot === nextSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the snapshot if there are changes detected.
|
||||
*/
|
||||
private maybeCloneSnapshot(
|
||||
action: CaptureUpdateActionType,
|
||||
elements: SceneElementsMap | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
) {
|
||||
if (!elements && !appState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevSnapshot = this.snapshot;
|
||||
const nextSnapshot = this.snapshot.maybeClone(action, elements, appState);
|
||||
|
||||
if (prevSnapshot === nextSnapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextSnapshot;
|
||||
}
|
||||
|
||||
private flushMicroActions() {
|
||||
for (const microAction of this.scheduledMicroActions) {
|
||||
try {
|
||||
microAction();
|
||||
} catch (error) {
|
||||
console.error(`Failed to execute scheduled micro action`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledMicroActions = [];
|
||||
}
|
||||
|
||||
private processAction(
|
||||
params:
|
||||
| {
|
||||
action: CaptureUpdateActionType;
|
||||
elements: SceneElementsMap | undefined;
|
||||
appState: AppState | ObservedAppState | undefined;
|
||||
}
|
||||
| {
|
||||
action: CaptureUpdateActionType;
|
||||
change: StoreChange;
|
||||
delta: StoreDelta | undefined;
|
||||
},
|
||||
) {
|
||||
const { action } = params;
|
||||
|
||||
// perf. optimisation, since "EVENTUALLY" does not update the snapshot,
|
||||
// so if nobody is listening for increments, we don't need to even clone the snapshot
|
||||
// as it's only needed for `StoreChange` computation inside `EphemeralIncrement`
|
||||
if (
|
||||
action === CaptureUpdateAction.EVENTUALLY &&
|
||||
!this.onStoreIncrementEmitter.subscribers.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextSnapshot: StoreSnapshot | null;
|
||||
|
||||
if ("change" in params) {
|
||||
nextSnapshot = this.applyChangeToSnapshot(params.change);
|
||||
} else {
|
||||
nextSnapshot = this.maybeCloneSnapshot(
|
||||
action,
|
||||
params.elements,
|
||||
params.appState,
|
||||
);
|
||||
}
|
||||
|
||||
if (!nextSnapshot) {
|
||||
// don't continue if there is not change detected
|
||||
return;
|
||||
}
|
||||
|
||||
const change = "change" in params ? params.change : undefined;
|
||||
const delta = "delta" in params ? params.delta : undefined;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
// only immediately emits a durable increment
|
||||
case CaptureUpdateAction.IMMEDIATELY:
|
||||
this.emitDurableIncrement(nextSnapshot, change, delta);
|
||||
break;
|
||||
// both never and eventually emit an ephemeral increment
|
||||
case CaptureUpdateAction.NEVER:
|
||||
case CaptureUpdateAction.EVENTUALLY:
|
||||
this.emitEphemeralIncrement(nextSnapshot, change);
|
||||
break;
|
||||
default:
|
||||
assertNever(action, `Unknown store action`);
|
||||
}
|
||||
} finally {
|
||||
// update the snapshot no-matter what, as it would mess up with the next action
|
||||
switch (action) {
|
||||
// both immediately and never update the snapshot, unlike eventually
|
||||
case CaptureUpdateAction.IMMEDIATELY:
|
||||
case CaptureUpdateAction.NEVER:
|
||||
this.snapshot = nextSnapshot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scheduled macro action.
|
||||
*/
|
||||
private getScheduledMacroAction() {
|
||||
let scheduledAction: CaptureUpdateActionType;
|
||||
|
||||
if (this.scheduledMacroActions.has(CaptureUpdateAction.IMMEDIATELY)) {
|
||||
// Capture has a precedence over update, since it also performs snapshot update
|
||||
scheduledAction = CaptureUpdateAction.IMMEDIATELY;
|
||||
} else if (this.scheduledMacroActions.has(CaptureUpdateAction.NEVER)) {
|
||||
// Update has a precedence over none, since it also emits an (ephemeral) increment
|
||||
scheduledAction = CaptureUpdateAction.NEVER;
|
||||
} else {
|
||||
// Default is to emit ephemeral increment and don't update the snapshot
|
||||
scheduledAction = CaptureUpdateAction.EVENTUALLY;
|
||||
}
|
||||
|
||||
return scheduledAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the scheduled actions invariant is satisfied.
|
||||
*/
|
||||
private satisfiesScheduledActionsInvariant() {
|
||||
if (
|
||||
!(
|
||||
this.scheduledMacroActions.size >= 0 &&
|
||||
this.scheduledMacroActions.size <=
|
||||
Object.keys(CaptureUpdateAction).length
|
||||
)
|
||||
) {
|
||||
const message = `There can be at most three store actions scheduled at the same time, but there are "${this.scheduledMacroActions.size}".`;
|
||||
console.error(message, this.scheduledMacroActions.values());
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repsents a change to the store containing changed elements and appState.
|
||||
*/
|
||||
export class StoreChange {
|
||||
// so figuring out what has changed should ideally be just quick reference checks
|
||||
// TODO: we might need to have binary files here as well, in order to be drop-in replacement for `onChange`
|
||||
private constructor(
|
||||
public readonly elements: Record<string, OrderedExcalidrawElement>,
|
||||
public readonly appState: Partial<ObservedAppState>,
|
||||
) {}
|
||||
|
||||
public static create(
|
||||
prevSnapshot: StoreSnapshot,
|
||||
nextSnapshot: StoreSnapshot,
|
||||
) {
|
||||
const changedElements = nextSnapshot.getChangedElements(prevSnapshot);
|
||||
const changedAppState = nextSnapshot.getChangedAppState(prevSnapshot);
|
||||
|
||||
return new StoreChange(changedElements, changedAppState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encpasulates any change to the store (durable or ephemeral).
|
||||
*/
|
||||
export abstract class StoreIncrement {
|
||||
protected constructor(
|
||||
public readonly type: "durable" | "ephemeral",
|
||||
public readonly change: StoreChange,
|
||||
) {}
|
||||
|
||||
public static isDurable(
|
||||
increment: StoreIncrement,
|
||||
): increment is DurableIncrement {
|
||||
return increment.type === "durable";
|
||||
}
|
||||
|
||||
public static isEphemeral(
|
||||
increment: StoreIncrement,
|
||||
): increment is EphemeralIncrement {
|
||||
return increment.type === "ephemeral";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a durable change to the store.
|
||||
*/
|
||||
export class DurableIncrement extends StoreIncrement {
|
||||
constructor(
|
||||
public readonly change: StoreChange,
|
||||
public readonly delta: StoreDelta,
|
||||
) {
|
||||
super("durable", change);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an ephemeral change to the store.
|
||||
*/
|
||||
export class EphemeralIncrement extends StoreIncrement {
|
||||
constructor(public readonly change: StoreChange) {
|
||||
super("ephemeral", change);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a captured delta by the Store.
|
||||
*/
|
||||
export class StoreDelta {
|
||||
protected constructor(
|
||||
public readonly id: string,
|
||||
public readonly elements: ElementsDelta,
|
||||
public readonly appState: AppStateDelta,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new instance of `StoreDelta`.
|
||||
*/
|
||||
public static create(
|
||||
elements: ElementsDelta,
|
||||
appState: AppStateDelta,
|
||||
opts: {
|
||||
id: string;
|
||||
} = {
|
||||
id: randomId(),
|
||||
},
|
||||
) {
|
||||
return new this(opts.id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a store delta instance from a DTO.
|
||||
*/
|
||||
public static restore(storeDeltaDTO: DTO<StoreDelta>) {
|
||||
const { id, elements, appState } = storeDeltaDTO;
|
||||
return new this(
|
||||
id,
|
||||
ElementsDelta.restore(elements),
|
||||
AppStateDelta.restore(appState),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and load the delta from the remote payload.
|
||||
*/
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated, {
|
||||
shouldRedistribute: false,
|
||||
});
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static inverse(delta: StoreDelta): StoreDelta {
|
||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static applyLatestChanges(
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
modifierOptions: "deleted" | "inserted",
|
||||
): StoreDelta {
|
||||
return this.create(
|
||||
delta.elements.applyLatestChanges(elements, modifierOptions),
|
||||
delta.appState,
|
||||
{
|
||||
id: delta.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||
*/
|
||||
public static applyTo(
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
prevSnapshot.elements,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
|
||||
const appliedVisibleChanges =
|
||||
elementsContainVisibleChange || appStateContainsVisibleChange;
|
||||
|
||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a snapshot of the captured or updated changes in the store,
|
||||
* used for producing deltas and emitting `DurableStoreIncrement`s.
|
||||
*/
|
||||
export class StoreSnapshot {
|
||||
private _lastChangedElementsHash: number = 0;
|
||||
private _lastChangedAppStateHash: number = 0;
|
||||
|
||||
private constructor(
|
||||
public readonly elements: SceneElementsMap,
|
||||
public readonly appState: ObservedAppState,
|
||||
public readonly metadata: {
|
||||
didElementsChange: boolean;
|
||||
didAppStateChange: boolean;
|
||||
isEmpty?: boolean;
|
||||
} = {
|
||||
didElementsChange: false,
|
||||
didAppStateChange: false,
|
||||
isEmpty: false,
|
||||
},
|
||||
) {}
|
||||
|
||||
public static create(
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState | ObservedAppState,
|
||||
metadata: {
|
||||
didElementsChange: boolean;
|
||||
didAppStateChange: boolean;
|
||||
} = {
|
||||
didElementsChange: false,
|
||||
didAppStateChange: false,
|
||||
},
|
||||
) {
|
||||
return new StoreSnapshot(
|
||||
elements,
|
||||
isObservedAppState(appState) ? appState : getObservedAppState(appState),
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return new StoreSnapshot(
|
||||
new Map() as SceneElementsMap,
|
||||
getDefaultObservedAppState(),
|
||||
{
|
||||
didElementsChange: false,
|
||||
didAppStateChange: false,
|
||||
isEmpty: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getChangedElements(prevSnapshot: StoreSnapshot) {
|
||||
const changedElements: Record<string, OrderedExcalidrawElement> = {};
|
||||
|
||||
for (const prevElement of toIterable(prevSnapshot.elements)) {
|
||||
const nextElement = this.elements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
changedElements[prevElement.id] = newElementWith(prevElement, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const nextElement of toIterable(this.elements)) {
|
||||
// Due to the structural clone inside `maybeClone`, we can perform just these reference checks
|
||||
if (prevSnapshot.elements.get(nextElement.id) !== nextElement) {
|
||||
changedElements[nextElement.id] = nextElement;
|
||||
}
|
||||
}
|
||||
|
||||
return changedElements;
|
||||
}
|
||||
|
||||
public getChangedAppState(
|
||||
prevSnapshot: StoreSnapshot,
|
||||
): Partial<ObservedAppState> {
|
||||
return Delta.getRightDifferences(
|
||||
prevSnapshot.appState,
|
||||
this.appState,
|
||||
).reduce(
|
||||
(acc, key) =>
|
||||
Object.assign(acc, {
|
||||
[key]: this.appState[key as keyof ObservedAppState],
|
||||
}),
|
||||
{} as Partial<ObservedAppState>,
|
||||
);
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.metadata.isEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the change and return a new snapshot instance.
|
||||
*/
|
||||
public applyChange(change: StoreChange): StoreSnapshot {
|
||||
const nextElements = new Map(this.elements) as SceneElementsMap;
|
||||
|
||||
for (const [id, changedElement] of Object.entries(change.elements)) {
|
||||
nextElements.set(id, changedElement);
|
||||
}
|
||||
|
||||
const nextAppState = Object.assign(
|
||||
{},
|
||||
this.appState,
|
||||
change.appState,
|
||||
) as ObservedAppState;
|
||||
|
||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||
// by default we assume that change is different from what we have in the snapshot
|
||||
// so that we trigger the delta calculation and if it isn't different, delta will be empty
|
||||
didElementsChange: Object.keys(change.elements).length > 0,
|
||||
didAppStateChange: Object.keys(change.appState).length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently clone the existing snapshot, only if we detected changes.
|
||||
*
|
||||
* @returns same instance if there are no changes detected, new instance otherwise.
|
||||
*/
|
||||
public maybeClone(
|
||||
action: CaptureUpdateActionType,
|
||||
elements: SceneElementsMap | undefined,
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
) {
|
||||
const options = {
|
||||
shouldCompareHashes: false,
|
||||
};
|
||||
|
||||
if (action === CaptureUpdateAction.EVENTUALLY) {
|
||||
// actions that do not update the snapshot immediately, must be additionally checked for changes against the latest hash
|
||||
// as we are always comparing against the latest snapshot, so they would emit elements or appState as changed on every component update
|
||||
// instead of just the first time the elements or appState actually changed
|
||||
options.shouldCompareHashes = true;
|
||||
}
|
||||
|
||||
const nextElementsSnapshot = this.maybeCreateElementsSnapshot(
|
||||
elements,
|
||||
options,
|
||||
);
|
||||
const nextAppStateSnapshot = this.maybeCreateAppStateSnapshot(
|
||||
appState,
|
||||
options,
|
||||
);
|
||||
|
||||
let didElementsChange = false;
|
||||
let didAppStateChange = false;
|
||||
|
||||
if (this.elements !== nextElementsSnapshot) {
|
||||
didElementsChange = true;
|
||||
}
|
||||
|
||||
if (this.appState !== nextAppStateSnapshot) {
|
||||
didAppStateChange = true;
|
||||
}
|
||||
|
||||
if (!didElementsChange && !didAppStateChange) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const snapshot = new StoreSnapshot(
|
||||
nextElementsSnapshot,
|
||||
nextAppStateSnapshot,
|
||||
{
|
||||
didElementsChange,
|
||||
didAppStateChange,
|
||||
},
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private maybeCreateAppStateSnapshot(
|
||||
appState: AppState | ObservedAppState | undefined,
|
||||
options: {
|
||||
shouldCompareHashes: boolean;
|
||||
} = {
|
||||
shouldCompareHashes: false,
|
||||
},
|
||||
): ObservedAppState {
|
||||
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,
|
||||
options,
|
||||
);
|
||||
|
||||
if (!didAppStateChange) {
|
||||
return this.appState;
|
||||
}
|
||||
|
||||
return nextAppStateSnapshot;
|
||||
}
|
||||
|
||||
private maybeCreateElementsSnapshot(
|
||||
elements: SceneElementsMap | undefined,
|
||||
options: {
|
||||
shouldCompareHashes: boolean;
|
||||
} = {
|
||||
shouldCompareHashes: false,
|
||||
},
|
||||
): SceneElementsMap {
|
||||
if (!elements) {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
const changedElements = this.detectChangedElements(elements, options);
|
||||
|
||||
if (!changedElements?.size) {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
const elementsSnapshot = this.createElementsSnapshot(changedElements);
|
||||
return elementsSnapshot;
|
||||
}
|
||||
|
||||
private detectChangedAppState(
|
||||
nextObservedAppState: ObservedAppState,
|
||||
options: {
|
||||
shouldCompareHashes: boolean;
|
||||
} = {
|
||||
shouldCompareHashes: false,
|
||||
},
|
||||
): boolean | undefined {
|
||||
if (this.appState === nextObservedAppState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didAppStateChange = Delta.isRightDifferent(
|
||||
this.appState,
|
||||
nextObservedAppState,
|
||||
);
|
||||
|
||||
if (!didAppStateChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedAppStateHash = hashString(
|
||||
JSON.stringify(nextObservedAppState),
|
||||
);
|
||||
|
||||
if (
|
||||
options.shouldCompareHashes &&
|
||||
this._lastChangedAppStateHash === changedAppStateHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastChangedAppStateHash = changedAppStateHash;
|
||||
|
||||
return didAppStateChange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if there any changed elements.
|
||||
*/
|
||||
private detectChangedElements(
|
||||
nextElements: SceneElementsMap,
|
||||
options: {
|
||||
shouldCompareHashes: boolean;
|
||||
} = {
|
||||
shouldCompareHashes: false,
|
||||
},
|
||||
): SceneElementsMap | undefined {
|
||||
if (this.elements === nextElements) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedElements: SceneElementsMap = new Map() as SceneElementsMap;
|
||||
|
||||
for (const prevElement of toIterable(this.elements)) {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
// element was deleted
|
||||
changedElements.set(
|
||||
prevElement.id,
|
||||
newElementWith(prevElement, { isDeleted: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nextElement of toIterable(nextElements)) {
|
||||
const prevElement = this.elements.get(nextElement.id);
|
||||
|
||||
if (
|
||||
!prevElement || // element was added
|
||||
prevElement.version < nextElement.version // element was updated
|
||||
) {
|
||||
changedElements.set(nextElement.id, nextElement);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedElements.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedElementsHash = hashElementsVersion(changedElements);
|
||||
|
||||
if (
|
||||
options.shouldCompareHashes &&
|
||||
this._lastChangedElementsHash === changedElementsHash
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastChangedElementsHash = changedElementsHash;
|
||||
|
||||
return changedElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform structural clone, deep cloning only elements that changed.
|
||||
*/
|
||||
private createElementsSnapshot(changedElements: SceneElementsMap) {
|
||||
const clonedElements = new Map() as SceneElementsMap;
|
||||
|
||||
for (const prevElement of toIterable(this.elements)) {
|
||||
// Clone previous elements, never delete, in case nextElements would be just a subset of previous elements
|
||||
// i.e. during collab, persist or whenenever isDeleted elements get cleared
|
||||
clonedElements.set(prevElement.id, prevElement);
|
||||
}
|
||||
|
||||
for (const changedElement of toIterable(changedElements)) {
|
||||
// TODO: consider just creating new instance, once we can ensure that all reference properties on every element are immutable
|
||||
// TODO: consider creating a lazy deep clone, having a one-time-usage proxy over the snapshotted element and deep cloning only if it gets mutated
|
||||
clonedElements.set(changedElement.id, deepCopyElement(changedElement));
|
||||
}
|
||||
|
||||
return clonedElements;
|
||||
}
|
||||
}
|
||||
|
||||
// hidden non-enumerable property for runtime checks
|
||||
const hiddenObservedAppStateProp = "__observedAppState";
|
||||
|
||||
const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
return {
|
||||
name: null,
|
||||
editingGroupId: null,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
};
|
||||
|
||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
const observedAppState = {
|
||||
name: appState.name,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
return observedAppState;
|
||||
};
|
||||
|
||||
const isObservedAppState = (
|
||||
appState: AppState | ObservedAppState,
|
||||
): appState is ObservedAppState =>
|
||||
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
@ -6,22 +6,18 @@ import {
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getFontString,
|
||||
isProdEnv,
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
@ -30,8 +26,6 @@ import {
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
@ -46,30 +40,17 @@ import type {
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
informMutation = true,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
let maxWidth = undefined;
|
||||
|
||||
if (!isProdEnv()) {
|
||||
invariant(
|
||||
!container || !isArrowElement(container) || textElement.angle === 0,
|
||||
"text element angle must be 0 if bound to arrow container",
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
angle: (container
|
||||
? isArrowElement(container)
|
||||
? 0
|
||||
: container.angle
|
||||
: textElement.angle) as Radians,
|
||||
angle: container?.angle ?? textElement.angle,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
@ -109,43 +90,38 @@ export const redrawTextBoundingBox = (
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
scene.mutateElement(container, { height: nextHeight });
|
||||
mutateElement(container, { height: nextHeight }, informMutation);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
scene.mutateElement(container, { width: nextWidth });
|
||||
mutateElement(container, { width: nextWidth }, informMutation);
|
||||
}
|
||||
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
scene.mutateElement(textElement, boundTextUpdates);
|
||||
mutateElement(textElement, boundTextUpdates, informMutation);
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
scene: Scene,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
return;
|
||||
@ -198,20 +174,20 @@ export const handleBindTextResize = (
|
||||
transformHandleType === "n")
|
||||
? container.y - diff
|
||||
: container.y;
|
||||
scene.mutateElement(container, {
|
||||
mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
});
|
||||
}
|
||||
|
||||
scene.mutateElement(textElement, {
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
scene.mutateElement(
|
||||
mutateElement(
|
||||
textElement,
|
||||
computeBoundTextPosition(container, textElement, elementsMap),
|
||||
);
|
||||
@ -359,10 +335,7 @@ export const getTextElementAngle = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return 0;
|
||||
}
|
||||
if (!container) {
|
||||
if (!container || isArrowElement(container)) {
|
||||
return textElement.angle;
|
||||
}
|
||||
return container.angle;
|
||||
|
@ -28,7 +28,6 @@ import type {
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
@ -120,20 +119,6 @@ export const isElbowArrow = (
|
||||
return isArrowElement(element) && element.elbowed;
|
||||
};
|
||||
|
||||
export const isSharpArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return isArrowElement(element) && !element.elbowed && !element.roundness;
|
||||
};
|
||||
|
||||
export const isCurvedArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return (
|
||||
isArrowElement(element) && !element.elbowed && element.roundness !== null
|
||||
);
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
@ -286,10 +271,6 @@ export const isBoundToContainer = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
|
||||
return !!element.startBinding || !!element.endBinding;
|
||||
};
|
||||
|
||||
export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "rectangle" ||
|
||||
type === "embeddable" ||
|
||||
@ -357,18 +338,3 @@ export const isBounds = (box: unknown): box is Bounds =>
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
if (isSharpArrow(element)) {
|
||||
return "sharpArrow";
|
||||
}
|
||||
if (isCurvedArrow(element)) {
|
||||
return "curvedArrow";
|
||||
}
|
||||
if (isElbowArrow(element)) {
|
||||
return "elbowArrow";
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
@ -296,11 +296,6 @@ export type FixedPointBinding = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
number,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
@ -417,13 +412,3 @@ export type NonDeletedSceneElementsMap = Map<
|
||||
export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
||||
|
||||
export type ExcalidrawLinearElementSubType =
|
||||
| "line"
|
||||
| "sharpArrow"
|
||||
| "curvedArrow"
|
||||
| "elbowArrow";
|
||||
|
||||
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
|
||||
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
|
||||
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
|
||||
|
@ -10,8 +10,6 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "./shapes";
|
||||
@ -70,7 +68,10 @@ export function deconstructRectanguloidElement(
|
||||
return [sides, []];
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
@ -253,7 +254,10 @@ export function deconstructDiamondElement(
|
||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||
}
|
||||
|
||||
const center = elementCenterPoint(element);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
|
@ -2,6 +2,8 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { getElementsInGroup } from "./groups";
|
||||
@ -10,8 +12,6 @@ import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
|
@ -18,7 +18,9 @@ const mouse = new Pointer("mouse");
|
||||
|
||||
describe("element binding", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
mouse.reset();
|
||||
});
|
||||
|
||||
it("should create valid binding if duplicate start/end points", async () => {
|
||||
@ -89,8 +91,16 @@ describe("element binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
//@TODO fix the test with rotation
|
||||
it.skip("rotation of arrow should rebind both ends", () => {
|
||||
// UX RATIONALE: We are not aware of any use-case where the user would want to
|
||||
// have the arrow rebind after rotation but not when the arrow shaft is
|
||||
// dragged so either the start or the end point is in the binding range of a
|
||||
// bindable element. So to remain consistent, we only "rebind" if at the end
|
||||
// of the rotation the original binding would remain the same (i.e. like we
|
||||
// would've evaluated binding only at the end of the operation).
|
||||
it(
|
||||
"rotation of arrow should not rebind on both ends if rotated enough to" +
|
||||
" not be in the binding range of the original elements",
|
||||
() => {
|
||||
const rectLeft = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
width: 200,
|
||||
@ -123,12 +133,13 @@ describe("element binding", () => {
|
||||
mouse.up();
|
||||
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
|
||||
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
|
||||
expect(arrow.startBinding?.elementId).toBe(rectRight.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectLeft.id);
|
||||
});
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
},
|
||||
);
|
||||
|
||||
// TODO fix & reenable once we rewrite tests to work with concurrency
|
||||
it.skip(
|
||||
it(
|
||||
"editing arrow and moving its head to bind it to element A, finalizing the" +
|
||||
"editing by clicking on element A should end up selecting A",
|
||||
async () => {
|
||||
@ -142,7 +153,10 @@ describe("element binding", () => {
|
||||
mouse.up(0, 80);
|
||||
|
||||
// Edit arrow with multi-point
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
|
||||
// move arrow head
|
||||
mouse.down();
|
||||
mouse.up(0, 10);
|
||||
@ -152,11 +166,7 @@ describe("element binding", () => {
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
mouse.down(0, 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.state.editingLinearElement).toBe(null);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
mouse.up();
|
||||
mouse.click();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
},
|
||||
);
|
||||
@ -187,12 +197,24 @@ describe("element binding", () => {
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
|
||||
// Sever connection
|
||||
expect(API.getSelectedElement().type).toBe("arrow");
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
// We have to move a significant distance to get out of the binding zone
|
||||
Array.from({ length: 10 }).forEach(() => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
});
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
// We have to move a significant distance to return to the binding
|
||||
Array.from({ length: 10 }).forEach(() => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
});
|
||||
// We are back in the binding zone but we shouldn't rebind
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
});
|
||||
|
||||
@ -481,4 +503,82 @@ describe("element binding", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// UX RATIONALE: The arrow might be outside of the shape at high zoom and you
|
||||
// won't see what's going on.
|
||||
it(
|
||||
"allow non-binding simple (complex) arrow creation while start and end" +
|
||||
" points are in the same shape",
|
||||
() => {
|
||||
UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: 5,
|
||||
y: 5,
|
||||
height: 95,
|
||||
width: 95,
|
||||
});
|
||||
|
||||
expect(arrow.startBinding).toBe(null);
|
||||
expect(arrow.endBinding).toBe(null);
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[95, 95],
|
||||
]);
|
||||
|
||||
const rect2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 300,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "red",
|
||||
fillStyle: "solid",
|
||||
});
|
||||
|
||||
API.setElements([rect2]);
|
||||
|
||||
const arrow2 = UI.createElement("arrow", {
|
||||
x: 305,
|
||||
y: 305,
|
||||
height: 95,
|
||||
width: 95,
|
||||
});
|
||||
|
||||
expect(arrow2.startBinding).toBe(null);
|
||||
expect(arrow2.endBinding).toBe(null);
|
||||
expect(arrow2.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[95, 95],
|
||||
]);
|
||||
|
||||
UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow3 = UI.createElement("arrow", {
|
||||
x: 5,
|
||||
y: 5,
|
||||
height: 95,
|
||||
width: 95,
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
expect(arrow3.startBinding).toBe(null);
|
||||
expect(arrow3.endBinding).toBe(null);
|
||||
expect(arrow3.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[45, 45],
|
||||
[95, 95],
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
@ -1,149 +0,0 @@
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedElementIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id5: true, id2: true, id4: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id1: true,
|
||||
id2: true,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: { id4: true, id2: true, id5: true },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedElementIds: {
|
||||
id3: true,
|
||||
id2: true,
|
||||
id1: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
|
||||
it("should maintain stable order for selectedGroupIds", () => {
|
||||
const commonAppState = {
|
||||
name: "",
|
||||
viewBackgroundColor: "#ffffff",
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id0: true,
|
||||
id1: true,
|
||||
id2: false,
|
||||
id3: true,
|
||||
},
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
...commonAppState,
|
||||
selectedGroupIds: {
|
||||
id3: true,
|
||||
id2: false,
|
||||
id1: true,
|
||||
id0: true,
|
||||
},
|
||||
};
|
||||
|
||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
||||
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
@ -7,13 +8,16 @@ import {
|
||||
isPrimitive,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
||||
import {
|
||||
actionDuplicateSelection,
|
||||
actionSelectAll,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
@ -24,9 +28,13 @@ import {
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawLinearElement,
|
||||
} from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@ -61,11 +69,11 @@ describe("duplicating single elements", () => {
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, new Map(), {
|
||||
mutateElement(element, {
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element, true);
|
||||
const copy = duplicateElement(null, new Map(), element, undefined, true);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
@ -171,7 +179,7 @@ describe("duplicating multiple elements", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
@ -179,10 +187,10 @@ describe("duplicating multiple elements", () => {
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
duplicatedElements.map((e) => e.type),
|
||||
clonedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = duplicatedElements[idx];
|
||||
const clonedElement = clonedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(clonedElement.id),
|
||||
@ -215,12 +223,12 @@ describe("duplicating multiple elements", () => {
|
||||
});
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const clonedArrows = duplicatedElements.filter(
|
||||
const clonedArrows = clonedElements.filter(
|
||||
(e) => e.type === "arrow",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
duplicatedElements as any as typeof origElements;
|
||||
clonedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
@ -325,10 +333,10 @@ describe("duplicating multiple elements", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const duplicatedElements = duplicateElements({
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
}).duplicatedElements as any as typeof origElements;
|
||||
}) as any as { newElements: typeof origElements };
|
||||
|
||||
const [
|
||||
clonedRectangle,
|
||||
@ -336,7 +344,7 @@ describe("duplicating multiple elements", () => {
|
||||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = duplicatedElements;
|
||||
] = clonedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
@ -372,12 +380,12 @@ describe("duplicating multiple elements", () => {
|
||||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const { duplicatedElements } = duplicateElements({
|
||||
const { newElements: clonedElements } = duplicateElements({
|
||||
type: "everything",
|
||||
elements: origElements,
|
||||
});
|
||||
}) as any as { newElements: typeof origElements };
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
duplicatedElements;
|
||||
clonedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
@ -397,7 +405,7 @@ describe("duplicating multiple elements", () => {
|
||||
});
|
||||
|
||||
const {
|
||||
duplicatedElements: [clonedRectangle1],
|
||||
newElements: [clonedRectangle1],
|
||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
@ -406,114 +414,119 @@ describe("duplicating multiple elements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("group-related duplication", () => {
|
||||
describe("elbow arrow duplication", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
it("action-duplicating within group", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["group1"],
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
groupIds: ["group1"],
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe("group1");
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("alt-duplicating within group", async () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
groupIds: ["group1"],
|
||||
});
|
||||
|
||||
API.setElements([rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe("group1");
|
||||
});
|
||||
|
||||
it("alt-duplicating within group away outside frame", () => {
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 0,
|
||||
y: 0,
|
||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["group1"],
|
||||
frameId: frame.id,
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 50,
|
||||
height: 50,
|
||||
groupIds: ["group1"],
|
||||
frameId: frame.id,
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([frame, rectangle1, rectangle2]);
|
||||
API.setSelectedElements([rectangle2], "group1");
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
// console.log(h.elements);
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: frame.id },
|
||||
{ id: rectangle1.id, frameId: frame.id },
|
||||
{ id: rectangle2.id, frameId: frame.id },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(h.state.editingGroupId).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@ -612,8 +625,8 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
@ -647,8 +660,8 @@ describe("duplication z-order", () => {
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@ -678,8 +691,8 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
]);
|
||||
@ -714,19 +727,19 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle1.id },
|
||||
{ id: rectangle2.id },
|
||||
{ id: rectangle3.id },
|
||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle2.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle1.id },
|
||||
{ [ORIG_ID]: rectangle2.id },
|
||||
{ [ORIG_ID]: rectangle3.id },
|
||||
{ id: rectangle1.id, selected: true },
|
||||
{ id: rectangle2.id, selected: true },
|
||||
{ id: rectangle3.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating text container (in-order)", async () => {
|
||||
it("reverse-duplicating text container (in-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([rectangle, text]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
@ -734,20 +747,20 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id },
|
||||
{ id: text.id, containerId: rectangle.id },
|
||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
{ id: rectangle.id, selected: true },
|
||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating text container (out-of-order)", async () => {
|
||||
it("reverse-duplicating text container (out-of-order)", async () => {
|
||||
const [rectangle, text] = API.createTextContainer();
|
||||
API.setElements([text, rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
API.setSelectedElements([rectangle, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
||||
@ -755,21 +768,21 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: rectangle.id },
|
||||
{ id: text.id, containerId: rectangle.id },
|
||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
||||
{ [ORIG_ID]: rectangle.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
||||
},
|
||||
{ id: rectangle.id, selected: true },
|
||||
{ id: text.id, containerId: rectangle.id, selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating labeled arrows (in-order)", async () => {
|
||||
it("reverse-duplicating labeled arrows (in-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([arrow, text]);
|
||||
API.setSelectedElements([arrow]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
@ -777,24 +790,21 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: arrow.id },
|
||||
{ id: text.id, containerId: arrow.id },
|
||||
{ [ORIG_ID]: arrow.id, selected: true },
|
||||
{ [ORIG_ID]: arrow.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||
},
|
||||
{ id: arrow.id, selected: true },
|
||||
{ id: text.id, containerId: arrow.id, selected: true },
|
||||
]);
|
||||
expect(h.state.selectedLinearElement).toEqual(
|
||||
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("alt-duplicating labeled arrows (out-of-order)", async () => {
|
||||
it("reverse-duplicating labeled arrows (out-of-order)", async () => {
|
||||
const [arrow, text] = API.createLabeledArrow();
|
||||
|
||||
API.setElements([text, arrow]);
|
||||
API.setSelectedElements([arrow]);
|
||||
API.setSelectedElements([arrow, text]);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
||||
@ -802,50 +812,13 @@ describe("duplication z-order", () => {
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{ id: arrow.id },
|
||||
{ id: text.id, containerId: arrow.id },
|
||||
{ [ORIG_ID]: arrow.id, selected: true },
|
||||
{ [ORIG_ID]: arrow.id },
|
||||
{
|
||||
[ORIG_ID]: text.id,
|
||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
|
||||
const rect = UI.createElement("rectangle", {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -100,
|
||||
y: 50,
|
||||
width: 95,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
mouse.down(5, 5);
|
||||
mouse.up(15, 15);
|
||||
});
|
||||
|
||||
assertElements(h.elements, [
|
||||
{
|
||||
id: rect.id,
|
||||
boundElements: expect.arrayContaining([
|
||||
expect.objectContaining({ id: arrow.id }),
|
||||
]),
|
||||
},
|
||||
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
|
||||
{
|
||||
id: arrow.id,
|
||||
endBinding: expect.objectContaining({ elementId: rect.id }),
|
||||
},
|
||||
{ id: arrow.id, selected: true },
|
||||
{ id: text.id, containerId: arrow.id, selected: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryByTestId,
|
||||
@ -22,8 +20,6 @@ import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
@ -143,7 +139,7 @@ describe("elbow arrow routing", () => {
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
@ -188,14 +184,14 @@ describe("elbow arrow routing", () => {
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
scene.insertElement(arrow);
|
||||
|
||||
bindLinearElement(arrow, rectangle1, "start", scene);
|
||||
bindLinearElement(arrow, rectangle2, "end", scene);
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
bindLinearElement(arrow, rectangle1, "start", elementsMap);
|
||||
bindLinearElement(arrow, rectangle2, "end", elementsMap);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
@ -302,114 +298,4 @@ describe("elbow arrow ui", () => {
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -7,14 +7,13 @@ import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
} from "@excalidraw/element/types";
|
||||
@ -750,7 +749,7 @@ function testInvalidIndicesSync(args: {
|
||||
function prepareArguments(
|
||||
elementsLike: { id: string; index?: string }[],
|
||||
movedElementsIds?: string[],
|
||||
): [ExcalidrawElement[], ElementsMap | undefined] {
|
||||
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] {
|
||||
const elements = elementsLike.map((x) =>
|
||||
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
|
||||
);
|
||||
@ -765,7 +764,7 @@ function prepareArguments(
|
||||
function test(
|
||||
name: string,
|
||||
elements: ExcalidrawElement[],
|
||||
movedElements: ElementsMap | undefined,
|
||||
movedElements: Map<string, ExcalidrawElement> | undefined,
|
||||
expectUnchangedElements: Map<string, { id: string }>,
|
||||
expectValidInput?: boolean,
|
||||
) {
|
||||
|
@ -15,6 +15,8 @@ import {
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isLinearElement } from "../src/typeChecks";
|
||||
@ -195,7 +197,7 @@ describe("generic element", () => {
|
||||
UI.resize(rectangle, "w", [50, 0]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(81, 0);
|
||||
});
|
||||
|
||||
it("resizes with a label", async () => {
|
||||
@ -333,7 +335,7 @@ describe("line element", () => {
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
"ne",
|
||||
);
|
||||
|
||||
@ -369,7 +371,7 @@ describe("line element", () => {
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
"se",
|
||||
);
|
||||
|
||||
@ -424,7 +426,7 @@ describe("line element", () => {
|
||||
element,
|
||||
element,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
h.app.scene,
|
||||
h.app.scene.getNonDeletedElementsMap(),
|
||||
"e",
|
||||
{
|
||||
shouldResizeFromCenter: true,
|
||||
@ -826,8 +828,9 @@ describe("image element", () => {
|
||||
UI.resize(image, "nw", [50, 20]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||
30 + imageWidth * scale,
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(
|
||||
30 + imageWidth * scale + 1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@ -1003,14 +1006,14 @@ describe("multiple selection", () => {
|
||||
size: 100,
|
||||
});
|
||||
const leftBoundArrow = UI.createElement("arrow", {
|
||||
x: -110,
|
||||
x: -100 - FIXED_BINDING_DISTANCE,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const rightBoundArrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
x: 200 + FIXED_BINDING_DISTANCE,
|
||||
y: 50,
|
||||
width: -100,
|
||||
height: 0,
|
||||
@ -1031,27 +1034,29 @@ describe("multiple selection", () => {
|
||||
shift: true,
|
||||
});
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-100 - FIXED_BINDING_DISTANCE);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(146 - FIXED_BINDING_DISTANCE, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(5);
|
||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
leftArrowBinding.elementId,
|
||||
);
|
||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210 - FIXED_BINDING_DISTANCE);
|
||||
expect(rightBoundArrow.y).toBeCloseTo(
|
||||
(selectionHeight - 50) * (1 - scale) + 50,
|
||||
0,
|
||||
);
|
||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||
//console.log(JSON.stringify(h.elements));
|
||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale + 1, 0);
|
||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
expect(rightBoundArrow.angle).toEqual(0);
|
||||
expect(rightBoundArrow.startBinding).toBeNull();
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(FIXED_BINDING_DISTANCE);
|
||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
@ -1338,8 +1343,8 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX - 2, 0);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY + 2, 0);
|
||||
|
||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const { h } = window;
|
||||
const assertOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
expectedOrder: string[],
|
||||
@ -37,7 +35,7 @@ describe("normalizeElementsOrder", () => {
|
||||
boundElements: [],
|
||||
});
|
||||
|
||||
mutateElement(container, new Map(), {
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
@ -354,7 +352,7 @@ describe("normalizeElementsOrder", () => {
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(container, {
|
||||
mutateElement(container, {
|
||||
boundElements: [
|
||||
{ type: "text", id: boundText.id },
|
||||
{ type: "text", id: "xxx" },
|
||||
@ -389,7 +387,7 @@ describe("normalizeElementsOrder", () => {
|
||||
boundElements: [],
|
||||
groupIds: ["C", "A"],
|
||||
});
|
||||
h.app.scene.mutateElement(container, {
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { LIBRARY_DISABLED_TYPES, randomId } from "@excalidraw/common";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { alignElements } from "@excalidraw/element/align";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
import type { Alignment } from "@excalidraw/element/align";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
@ -27,6 +25,7 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -51,8 +50,14 @@ const alignSelectedElements = (
|
||||
alignment: Alignment,
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
alignment,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
|
@ -10,30 +10,28 @@ import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/containerCache";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
computeContainerDimensionForBoundText,
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isArrowElement,
|
||||
isTextBindableContainer,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { measureText } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { newElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { newElement } from "@excalidraw/element/newElement";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -44,7 +42,7 @@ import type {
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -79,7 +77,7 @@ export const actionUnbindText = register({
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
@ -87,7 +85,7 @@ export const actionUnbindText = register({
|
||||
x,
|
||||
y,
|
||||
});
|
||||
app.scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
@ -152,21 +150,24 @@ export const actionBindText = register({
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
app.scene.mutateElement(textElement, {
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
|
||||
});
|
||||
app.scene.mutateElement(container, {
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
const originalContainerHeight = container.height;
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// overwritting the cache with original container height so
|
||||
// it can be restored when unbind
|
||||
updateOriginalContainerCache(container.id, originalContainerHeight);
|
||||
@ -225,8 +226,8 @@ export const actionWrapTextInContainer = register({
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && someTextElements;
|
||||
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && areTextElements;
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
@ -296,23 +297,27 @@ export const actionWrapTextInContainer = register({
|
||||
}
|
||||
|
||||
if (startBinding || endBinding) {
|
||||
app.scene.mutateElement(ele, {
|
||||
startBinding,
|
||||
endBinding,
|
||||
});
|
||||
mutateElement(ele, { startBinding, endBinding }, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.scene.mutateElement(textElement, {
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, app.scene);
|
||||
},
|
||||
false,
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
updatedElements = pushContainerBelowText(
|
||||
[...updatedElements, container],
|
||||
|
@ -14,10 +14,8 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { getCommonBounds, type SceneBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -31,7 +29,6 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import {
|
||||
handIcon,
|
||||
LassoIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
@ -46,6 +43,7 @@ import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -54,6 +52,7 @@ import type { AppState, Offsets } from "../types";
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
label: "labels.canvasBackground",
|
||||
paletteName: "Change canvas background color",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
@ -91,6 +90,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
label: "labels.clearCanvas",
|
||||
paletteName: "Clear canvas",
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
@ -525,42 +525,10 @@ export const actionToggleEraserTool = register({
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
});
|
||||
|
||||
export const actionToggleLassoTool = register({
|
||||
name: "toggleLassoTool",
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (appState.activeTool.type !== "lasso") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "lasso",
|
||||
fromSelection: false,
|
||||
});
|
||||
setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
label: "toolBar.hand",
|
||||
paletteName: "Toggle hand tool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
icon: handIcon,
|
||||
viewMode: false,
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
import { getTextFromElements } from "@excalidraw/element";
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
import { getTextFromElements } from "@excalidraw/element/textElement";
|
||||
|
||||
import { CODES, KEYS, isFirefox } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
@ -17,6 +15,8 @@ import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
|
||||
import { exportCanvas, prepareElementsForExport } from "../data/index";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { isImageElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isImageElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawImageElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { Excalidraw } from "../index";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { act, assertElements, render } from "../tests/test-utils";
|
||||
|
||||
@ -56,7 +56,7 @@ describe("deleting selected elements when frame selected should keep children +
|
||||
frameId: f1.id,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(r1, {
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
@ -94,7 +94,7 @@ describe("deleting selected elements when frame selected should keep children +
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(r1, {
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
@ -132,7 +132,7 @@ describe("deleting selected elements when frame selected should keep children +
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(r1, {
|
||||
mutateElement(r1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
@ -170,7 +170,7 @@ describe("deleting selected elements when frame selected should keep children +
|
||||
frameId: null,
|
||||
});
|
||||
|
||||
h.app.scene.mutateElement(a1, {
|
||||
mutateElement(a1, {
|
||||
boundElements: [{ type: "text", id: t1.id }],
|
||||
});
|
||||
|
||||
|
@ -1,28 +1,30 @@
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { getContainerElement } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import {
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
import { getContainerElement } from "@excalidraw/element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
|
||||
import {
|
||||
getElementsInGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import { TrashIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
||||
@ -92,7 +94,7 @@ const deleteSelectedElements = (
|
||||
el.boundElements.forEach((candidate) => {
|
||||
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
|
||||
if (bound && isElbowArrow(bound)) {
|
||||
app.scene.mutateElement(bound, {
|
||||
mutateElement(bound, {
|
||||
startBinding:
|
||||
el.id === bound.startBinding?.elementId
|
||||
? null
|
||||
@ -100,6 +102,7 @@ const deleteSelectedElements = (
|
||||
endBinding:
|
||||
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
|
||||
});
|
||||
mutateElement(bound, { points: bound.points });
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -258,11 +261,7 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
app.scene,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
LinearElementEditor.deletePoints(element, selectedPointsIndices);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
|
||||
import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { distributeElements } from "@excalidraw/element/distribute";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
import type { Distribution } from "@excalidraw/element/distribute";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import {
|
||||
@ -23,6 +21,7 @@ import {
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -7,24 +7,32 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
getSelectionStateForElements,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/selection";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element";
|
||||
import { duplicateElements } from "@excalidraw/element/duplicate";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -44,7 +52,7 @@ export const actionDuplicateSelection = register({
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
return {
|
||||
@ -57,7 +65,8 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
}
|
||||
|
||||
let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
|
||||
let { newElements: duplicatedElements, elementsWithClones: nextElements } =
|
||||
duplicateElements({
|
||||
type: "in-place",
|
||||
elements,
|
||||
idsOfElementsToDuplicate: arrayToMap(
|
||||
@ -68,38 +77,40 @@ export const actionDuplicateSelection = register({
|
||||
),
|
||||
appState,
|
||||
randomizeSeed: true,
|
||||
overrides: ({ origElement, origIdToDuplicateId }) => {
|
||||
const duplicateFrameId =
|
||||
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
|
||||
return {
|
||||
x: origElement.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: origElement.y + DEFAULT_GRID_SIZE / 2,
|
||||
frameId: duplicateFrameId ?? origElement.frameId,
|
||||
};
|
||||
},
|
||||
overrides: (element) => ({
|
||||
x: element.x + DEFAULT_GRID_SIZE / 2,
|
||||
y: element.y + DEFAULT_GRID_SIZE / 2,
|
||||
}),
|
||||
reverseOrder: false,
|
||||
});
|
||||
|
||||
if (app.props.onDuplicate && elementsWithDuplicates) {
|
||||
const mappedElements = app.props.onDuplicate(
|
||||
elementsWithDuplicates,
|
||||
elements,
|
||||
);
|
||||
if (app.props.onDuplicate && nextElements) {
|
||||
const mappedElements = app.props.onDuplicate(nextElements, elements);
|
||||
if (mappedElements) {
|
||||
elementsWithDuplicates = mappedElements;
|
||||
nextElements = mappedElements;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
elements: syncMovedIndices(
|
||||
elementsWithDuplicates,
|
||||
arrayToMap(duplicatedElements),
|
||||
),
|
||||
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)),
|
||||
appState: {
|
||||
...appState,
|
||||
...getSelectionStateForElements(
|
||||
...updateLinearElementEditors(duplicatedElements),
|
||||
...selectGroupsForSelectedElements(
|
||||
{
|
||||
editingGroupId: appState.editingGroupId,
|
||||
selectedElementIds: excludeElementsInFramesFromSelection(
|
||||
duplicatedElements,
|
||||
getNonDeletedElements(elementsWithDuplicates),
|
||||
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
},
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@ -119,3 +130,24 @@ export const actionDuplicateSelection = register({
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
|
||||
const linears = clonedElements.filter(isLinearElement);
|
||||
if (linears.length === 1) {
|
||||
const linear = linears[0];
|
||||
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
|
||||
const onlySingleLinearSelected = clonedElements.every(
|
||||
(el) => el.id === linear.id || boundElements.includes(el.id),
|
||||
);
|
||||
|
||||
if (onlySingleLinearSelected) {
|
||||
return {
|
||||
selectedLinearElement: new LinearElementEditor(linear),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
@ -2,14 +2,13 @@ import {
|
||||
canCreateLinkFromElements,
|
||||
defaultGetElementLinkFromSelection,
|
||||
getLinkIdAndTypeFromSelection,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/elementLink";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { copyIcon, elementLinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,23 +1,18 @@
|
||||
import { KEYS, arrayToMap, randomId } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementsAreInSameGroup,
|
||||
newElementWith,
|
||||
selectGroupsFromGivenElements,
|
||||
} from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { LockedIcon, UnlockedIcon } from "../components/icons";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.every((el) => !el.locked);
|
||||
|
||||
@ -28,10 +23,15 @@ export const actionToggleElementLock = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return shouldLock(selected)
|
||||
? "labels.elementLock.lock"
|
||||
: "labels.elementLock.unlock";
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
icon: (appState, elements) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
@ -58,84 +58,19 @@ export const actionToggleElementLock = register({
|
||||
|
||||
const nextLockState = shouldLock(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
const isAGroup =
|
||||
selectedElements.length > 1 && elementsAreInSameGroup(selectedElements);
|
||||
const isASingleUnit = selectedElements.length === 1 || isAGroup;
|
||||
const newGroupId = isASingleUnit ? null : randomId();
|
||||
|
||||
let nextLockedMultiSelections = { ...appState.lockedMultiSelections };
|
||||
|
||||
if (nextLockState) {
|
||||
nextLockedMultiSelections = {
|
||||
...appState.lockedMultiSelections,
|
||||
...(newGroupId ? { [newGroupId]: true } : {}),
|
||||
};
|
||||
} else if (isAGroup) {
|
||||
const groupId = selectedElements[0].groupIds.at(-1)!;
|
||||
delete nextLockedMultiSelections[groupId];
|
||||
}
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
let nextGroupIds = element.groupIds;
|
||||
|
||||
// if locking together, add to group
|
||||
// if unlocking, remove the temporary group
|
||||
if (nextLockState) {
|
||||
if (newGroupId) {
|
||||
nextGroupIds = [...nextGroupIds, newGroupId];
|
||||
}
|
||||
} else {
|
||||
nextGroupIds = nextGroupIds.filter(
|
||||
(groupId) => !appState.lockedMultiSelections[groupId],
|
||||
);
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: nextLockState,
|
||||
// do not recreate the array unncessarily
|
||||
groupIds:
|
||||
nextGroupIds.length !== element.groupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
const nextSelectedElementIds: AppState["selectedElementIds"] = nextLockState
|
||||
? {}
|
||||
: Object.fromEntries(selectedElements.map((el) => [el.id, true]));
|
||||
const unlockedSelectedElements = selectedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
const nextSelectedGroupIds = nextLockState
|
||||
? {}
|
||||
: selectGroupsFromGivenElements(unlockedSelectedElements, appState);
|
||||
|
||||
const activeLockedId = nextLockState
|
||||
? newGroupId
|
||||
? newGroupId
|
||||
: isAGroup
|
||||
? selectedElements[0].groupIds.at(-1)!
|
||||
: selectedElements[0].id
|
||||
: null;
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
|
||||
return newElementWith(element, { locked: nextLockState });
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: nextSelectedElementIds,
|
||||
selectedGroupIds: nextSelectedGroupIds,
|
||||
selectedLinearElement: nextLockState
|
||||
? null
|
||||
: appState.selectedLinearElement,
|
||||
lockedMultiSelections: nextLockedMultiSelections,
|
||||
activeLockedId,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@ -155,6 +90,7 @@ export const actionToggleElementLock = register({
|
||||
|
||||
export const actionUnlockAllElements = register({
|
||||
name: "unlockAllElements",
|
||||
paletteName: "Unlock all elements",
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
icon: UnlockedIcon,
|
||||
@ -168,44 +104,18 @@ export const actionUnlockAllElements = register({
|
||||
perform: (elements, appState) => {
|
||||
const lockedElements = elements.filter((el) => el.locked);
|
||||
|
||||
const nextElements = elements.map((element) => {
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (element.locked) {
|
||||
// remove the temporary groupId if it exists
|
||||
const nextGroupIds = element.groupIds.filter(
|
||||
(gid) => !appState.lockedMultiSelections[gid],
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
locked: false,
|
||||
groupIds:
|
||||
// do not recreate the array unncessarily
|
||||
element.groupIds.length !== nextGroupIds.length
|
||||
? nextGroupIds
|
||||
: element.groupIds,
|
||||
});
|
||||
return newElementWith(element, { locked: false });
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
const nextElementsMap = arrayToMap(nextElements);
|
||||
|
||||
const unlockedElements = lockedElements.map(
|
||||
(el) => nextElementsMap.get(el.id) || el,
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: Object.fromEntries(
|
||||
lockedElements.map((el) => [el.id, true]),
|
||||
),
|
||||
selectedGroupIds: selectGroupsFromGivenElements(
|
||||
unlockedElements,
|
||||
appState,
|
||||
),
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -7,8 +7,6 @@ import {
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
import { useDevice } from "../components/App";
|
||||
@ -26,6 +24,7 @@ import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getExportSize } from "../scene/export";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
|
||||
|
@ -1,24 +1,28 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { type GlobalPoint, pointFrom } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
||||
getHoveredElementForBinding,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
import { isPathALoop } from "@excalidraw/element/shapes";
|
||||
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { done } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -44,6 +48,7 @@ export const actionFinalize = register({
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
elementsMap,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
@ -69,11 +74,7 @@ export const actionFinalize = register({
|
||||
scene.getElement(appState.pendingImageElementId);
|
||||
|
||||
if (pendingImageElement) {
|
||||
scene.mutateElement(
|
||||
pendingImageElement,
|
||||
{ isDeleted: true },
|
||||
{ informMutation: false, isDragging: false },
|
||||
);
|
||||
mutateElement(pendingImageElement, { isDeleted: true }, false);
|
||||
}
|
||||
|
||||
if (window.document.activeElement instanceof HTMLElement) {
|
||||
@ -92,12 +93,28 @@ export const actionFinalize = register({
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
const { x: rx, y: ry, points, lastCommittedPoint } = multiPointElement;
|
||||
const lastGlobalPoint = pointFrom<GlobalPoint>(
|
||||
rx + points[points.length - 1][0],
|
||||
ry + points[points.length - 1][1],
|
||||
);
|
||||
const hoveredElementForBinding = getHoveredElementForBinding(
|
||||
{
|
||||
x: lastGlobalPoint[0],
|
||||
y: lastGlobalPoint[1],
|
||||
},
|
||||
elements,
|
||||
elementsMap,
|
||||
app.state.zoom,
|
||||
true,
|
||||
isElbowArrow(multiPointElement),
|
||||
);
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
!hoveredElementForBinding &&
|
||||
(!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint)
|
||||
) {
|
||||
scene.mutateElement(multiPointElement, {
|
||||
mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
});
|
||||
}
|
||||
@ -121,7 +138,7 @@ export const actionFinalize = register({
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
scene.mutateElement(multiPointElement, {
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
@ -141,7 +158,13 @@ export const actionFinalize = register({
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
elements,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,10 +220,7 @@ export const actionFinalize = register({
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(
|
||||
multiPointElement,
|
||||
arrayToMap(newElements),
|
||||
)
|
||||
? new LinearElementEditor(multiPointElement)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
|
@ -87,6 +87,16 @@ describe("flipping arrowheads", () => {
|
||||
await render(<Excalidraw />);
|
||||
});
|
||||
|
||||
// UX RATIONALE: If we flip bound arrows by the center axes then there could
|
||||
// be a case where the bindable objects are offset and the arrow would lay
|
||||
// outside both bindable objects binding range, yet remain bound to then,
|
||||
// resulting in a jump on movement.
|
||||
//
|
||||
// We are aware that 2+ point simple arrows behave incorrectly when flipped
|
||||
// this way but it was decided that there is no known use case for this so
|
||||
// left as it is.
|
||||
//
|
||||
// Demo: https://excalidraw.com/#json=isE-S8LqNlD1u-LsS8Ezz,iZZ09PPasp6OWbGtJwOUGQ
|
||||
it("flipping bound arrow should flip arrowheads only", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
@ -123,6 +133,7 @@ describe("flipping arrowheads", () => {
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
||||
});
|
||||
|
||||
// UX RATIONALE: See above for the reasoning.
|
||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
@ -164,7 +175,9 @@ describe("flipping arrowheads", () => {
|
||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
||||
});
|
||||
|
||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
||||
// UX RATIONALE: Unbound arrows are not constrained by other elements and
|
||||
// should behave like any other element when flipped for consisency.
|
||||
it("flipping unbound arrow should mirror on horizontal or vertical axis", () => {
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
|
@ -2,21 +2,22 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element";
|
||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { deepCopyElement } from "@excalidraw/element";
|
||||
import { resizeMultipleElements } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/binding";
|
||||
import { getCommonBoundingBox } from "@excalidraw/element/bounds";
|
||||
import {
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
import { deepCopyElement } from "@excalidraw/element/duplicate";
|
||||
import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
} from "@excalidraw/element";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element/frame";
|
||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
@ -26,6 +27,7 @@ import type {
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
|
||||
@ -160,9 +162,11 @@ const flipElements = (
|
||||
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
elementsMap,
|
||||
app.scene.getNonDeletedElements(),
|
||||
app.scene,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
app.scene,
|
||||
appState.zoom,
|
||||
);
|
||||
|
||||
@ -190,13 +194,13 @@ const flipElements = (
|
||||
getCommonBoundingBox(selectedElements);
|
||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
||||
otherElements.forEach((element) =>
|
||||
app.scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
);
|
||||
elbowArrows.forEach((element) =>
|
||||
app.scene.mutateElement(element, {
|
||||
mutateElement(element, {
|
||||
x: element.x + diffX,
|
||||
y: element.y + diffY,
|
||||
}),
|
||||
|
@ -1,26 +1,25 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element";
|
||||
import { newFrameElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { mutateElement } from "@excalidraw/element/mutateElement";
|
||||
import { newFrameElement } from "@excalidraw/element/newElement";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import {
|
||||
addElementsToFrame,
|
||||
removeAllElementsFromFrame,
|
||||
} from "@excalidraw/element";
|
||||
import { getFrameChildren } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/frame";
|
||||
import { getFrameChildren } from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { getCommonBounds } from "@excalidraw/element/bounds";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { frameToolIcon } from "../components/icons";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -174,9 +173,11 @@ export const actionWrapSelectionInFrame = register({
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
|
||||
const [x1, y1, x2, y2] = getCommonBounds(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const PADDING = 16;
|
||||
const frame = newFrameElement({
|
||||
x: x1 - PADDING,
|
||||
@ -195,9 +196,13 @@ export const actionWrapSelectionInFrame = register({
|
||||
for (const elementInGroup of elementsInGroup) {
|
||||
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
|
||||
|
||||
mutateElement(elementInGroup, elementsMap, {
|
||||
mutateElement(
|
||||
elementInGroup,
|
||||
{
|
||||
groupIds: elementInGroup.groupIds.slice(0, index),
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import { isBoundToContainer } from "@excalidraw/element";
|
||||
import { isBoundToContainer } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
frameAndChildrenSelectedTogether,
|
||||
@ -12,7 +12,7 @@ import {
|
||||
groupByFrameLikes,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/frame";
|
||||
|
||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
@ -24,11 +24,9 @@ import {
|
||||
addToGroup,
|
||||
removeFromSelectedGroups,
|
||||
isElementInGroup,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/groups";
|
||||
|
||||
import { syncMovedIndices } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@ -42,6 +40,7 @@ import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,9 +1,5 @@
|
||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { orderByFractionalIndex } from "@excalidraw/element";
|
||||
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -11,8 +7,10 @@ import { UndoIcon, RedoIcon } from "../components/icons";
|
||||
import { HistoryChangedEvent } from "../history";
|
||||
import { useEmitter } from "../hooks/useEmitter";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import type { History } from "../history";
|
||||
import type { Store } from "../store";
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
import type { Action, ActionResult } from "./types";
|
||||
|
||||
@ -37,11 +35,7 @@ const executeHistoryAction = (
|
||||
}
|
||||
|
||||
const [nextElementsMap, nextAppState] = result;
|
||||
|
||||
// order by fractional indices in case the map was accidently modified in the meantime
|
||||
const nextElements = orderByFractionalIndex(
|
||||
Array.from(nextElementsMap.values()),
|
||||
);
|
||||
const nextElements = Array.from(nextElementsMap.values());
|
||||
|
||||
return {
|
||||
appState: nextAppState,
|
||||
@ -53,9 +47,9 @@ const executeHistoryAction = (
|
||||
return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
|
||||
};
|
||||
|
||||
type ActionCreator = (history: History) => Action;
|
||||
type ActionCreator = (history: History, store: Store) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
export const createUndoAction: ActionCreator = (history, store) => ({
|
||||
name: "undo",
|
||||
label: "buttons.undo",
|
||||
icon: UndoIcon,
|
||||
@ -63,7 +57,11 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.undo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||
history.undo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||
@ -90,15 +88,19 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
export const createRedoAction: ActionCreator = (history, store) => ({
|
||||
name: "redo",
|
||||
label: "buttons.redo",
|
||||
icon: RedoIcon,
|
||||
trackEvent: { category: "history" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, __, app) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
executeHistoryAction(app, appState, () =>
|
||||
history.redo(arrayToMap(elements) as SceneElementsMap, appState),
|
||||
history.redo(
|
||||
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
|
||||
appState,
|
||||
store.snapshot,
|
||||
),
|
||||
),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -13,6 +9,7 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -53,7 +50,7 @@ export const actionToggleLinearEditor = register({
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||
: new LinearElementEditor(selectedElement);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,15 +1,14 @@
|
||||
import { isEmbeddableElement } from "@excalidraw/element";
|
||||
import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -2,14 +2,14 @@ import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { showSelectedShapeActions } from "@excalidraw/element/showSelectedShapeActions";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
|
@ -1,7 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import {
|
||||
@ -10,6 +8,7 @@ import {
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -20,7 +20,6 @@ import {
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
getLineHeight,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
@ -31,16 +30,19 @@ import {
|
||||
calculateFixedPointForElbowArrowBinding,
|
||||
getHoveredElementForBinding,
|
||||
updateBoundElements,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/binding";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import {
|
||||
mutateElement,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element/mutateElement";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import {
|
||||
isArrowElement,
|
||||
@ -49,19 +51,16 @@ import {
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { hasStrokeColor } from "@excalidraw/element";
|
||||
import { hasStrokeColor } from "@excalidraw/element/comparisons";
|
||||
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { updateElbowArrowPoints } from "@excalidraw/element/elbowArrow";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
Arrowhead,
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@ -69,14 +68,11 @@ import type {
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import type { CaptureUpdateActionType } from "@excalidraw/element";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
import { RadioSelection } from "../components/RadioSelection";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
@ -131,13 +127,16 @@ import { Fonts } from "../fonts";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import {
|
||||
canHaveArrowheads,
|
||||
getCommonAttributeOfSelectedElements,
|
||||
getSelectedElements,
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { CaptureUpdateActionType } from "../store";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
@ -167,12 +166,12 @@ export const changeProperty = (
|
||||
|
||||
export const getFormValue = function <T extends Primitive>(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
appState: AppState,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
|
||||
defaultValue: T | ((isSomeElementSelected: boolean) => T),
|
||||
): T {
|
||||
const editingTextElement = app.state.editingTextElement;
|
||||
const editingTextElement = appState.editingTextElement;
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
|
||||
let ret: T | null = null;
|
||||
@ -182,17 +181,17 @@ export const getFormValue = function <T extends Primitive>(
|
||||
}
|
||||
|
||||
if (!ret) {
|
||||
const hasSelection = isSomeElementSelected(nonDeletedElements, app.state);
|
||||
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
|
||||
|
||||
if (hasSelection) {
|
||||
const selectedElements = app.scene.getSelectedElements(app.state);
|
||||
const targetElements =
|
||||
isRelevantElement === true
|
||||
? selectedElements
|
||||
: selectedElements.filter((el) => isRelevantElement(el));
|
||||
|
||||
ret =
|
||||
reduceToCommonValue(targetElements, getAttribute) ??
|
||||
getCommonAttributeOfSelectedElements(
|
||||
isRelevantElement === true
|
||||
? nonDeletedElements
|
||||
: nonDeletedElements.filter((el) => isRelevantElement(el)),
|
||||
appState,
|
||||
getAttribute,
|
||||
) ??
|
||||
(typeof defaultValue === "function"
|
||||
? defaultValue(true)
|
||||
: defaultValue);
|
||||
@ -208,12 +207,13 @@ export const getFormValue = function <T extends Primitive>(
|
||||
const offsetElementAfterFontResize = (
|
||||
prevElement: ExcalidrawTextElement,
|
||||
nextElement: ExcalidrawTextElement,
|
||||
scene: Scene,
|
||||
) => {
|
||||
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||
return nextElement;
|
||||
}
|
||||
return scene.mutateElement(nextElement, {
|
||||
return mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
x:
|
||||
prevElement.textAlign === "left"
|
||||
? prevElement.x
|
||||
@ -223,7 +223,9 @@ const offsetElementAfterFontResize = (
|
||||
// centering vertically is non-standard, but for Excalidraw I think
|
||||
// it makes sense
|
||||
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
};
|
||||
|
||||
const changeFontSize = (
|
||||
@ -249,14 +251,10 @@ const changeFontSize = (
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
newElement = offsetElementAfterFontResize(
|
||||
oldElement,
|
||||
newElement,
|
||||
app.scene,
|
||||
);
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
@ -266,11 +264,15 @@ const changeFontSize = (
|
||||
);
|
||||
|
||||
// Update arrow elements after text elements have been updated
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).forEach((element) => {
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, app.scene);
|
||||
updateBoundElements(
|
||||
element,
|
||||
updatedElementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -320,7 +322,7 @@ export const actionChangeStrokeColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
<ColorPicker
|
||||
@ -330,11 +332,10 @@ export const actionChangeStrokeColor = register({
|
||||
label={t("labels.stroke")}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.strokeColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemStrokeColor : null,
|
||||
appState.currentItemStrokeColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemStrokeColor: color })}
|
||||
elements={elements}
|
||||
@ -367,7 +368,7 @@ export const actionChangeBackgroundColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
<ColorPicker
|
||||
@ -377,11 +378,10 @@ export const actionChangeBackgroundColor = register({
|
||||
label={t("labels.background")}
|
||||
color={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.backgroundColor,
|
||||
true,
|
||||
(hasSelection) =>
|
||||
!hasSelection ? appState.currentItemBackgroundColor : null,
|
||||
appState.currentItemBackgroundColor,
|
||||
)}
|
||||
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
|
||||
elements={elements}
|
||||
@ -412,7 +412,7 @@ export const actionChangeFillStyle = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const allElementsZigZag =
|
||||
selectedElements.length > 0 &&
|
||||
@ -421,8 +421,7 @@ export const actionChangeFillStyle = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fill")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
type="button"
|
||||
options={[
|
||||
{
|
||||
@ -449,7 +448,7 @@ export const actionChangeFillStyle = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.fillStyle,
|
||||
(element) => element.hasOwnProperty("fillStyle"),
|
||||
(hasSelection) =>
|
||||
@ -466,7 +465,6 @@ export const actionChangeFillStyle = register({
|
||||
updateData(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
@ -487,11 +485,10 @@ export const actionChangeStrokeWidth = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="stroke-width"
|
||||
options={[
|
||||
{
|
||||
@ -515,7 +512,7 @@ export const actionChangeStrokeWidth = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.strokeWidth,
|
||||
(element) => element.hasOwnProperty("strokeWidth"),
|
||||
(hasSelection) =>
|
||||
@ -523,7 +520,6 @@ export const actionChangeStrokeWidth = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
@ -544,11 +540,10 @@ export const actionChangeSloppiness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="sloppiness"
|
||||
options={[
|
||||
{
|
||||
@ -569,7 +564,7 @@ export const actionChangeSloppiness = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.roughness,
|
||||
(element) => element.hasOwnProperty("roughness"),
|
||||
(hasSelection) =>
|
||||
@ -577,7 +572,6 @@ export const actionChangeSloppiness = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
@ -597,11 +591,10 @@ export const actionChangeStrokeStyle = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="strokeStyle"
|
||||
options={[
|
||||
{
|
||||
@ -622,7 +615,7 @@ export const actionChangeStrokeStyle = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => element.strokeStyle,
|
||||
(element) => element.hasOwnProperty("strokeStyle"),
|
||||
(hasSelection) =>
|
||||
@ -630,7 +623,6 @@ export const actionChangeStrokeStyle = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
@ -654,8 +646,13 @@ export const actionChangeOpacity = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ app, updateData }) => (
|
||||
<Range updateData={updateData} app={app} testId="opacity" />
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<Range
|
||||
updateData={updateData}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
testId="opacity"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@ -669,8 +666,7 @@ export const actionChangeFontSize = register({
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontSize")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="font-size"
|
||||
options={[
|
||||
{
|
||||
@ -700,7 +696,7 @@ export const actionChangeFontSize = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontSize;
|
||||
@ -727,7 +723,6 @@ export const actionChangeFontSize = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
@ -783,7 +778,7 @@ type ChangeFontFamilyData = Partial<
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
cachedElements?: ElementsMap;
|
||||
cachedElements?: Map<string, ExcalidrawElement>;
|
||||
/** flag to reset all elements to their cached versions */
|
||||
resetAll?: true;
|
||||
/** flag to reset all containers to their cached versions */
|
||||
@ -924,7 +919,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
if (resetContainers && container && cachedContainer) {
|
||||
// reset the container back to it's cached version
|
||||
app.scene.mutateElement(container, { ...cachedContainer });
|
||||
mutateElement(container, { ...cachedContainer }, false);
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
@ -955,7 +950,12 @@ export const actionChangeFontFamily = register({
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
redrawTextBoundingBox(element, container, app.scene);
|
||||
redrawTextBoundingBox(
|
||||
element,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||
@ -972,7 +972,8 @@ export const actionChangeFontFamily = register({
|
||||
redrawTextBoundingBox(
|
||||
latestElement as ExcalidrawTextElement,
|
||||
latestContainer,
|
||||
app.scene,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -986,7 +987,7 @@ export const actionChangeFontFamily = register({
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||
@ -995,11 +996,11 @@ export const actionChangeFontFamily = register({
|
||||
const selectedFontFamily = useMemo(() => {
|
||||
const getFontFamily = (
|
||||
elementsArray: readonly ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
elementsMap: Map<string, ExcalidrawElement>,
|
||||
) =>
|
||||
getFormValue(
|
||||
elementsArray,
|
||||
app,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
@ -1037,7 +1038,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||
return prevSelectedFontFamilyRef.current;
|
||||
}, [batchedData.openPopup, appState, elements, app]);
|
||||
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||
@ -1178,7 +1179,7 @@ export const actionChangeTextAlign = register({
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
@ -1199,8 +1200,7 @@ export const actionChangeTextAlign = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection<TextAlign | false>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
@ -1224,7 +1224,7 @@ export const actionChangeTextAlign = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.textAlign;
|
||||
@ -1246,7 +1246,6 @@ export const actionChangeTextAlign = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
@ -1271,7 +1270,7 @@ export const actionChangeVerticalAlign = register({
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
@ -1289,8 +1288,7 @@ export const actionChangeVerticalAlign = register({
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<div className="buttonList">
|
||||
<RadioSelection<VerticalAlign | false>
|
||||
<ButtonIconSelect<VerticalAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
@ -1314,7 +1312,7 @@ export const actionChangeVerticalAlign = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
return element.verticalAlign;
|
||||
@ -1338,7 +1336,6 @@ export const actionChangeVerticalAlign = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
@ -1373,7 +1370,7 @@ export const actionChangeRoundness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
@ -1386,8 +1383,7 @@ export const actionChangeRoundness = register({
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.edges")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="edges"
|
||||
options={[
|
||||
{
|
||||
@ -1403,13 +1399,9 @@ export const actionChangeRoundness = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) =>
|
||||
hasLegacyRoundness
|
||||
? null
|
||||
: element.roundness
|
||||
? "round"
|
||||
: "sharp",
|
||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||
(element) =>
|
||||
!isArrowElement(element) && element.hasOwnProperty("roundness"),
|
||||
(hasSelection) =>
|
||||
@ -1417,7 +1409,6 @@ export const actionChangeRoundness = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
@ -1538,7 +1529,7 @@ export const actionChangeArrowhead = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const isRTL = getLanguage().rtl;
|
||||
|
||||
return (
|
||||
@ -1550,7 +1541,7 @@ export const actionChangeArrowhead = register({
|
||||
options={getArrowheadOptions(!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.startArrowhead
|
||||
@ -1567,7 +1558,7 @@ export const actionChangeArrowhead = register({
|
||||
options={getArrowheadOptions(!!isRTL)}
|
||||
value={getFormValue<Arrowhead | null>(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) =>
|
||||
isLinearElement(element) && canHaveArrowheads(element.type)
|
||||
? element.endArrowhead
|
||||
@ -1664,6 +1655,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
: startGlobalPoint;
|
||||
const finalEndPoint = endHoveredElement
|
||||
@ -1671,6 +1663,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
endHoveredElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
: endGlobalPoint;
|
||||
|
||||
@ -1679,10 +1672,10 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
startHoveredElement,
|
||||
"start",
|
||||
app.scene,
|
||||
elementsMap,
|
||||
);
|
||||
endHoveredElement &&
|
||||
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
|
||||
bindLinearElement(newElement, endHoveredElement, "end", elementsMap);
|
||||
|
||||
const startBinding =
|
||||
startElement && newElement.startBinding
|
||||
@ -1693,6 +1686,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
startElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@ -1705,6 +1699,7 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
endElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: null;
|
||||
@ -1723,6 +1718,12 @@ export const actionChangeArrowType = register({
|
||||
fixedSegments: null,
|
||||
}),
|
||||
};
|
||||
|
||||
LinearElementEditor.updateEditorMidPointsCache(
|
||||
newElement,
|
||||
elementsMap,
|
||||
app.state,
|
||||
);
|
||||
} else {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
if (newElement.startBinding) {
|
||||
@ -1730,7 +1731,7 @@ export const actionChangeArrowType = register({
|
||||
newElement.startBinding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
if (startElement) {
|
||||
bindLinearElement(newElement, startElement, "start", app.scene);
|
||||
bindLinearElement(newElement, startElement, "start", elementsMap);
|
||||
}
|
||||
}
|
||||
if (newElement.endBinding) {
|
||||
@ -1738,7 +1739,7 @@ export const actionChangeArrowType = register({
|
||||
newElement.endBinding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
if (endElement) {
|
||||
bindLinearElement(newElement, endElement, "end", app.scene);
|
||||
bindLinearElement(newElement, endElement, "end", elementsMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1759,7 +1760,6 @@ export const actionChangeArrowType = register({
|
||||
if (selected) {
|
||||
newState.selectedLinearElement = new LinearElementEditor(
|
||||
selected as ExcalidrawLinearElement,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1770,12 +1770,11 @@ export const actionChangeArrowType = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.arrowtypes")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
<ButtonIconSelect
|
||||
group="arrowtypes"
|
||||
options={[
|
||||
{
|
||||
@ -1799,7 +1798,7 @@ export const actionChangeArrowType = register({
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
app,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isArrowElement(element)) {
|
||||
return element.elbowed
|
||||
@ -1817,7 +1816,6 @@ export const actionChangeArrowType = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import { isLinearElement, isTextElement } from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { arrayToMap, KEYS } from "@excalidraw/common";
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { selectAllIcon } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
@ -53,7 +53,7 @@ export const actionSelectAll = register({
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], arrayToMap(elements))
|
||||
? new LinearElementEditor(elements[0])
|
||||
: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
@ -17,14 +17,12 @@ import {
|
||||
isArrowElement,
|
||||
isExcalidrawElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
|
||||
import {
|
||||
getBoundTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
} from "@excalidraw/element/textElement";
|
||||
|
||||
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
|
||||
|
||||
@ -32,6 +30,7 @@ import { paintIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@ -140,8 +139,11 @@ export const actionPasteStyles = register({
|
||||
element.id === newElement.containerId,
|
||||
) || null;
|
||||
}
|
||||
|
||||
redrawTextBoundingBox(newElement, container, app.scene);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { getFontString } from "@excalidraw/common";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { measureText } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { measureText } from "@excalidraw/element/textMeasurements";
|
||||
|
||||
import { isTextElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
import { isTextElement } from "@excalidraw/element/typeChecks";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { gridIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { CODES, KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { magnetIcon } from "../components/icons";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user