Compare commits

...
Author SHA1 Message Date
Márk TolmácsandGitHub 4eb1bd8036 Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-05-05 09:56:19 +02:00
Márk TolmácsandGitHub e19fd1332a feat: Precise highlights for bindings (#9472) 2025-05-05 09:51:20 +02:00
6e655cdb24 fix: When moving a frame through the stats inputs or drags move along its children (#9433)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-02 17:07:17 +02:00
Márk TolmácsandGitHub 8d7ffa21d1 Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-05-02 09:47:18 +02:00
192c4e7658 docs: added shape cycling shortcut in helper dialog (#9465)
* docs: added shape cycling shortcut in helper dialog

- Document Tab and Shift+Tab usage for shape cycling

* docs: added shape cycling shortcut in helper dialog

* Update packages/excalidraw/components/HelpDialog.tsx

* Update packages/excalidraw/locales/en.json

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-05-01 12:12:45 +02:00
195a743874 feat: switch between basic shapes (#9270)
* feat: switch between basic shapes

* add tab for testing

* style tweaks

* only show hint when a new node is created

* fix panel state

* refactor

* combine captures into one

* keep original font size

* switch multi

* switch different types altogether

* use tab only

* fix font size atom

* do not switch from active tool change

* prefer generic when mixed

* provide an optional direction when shape switching

* adjust panel bg & shadow

* redraw to correctly position text

* remove redundant code

* only tab to switch if focusing on app container

* limit which linear elements can be switched

* add shape switch to command palette

* remove hint

* cache initial panel position

* bend line to elbow if needed

* remove debug logic

* clean switch of arrows using app state

* safe conversion between line, sharp, curved, and elbow

* cache linear when panel shows up

* type safe element conversion

* rename type

* respect initial type when switching between linears

* fix elbow segment indexing

* use latest linear

* merge converted elbow points if too close

* focus on panel after click

* set roudness to null to fix drag points offset for elbows

* remove Mutable

* add arrowBoundToElement check

* make it dependent on one signle state

* unmount when not showing

* simpler types, tidy up code

* can change linear when it's linear + non-generic

* fix popup component lifecycle

* move constant to CLASSES

* DRY out type detection

* file & variable renaming

* refactor

* throw in not-prod instead

* simplify

* semi-fix bindings on `generic` type conversion

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-04-30 18:07:31 +02:00
dwelle 82cef23c3d DEBUG 2025-04-30 10:39:54 +02:00
David LuzarandGitHub 4a60fe3d22 fix: remove noreferrer on internal links (#9452)
* fix: remove `noreferrer` on internal links

* fix snaps

* fix lint
2025-04-29 18:45:17 +02:00
Mark Tolmacs 541725ff5a Test fixes 2025-04-28 19:49:41 +02:00
Mark Tolmacs 28066034d7 Arrowhead padding 2025-04-28 19:37:50 +02:00
Mark Tolmacs 7d0d6aec7a Another algo forpaddings 2025-04-28 19:31:30 +02:00
Mark Tolmacs e6ade3b627 Fix test 2025-04-25 18:58:05 +02:00
Mark Tolmacs 9a2bd18904 Further adjustments for edge cases 2025-04-25 18:54:18 +02:00
Mark Tolmacs c7c6a4c3f1 Fix test 2025-04-25 14:33:34 +02:00
Márk TolmácsandGitHub 9c27f936de Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-25 14:27:15 +02:00
Mark Tolmacs b8fdd7ef23 Remove unneeded imports 2025-04-25 14:26:01 +02:00
Mark Tolmacs ece841326b Refine corner avoidance 2025-04-25 14:25:40 +02:00
Narek MalkhasyanandGitHub 2a0d15799c fix: when dragging arrow endpoint, update binding only on the dragged side (#9367) 2025-04-25 10:46:58 +02:00
CharitSinghChauhanandGitHub a18b139a60 fix: laser pointer trail disappearing on pointerup (#9413) (#9427)
* Fix laser pointer trail disappearing on pointerup (#9413)

Previously, the laser pointer trail would disappear as soon as the pointerup event was triggered. This fix delays the trail removal to ensure it persists for a smoother visual experience.

Fixes #9413.

* Remove extra blank lines

Minor formatting cleanup. No functional changes.
2025-04-24 10:05:08 +10:00
Marcel MrazandGitHub 1913599594 refactor: remove dependency on the (static) Scene (#9389) 2025-04-23 13:45:08 +02:00
Mark Tolmacs 41711af210 Adjust padding so smaller objects have smaller padding 2025-04-21 17:24:13 +02:00
Mark Tolmacs 230e47fd52 Remove debug 2025-04-21 14:56:58 +02:00
Mark Tolmacs 52445aeb68 Fix a particular routing issue 2025-04-21 14:56:36 +02:00
Márk TolmácsandGitHub bc9f34e71e Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-21 12:30:49 +02:00
Márk TolmácsandGitHub 22aade07b3 Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-16 21:50:42 +02:00
Mark Tolmacs c2de1304b7 Add snapshot update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-15 16:13:01 +02:00
Mark Tolmacs 25fb43f5b7 Snapshot update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-14 19:05:34 +02:00
Márk TolmácsandGitHub 6dfa5de66c Merge branch 'master' into mtolmacs/fix/small-elbow-routing 2025-04-14 18:54:01 +02:00
Mark Tolmacs 7abbb2afa3 New heuristic based on minimal arrow extent
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-04-14 18:52:51 +02:00
Mark Tolmacs aa91a3d610 Adaptive segment unification 2025-04-13 13:52:24 +02:00
Mark Tolmacs 25d6e517c9 Revert attempt to exclude some files from coverage 2025-04-13 13:29:21 +02:00
Mark Tolmacs d5e33730ab Reduce scope of coverage reports 2025-04-13 13:19:45 +02:00
Mark Tolmacs c06b78c1b2 Further fine-tune adaptive padding 2025-04-13 13:08:22 +02:00
Mark Tolmacs eaa869620e Fine tuning 2025-04-11 21:53:23 +02:00
Mark Tolmacs a8338cdb5a More adaptive elbow dongle offset 2025-04-11 21:08:55 +02:00
Mark Tolmacs 1ee3676784 Move visal debug to @excalidraw/util 2025-04-11 15:39:31 +02:00
Mark Tolmacs f12f7e4b50 Fix sentry#6530117915 2025-04-11 10:24:02 +02:00
99 changed files with 2967 additions and 1310 deletions
+6
View File
@@ -32,6 +32,12 @@
"name": "jotai", "name": "jotai",
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-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
}
] ]
} }
} }
+1 -1
View File
@@ -73,7 +73,7 @@ export const AIComponents = ({
</br> </br>
<div>You can also try <a href="${ <div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div> }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
</div> </div>
</body> </body>
</html>`, </html>`,
+1 -1
View File
@@ -18,7 +18,7 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug"; import type { DebugElement } from "@excalidraw/utils/visualdebug";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
+1 -1
View File
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
className="encrypted-icon tooltip" className="encrypted-icon tooltip"
href="https://plus.excalidraw.com/blog/end-to-end-encryption" href="https://plus.excalidraw.com/blog/end-to-end-encryption"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
aria-label={t("encrypted.link")} aria-label={t("encrypted.link")}
> >
<Tooltip label={t("encrypted.tooltip")} long={true}> <Tooltip label={t("encrypted.tooltip")} long={true}>
@@ -10,7 +10,7 @@ export const ExcalidrawPlusAppLink = () => {
import.meta.env.VITE_APP_PLUS_APP import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`} }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
target="_blank" target="_blank"
rel="noreferrer" rel="noopener"
className="plus-button" className="plus-button"
> >
Go to Excalidraw+ Go to Excalidraw+
@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<a <a
class="welcome-screen-menu-item " class="welcome-screen-menu-item "
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest" href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
> >
<div <div
+1
View File
@@ -119,6 +119,7 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
}; };
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai"; export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
+1 -1
View File
@@ -680,7 +680,7 @@ export const arrayToMap = <T extends { id: string } | string>(
return items.reduce((acc: Map<string, T>, element) => { return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element); acc.set(typeof element === "string" ? element : element.id, element);
return acc; return acc;
}, new Map()); }, new Map() as Map<string, T>);
}; };
export const arrayToMapWithIndex = <T extends { id: string }>( export const arrayToMapWithIndex = <T extends { id: string }>(
@@ -13,6 +13,7 @@ import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups"; import { getElementsInGroup } from "@excalidraw/element/groups";
import { import {
orderByFractionalIndex,
syncInvalidIndices, syncInvalidIndices,
syncMovedIndices, syncMovedIndices,
validateFractionalIndices, validateFractionalIndices,
@@ -20,7 +21,11 @@ import {
import { getSelectedElements } from "@excalidraw/element/selection"; import { getSelectedElements } from "@excalidraw/element/selection";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@@ -33,12 +38,13 @@ import type {
Ordered, Ordered,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { Assert, SameType } from "@excalidraw/common/utility-types"; import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../types"; import type { AppState } from "../../excalidraw/types";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
type SceneStateCallback = () => void; type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void; type SceneStateCallbackRemover = () => void;
@@ -103,44 +109,7 @@ const hashSelectionOpts = (
// in our codebase // in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
if (typeof elementKey === "string") {
return true;
}
return false;
};
class Scene { class Scene {
// ---------------------------------------------------------------------------
// static methods/props
// ---------------------------------------------------------------------------
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
/**
* @deprecated pass down `app.scene` and use it directly
*/
static getScene(elementKey: ElementKey): Scene | null {
if (isIdKey(elementKey)) {
return this.sceneMapById.get(elementKey) || null;
}
return this.sceneMapByElement.get(elementKey) || null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// instance methods/props // instance methods/props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -199,6 +168,12 @@ class Scene {
return this.frames; return this.frames;
} }
constructor(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements);
}
}
getSelectedElements(opts: { getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance // NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"]; selectedElementIds: AppState["selectedElementIds"];
@@ -293,21 +268,25 @@ class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements = isReadonlyArray(nextElements) // ts doesn't like `Array.isArray` of `instanceof Map`
? nextElements if (!isReadonlyArray(nextElements)) {
: Array.from(nextElements.values()); // need to order by fractional indices to get the correct order
nextElements = orderByFractionalIndex(
Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements); validateIndicesThrottled(nextElements);
this.elements = syncInvalidIndices(_nextElements); this.elements = syncInvalidIndices(nextElements);
this.elementsMap.clear(); this.elementsMap.clear();
this.elements.forEach((element) => { this.elements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
nextFrameLikes.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this);
}); });
const nonDeletedElements = getNonDeletedElements(this.elements); const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements; this.nonDeletedElements = nonDeletedElements.elements;
@@ -352,12 +331,6 @@ class Scene {
this.selectedElementsCache.elements = null; this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear(); this.selectedElementsCache.cache.clear();
Scene.sceneMapById.forEach((scene, elementKey) => {
if (scene === this) {
Scene.sceneMapById.delete(elementKey);
}
});
// done not for memory leaks, but to guard against possible late fires // done not for memory leaks, but to guard against possible late fires
// (I guess?) // (I guess?)
this.callbacks.clear(); this.callbacks.clear();
@@ -454,6 +427,42 @@ class Scene {
// then, check if the id is a group // then, check if the id is a group
return getElementsInGroup(elementsMap, id); return getElementsInGroup(elementsMap, id);
}; };
// Mutate an element with passed updates and trigger the component to update. Make sure you
// are calling it either from a React event handler or within unstable_batchedUpdates().
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
options: {
informMutation: boolean;
isDragging: boolean;
} = {
informMutation: true,
isDragging: false,
},
) {
const elementsMap = this.getNonDeletedElementsMap();
const { version: prevVersion } = element;
const { version: nextVersion } = mutateElement(
element,
elementsMap,
updates,
options,
);
if (
// skip if the element is not in the scene (i.e. selection)
this.elementsMap.has(element.id) &&
// skip if the element's version hasn't changed, as mutateElement returned the same element
prevVersion !== nextVersion &&
options.informMutation
) {
this.triggerUpdate();
}
return element;
}
} }
export default Scene; export default Scene;
+7 -7
View File
@@ -1,12 +1,11 @@
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds"; import { getCommonBoundingBox } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getMaximumGroups } from "./groups"; import { getMaximumGroups } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
export interface Alignment { export interface Alignment {
position: "start" | "center" | "end"; position: "start" | "center" | "end";
@@ -15,10 +14,10 @@ export interface Alignment {
export const alignElements = ( export const alignElements = (
selectedElements: ExcalidrawElement[], selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
alignment: Alignment, alignment: Alignment,
scene: Scene, scene: Scene,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups( const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements, selectedElements,
elementsMap, elementsMap,
@@ -33,12 +32,13 @@ export const alignElements = (
); );
return group.map((element) => { return group.map((element) => {
// update element // update element
const updatedEle = mutateElement(element, { const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x, x: element.x + translation.x,
y: element.y + translation.y, y: element.y + translation.y,
}); });
// update bound elements // update bound elements
updateBoundElements(element, scene.getNonDeletedElementsMap(), { updateBoundElements(element, scene, {
simultaneouslyUpdated: group, simultaneouslyUpdated: group,
}); });
return updatedEle; return updatedEle;
+120 -93
View File
@@ -31,8 +31,6 @@ import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
@@ -45,6 +43,12 @@ import {
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToBindableElement } from "./distance";
import { import {
compareHeading,
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
headingForPoint,
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
vectorToHeading, vectorToHeading,
@@ -68,6 +72,8 @@ import {
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
import type { import type {
@@ -81,10 +87,8 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
OrderedExcalidrawElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPoint, FixedPoint,
SceneElementsMap,
FixedPointBinding, FixedPointBinding,
} from "./types"; } from "./types";
@@ -130,7 +134,6 @@ export const bindOrUnbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startBindingElement: ExcalidrawBindableElement | null | "keep", startBindingElement: ExcalidrawBindableElement | null | "keep",
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
elementsMap: NonDeletedSceneElementsMap,
scene: Scene, scene: Scene,
): void => { ): void => {
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
@@ -142,7 +145,7 @@ export const bindOrUnbindLinearElement = (
"start", "start",
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, scene,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@@ -151,7 +154,7 @@ export const bindOrUnbindLinearElement = (
"end", "end",
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, scene,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@@ -159,7 +162,7 @@ export const bindOrUnbindLinearElement = (
); );
getNonDeletedElements(scene, onlyUnbound).forEach((element) => { getNonDeletedElements(scene, onlyUnbound).forEach((element) => {
mutateElement(element, { scene.mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(element) => (element) =>
element.type !== "arrow" || element.id !== linearElement.id, element.type !== "arrow" || element.id !== linearElement.id,
@@ -177,7 +180,7 @@ const bindOrUnbindLinearElementEdge = (
boundToElementIds: Set<ExcalidrawBindableElement["id"]>, boundToElementIds: Set<ExcalidrawBindableElement["id"]>,
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
elementsMap: NonDeletedSceneElementsMap, scene: Scene,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@@ -186,7 +189,7 @@ const bindOrUnbindLinearElementEdge = (
// null means break the bind, so nothing to consider here // null means break the bind, so nothing to consider here
if (bindableElement === null) { if (bindableElement === null) {
const unbound = unbindLinearElement(linearElement, startOrEnd); const unbound = unbindLinearElement(linearElement, startOrEnd, scene);
if (unbound != null) { if (unbound != null) {
unboundFromElementIds.add(unbound); unboundFromElementIds.add(unbound);
} }
@@ -209,16 +212,11 @@ const bindOrUnbindLinearElementEdge = (
: startOrEnd === "start" || : startOrEnd === "start" ||
otherEdgeBindableElement.id !== bindableElement.id) otherEdgeBindableElement.id !== bindableElement.id)
) { ) {
bindLinearElement( bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
linearElement,
bindableElement,
startOrEnd,
elementsMap,
);
boundToElementIds.add(bindableElement.id); boundToElementIds.add(bindableElement.id);
} }
} else { } else {
bindLinearElement(linearElement, bindableElement, startOrEnd, elementsMap); bindLinearElement(linearElement, bindableElement, startOrEnd, scene);
boundToElementIds.add(bindableElement.id); boundToElementIds.add(bindableElement.id);
} }
}; };
@@ -283,15 +281,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom, zoom,
) )
: null // If binding is disabled and start is dragged, break all binds : null // If binding is disabled and start is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"start",
elementsMap,
elements,
zoom,
)
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
@@ -303,15 +292,6 @@ const getBindingStrategyForDraggingArrowEndpoints = (
zoom, zoom,
) )
: null // If binding is disabled and end is dragged, break all binds : null // If binding is disabled and end is dragged, break all binds
: !isElbowArrow(selectedElement)
? // We have to update the focus and gap of the binding, so let's rebind
getElligibleElementForBindingElement(
selectedElement,
"end",
elementsMap,
elements,
zoom,
)
: "keep"; : "keep";
return [start, end]; return [start, end];
@@ -362,11 +342,9 @@ const getBindingStrategyForDraggingArrowOrJoints = (
export const bindOrUnbindLinearElements = ( export const bindOrUnbindLinearElements = (
selectedElements: NonDeleted<ExcalidrawLinearElement>[], selectedElements: NonDeleted<ExcalidrawLinearElement>[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
isBindingEnabled: boolean, isBindingEnabled: boolean,
draggingPoints: readonly number[] | null, draggingPoints: readonly number[] | null,
scene: Scene,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): void => { ): void => {
selectedElements.forEach((selectedElement) => { selectedElements.forEach((selectedElement) => {
@@ -376,20 +354,20 @@ export const bindOrUnbindLinearElements = (
selectedElement, selectedElement,
isBindingEnabled, isBindingEnabled,
draggingPoints ?? [], draggingPoints ?? [],
elementsMap, scene.getNonDeletedElementsMap(),
elements, scene.getNonDeletedElements(),
zoom, zoom,
) )
: // The arrow itself (the shaft) or the inner joins are dragged : // The arrow itself (the shaft) or the inner joins are dragged
getBindingStrategyForDraggingArrowOrJoints( getBindingStrategyForDraggingArrowOrJoints(
selectedElement, selectedElement,
elementsMap, scene.getNonDeletedElementsMap(),
elements, scene.getNonDeletedElements(),
isBindingEnabled, isBindingEnabled,
zoom, zoom,
); );
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap, scene); bindOrUnbindLinearElement(selectedElement, start, end, scene);
}); });
}; };
@@ -429,15 +407,17 @@ export const maybeBindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
appState: AppState, appState: AppState,
pointerCoords: { x: number; y: number }, pointerCoords: { x: number; y: number },
elementsMap: NonDeletedSceneElementsMap, scene: Scene,
elements: readonly NonDeletedExcalidrawElement[],
): void => { ): void => {
const elements = scene.getNonDeletedElements();
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.startBoundElement != null) { if (appState.startBoundElement != null) {
bindLinearElement( bindLinearElement(
linearElement, linearElement,
appState.startBoundElement, appState.startBoundElement,
"start", "start",
elementsMap, scene,
); );
} }
@@ -458,7 +438,7 @@ export const maybeBindLinearElement = (
"end", "end",
) )
) { ) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap); bindLinearElement(linearElement, hoveredElement, "end", scene);
} }
} }
}; };
@@ -487,7 +467,7 @@ export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, scene: Scene,
): void => { ): void => {
if (!isArrowElement(linearElement)) { if (!isArrowElement(linearElement)) {
return; return;
@@ -500,7 +480,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, scene.getNonDeletedElementsMap(),
), ),
hoveredElement, hoveredElement,
), ),
@@ -513,18 +493,17 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
), ),
}; };
} }
mutateElement(linearElement, { scene.mutateElement(linearElement, {
[startOrEnd === "start" ? "startBinding" : "endBinding"]: binding, [startOrEnd === "start" ? "startBinding" : "endBinding"]: binding,
}); });
const boundElementsMap = arrayToMap(hoveredElement.boundElements || []); const boundElementsMap = arrayToMap(hoveredElement.boundElements || []);
if (!boundElementsMap.has(linearElement.id)) { if (!boundElementsMap.has(linearElement.id)) {
mutateElement(hoveredElement, { scene.mutateElement(hoveredElement, {
boundElements: (hoveredElement.boundElements || []).concat({ boundElements: (hoveredElement.boundElements || []).concat({
id: linearElement.id, id: linearElement.id,
type: "arrow", type: "arrow",
@@ -566,13 +545,14 @@ const isLinearElementSimple = (
const unbindLinearElement = ( const unbindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
scene: Scene,
): ExcalidrawBindableElement["id"] | null => { ): ExcalidrawBindableElement["id"] | null => {
const field = startOrEnd === "start" ? "startBinding" : "endBinding"; const field = startOrEnd === "start" ? "startBinding" : "endBinding";
const binding = linearElement[field]; const binding = linearElement[field];
if (binding == null) { if (binding == null) {
return null; return null;
} }
mutateElement(linearElement, { [field]: null }); scene.mutateElement(linearElement, { [field]: null });
return binding.elementId; return binding.elementId;
}; };
@@ -735,25 +715,30 @@ const calculateFocusAndGap = (
// Supports translating, rotating and scaling `changedElement` with bound // Supports translating, rotating and scaling `changedElement` with bound
// linear elements. // linear elements.
// Because scaling involves moving the focus points as well, it is
// done before the `changedElement` is updated, and the `newSize` is passed
// in explicitly.
export const updateBoundElements = ( export const updateBoundElements = (
changedElement: NonDeletedExcalidrawElement, changedElement: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number }; newSize?: { width: number; height: number };
changedElements?: Map<string, OrderedExcalidrawElement>; changedElements?: Map<string, ExcalidrawElement>;
}, },
) => { ) => {
if (!isBindableElement(changedElement)) {
return;
}
const { newSize, simultaneouslyUpdated } = options ?? {}; const { newSize, simultaneouslyUpdated } = options ?? {};
const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds(
simultaneouslyUpdated, simultaneouslyUpdated,
); );
if (!isBindableElement(changedElement)) { let elementsMap: ElementsMap = scene.getNonDeletedElementsMap();
return; if (options?.changedElements) {
elementsMap = new Map(elementsMap) as typeof elementsMap;
options.changedElements.forEach((element) => {
elementsMap.set(element.id, element);
});
} }
boundElementsVisitor(elementsMap, changedElement, (element) => { boundElementsVisitor(elementsMap, changedElement, (element) => {
@@ -796,7 +781,7 @@ export const updateBoundElements = (
// `linearElement` is being moved/scaled already, just update the binding // `linearElement` is being moved/scaled already, just update the binding
if (simultaneouslyUpdatedElementIds.has(element.id)) { if (simultaneouslyUpdatedElementIds.has(element.id)) {
mutateElement(element, bindings, true); scene.mutateElement(element, bindings);
return; return;
} }
@@ -843,27 +828,41 @@ export const updateBoundElements = (
}> => update !== null, }> => update !== null,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(element, scene, updates, {
element, ...(changedElement.id === element.startBinding?.elementId
updates, ? { startBinding: bindings.startBinding }
{ : {}),
...(changedElement.id === element.startBinding?.elementId ...(changedElement.id === element.endBinding?.elementId
? { startBinding: bindings.startBinding } ? { endBinding: bindings.endBinding }
: {}), : {}),
...(changedElement.id === element.endBinding?.elementId });
? { endBinding: bindings.endBinding }
: {}),
},
elementsMap as NonDeletedSceneElementsMap,
);
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !boundText.isDeleted) { if (boundText && !boundText.isDeleted) {
handleBindTextResize(element, elementsMap, false); handleBindTextResize(element, scene, false);
} }
}); });
}; };
export const updateBindings = (
latestElement: ExcalidrawElement,
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
} else {
updateBoundElements(latestElement, scene, {
...options,
changedElements: new Map([[latestElement.id, latestElement]]),
});
}
};
const doesNeedUpdate = ( const doesNeedUpdate = (
boundElement: NonDeleted<ExcalidrawLinearElement>, boundElement: NonDeleted<ExcalidrawLinearElement>,
changedElement: ExcalidrawBindableElement, changedElement: ExcalidrawBindableElement,
@@ -885,7 +884,6 @@ export const getHeadingForElbowArrowSnap = (
otherPoint: Readonly<GlobalPoint>, otherPoint: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
elementsMap: ElementsMap,
origPoint: GlobalPoint, origPoint: GlobalPoint,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
@@ -895,12 +893,7 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding( const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
@@ -914,7 +907,6 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToBindableElement(bindableElement, point);
@@ -1039,7 +1031,14 @@ export const avoidRectangularCorner = (
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
// Top left // Top left
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x, element.y),
);
if (
compareHeading(heading, HEADING_DOWN) ||
compareHeading(heading, HEADING_LEFT)
) {
return pointRotateRads<GlobalPoint>( return pointRotateRads<GlobalPoint>(
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y), pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
center, center,
@@ -1056,7 +1055,14 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > element.y + element.height
) { ) {
// Bottom left // Bottom left
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x, element.y + element.height),
);
if (
compareHeading(heading, HEADING_DOWN) ||
compareHeading(heading, HEADING_RIGHT)
) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
element.x, element.x,
@@ -1076,9 +1082,13 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] > element.y + element.height nonRotatedPoint[1] > element.y + element.height
) { ) {
// Bottom right // Bottom right
const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x + element.width, element.y + element.height),
);
if ( if (
nonRotatedPoint[0] - element.x < compareHeading(heading, HEADING_DOWN) ||
element.width + FIXED_BINDING_DISTANCE compareHeading(heading, HEADING_LEFT)
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
@@ -1102,9 +1112,13 @@ export const avoidRectangularCorner = (
nonRotatedPoint[1] < element.y nonRotatedPoint[1] < element.y
) { ) {
// Top right // Top right
const heading = headingForPoint(
nonRotatedPoint,
pointFrom(element.x + element.width, element.y),
);
if ( if (
nonRotatedPoint[0] - element.x < compareHeading(heading, HEADING_UP) ||
element.width + FIXED_BINDING_DISTANCE compareHeading(heading, HEADING_LEFT)
) { ) {
return pointRotateRads( return pointRotateRads(
pointFrom( pointFrom(
@@ -1122,6 +1136,17 @@ export const avoidRectangularCorner = (
); );
} }
// Break up explicit border bindings to have better elbow arrow routing
if (p[0] === element.x) {
return pointFrom(p[0] - FIXED_BINDING_DISTANCE, p[1]);
} else if (p[0] === element.x + element.width) {
return pointFrom(p[0] + FIXED_BINDING_DISTANCE, p[1]);
} else if (p[1] === element.y) {
return pointFrom(p[0], p[1] - FIXED_BINDING_DISTANCE);
} else if (p[1] === element.y + element.height) {
return pointFrom(p[0], p[1] + FIXED_BINDING_DISTANCE);
}
return p; return p;
}; };
@@ -1216,7 +1241,6 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
@@ -1320,7 +1344,6 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@@ -1486,8 +1509,12 @@ export const fixBindingsAfterDeletion = (
const elements = arrayToMap(sceneElements); const elements = arrayToMap(sceneElements);
for (const element of deletedElements) { for (const element of deletedElements) {
BoundElement.unbindAffected(elements, element, mutateElement); BoundElement.unbindAffected(elements, element, (element, updates) =>
BindableElement.unbindAffected(elements, element, mutateElement); mutateElement(element, elements, updates),
);
BindableElement.unbindAffected(elements, element, (element, updates) =>
mutateElement(element, elements, updates),
);
} }
}; };
+15 -15
View File
@@ -1,17 +1,17 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
rescalePoints,
arrayToMap, arrayToMap,
invariant, invariant,
rescalePoints,
sizeOf, sizeOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
lineSegment, lineSegment,
pointFrom,
pointDistance, pointDistance,
pointFrom,
pointFromArray, pointFromArray,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
@@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache";
import { generateRoughOptions } from "./Shape"; import { generateRoughOptions } from "./Shape";
import { ShapeCache } from "./ShapeCache";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { import {
@@ -52,20 +52,20 @@ import {
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
Arrowhead,
ExcalidrawFreeDrawElement,
NonDeleted,
ExcalidrawTextElementWithContainer,
ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types";
import type { Drawable, Op } from "roughjs/bin/core"; import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; 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 = { export type RectangleBox = {
x: number; x: number;
+16 -9
View File
@@ -11,13 +11,10 @@ import type {
PointerDownState, PointerDownState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements"; import { getMinTextElementWidth } from "./textMeasurements";
@@ -29,6 +26,8 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types"; import type { ExcalidrawElement } from "./types";
@@ -104,7 +103,7 @@ export const dragSelectedElements = (
); );
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, adjustedOffset); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
if (!isArrowElement(element)) { if (!isArrowElement(element)) {
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
@@ -112,9 +111,14 @@ export const dragSelectedElements = (
scene.getNonDeletedElementsMap(), scene.getNonDeletedElementsMap(),
); );
if (textElement) { if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset); updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
} }
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
} }
@@ -155,6 +159,7 @@ const calculateOffset = (
const updateElementCoords = ( const updateElementCoords = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
) => { ) => {
const originalElement = const originalElement =
@@ -163,7 +168,7 @@ const updateElementCoords = (
const nextX = originalElement.x + dragOffset.x; const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y; const nextY = originalElement.y + dragOffset.y;
mutateElement(element, { scene.mutateElement(element, {
x: nextX, x: nextX,
y: nextY, y: nextY,
}); });
@@ -190,6 +195,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
shouldResizeFromCenter, shouldResizeFromCenter,
zoom, zoom,
scene,
widthAspectRatio = null, widthAspectRatio = null,
originOffset = null, originOffset = null,
informMutation = true, informMutation = true,
@@ -205,6 +211,7 @@ export const dragNewElement = ({
shouldMaintainAspectRatio: boolean; shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean; shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue; zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */ true */
widthAspectRatio?: number | null; widthAspectRatio?: number | null;
@@ -285,7 +292,7 @@ export const dragNewElement = ({
}; };
} }
mutateElement( scene.mutateElement(
newElement, newElement,
{ {
x: newX + (originOffset?.x ?? 0), x: newX + (originOffset?.x ?? 0),
@@ -295,7 +302,7 @@ export const dragNewElement = ({
...textAutoResize, ...textAutoResize,
...imageInitialDimension, ...imageInitialDimension,
}, },
informMutation, { informMutation, isDragging: false },
); );
} }
}; };
+165 -68
View File
@@ -50,10 +50,9 @@ import { isBindableElement } from "./typeChecks";
import { import {
type ExcalidrawElbowArrowElement, type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
type SceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./shapes"; import { aabbForElement, aabbForPoints, pointInsideBounds } from "./shapes";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
@@ -66,6 +65,8 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
type GridAddress = [number, number] & { _brand: "gridaddress" }; type GridAddress = [number, number] & { _brand: "gridaddress" };
type Node = { type Node = {
@@ -107,8 +108,32 @@ type ElbowArrowData = {
hoveredEndElement: ExcalidrawBindableElement | null; hoveredEndElement: ExcalidrawBindableElement | null;
}; };
const DEDUP_TRESHOLD = 1; const calculateDedupTreshhold = <Point extends GlobalPoint | LocalPoint>(
export const BASE_PADDING = 40; a: Point,
b: Point,
) => 1 + pointDistance(a, b) / 300;
const calculatePadding = (
aabb: Bounds,
startBoundingBox: Bounds,
endBoundingBox: Bounds,
) => {
return Math.max(
Math.min(
Math.hypot(
startBoundingBox[2] - startBoundingBox[0],
startBoundingBox[3] - startBoundingBox[1],
) / 4,
Math.hypot(
endBoundingBox[2] - endBoundingBox[0],
endBoundingBox[3] - endBoundingBox[1],
) / 4,
Math.hypot(aabb[2] - aabb[0], aabb[3] - aabb[1]) / 4,
40,
),
30,
);
};
const handleSegmentRenormalization = ( const handleSegmentRenormalization = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
@@ -184,7 +209,11 @@ const handleSegmentRenormalization = (
if ( if (
// Remove segments that are too short // Remove segments that are too short
pointDistance(points[i - 2], points[i - 1]) < DEDUP_TRESHOLD pointDistance(points[i - 2], points[i - 1]) <
calculateDedupTreshhold(
points[i - 3] ?? points[i - 3],
points[i] ?? points[i - 1],
)
) { ) {
const prevPrevSegmentIdx = const prevPrevSegmentIdx =
nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ?? nextFixedSegments?.findIndex((segment) => segment.index === i - 2) ??
@@ -360,6 +389,10 @@ const handleSegmentRelease = (
null, null,
); );
if (!restoredPoints) {
return {};
}
const nextPoints: GlobalPoint[] = []; const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points // First part of the arrow are the old points
@@ -464,6 +497,13 @@ const handleSegmentMove = (
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
): ElementUpdate<ExcalidrawElbowArrowElement> => { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
const BASE_PADDING = calculatePadding(
aabbForElement(arrow),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
const activelyModifiedSegmentIdx = fixedSegments const activelyModifiedSegmentIdx = fixedSegments
.map((segment, i) => { .map((segment, i) => {
if ( if (
@@ -708,6 +748,13 @@ const handleEndpointDrag = (
hoveredStartElement: ExcalidrawBindableElement | null, hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null, hoveredEndElement: ExcalidrawBindableElement | null,
) => { ) => {
const BASE_PADDING = calculatePadding(
aabbForPoints([startGlobalPoint, endGlobalPoint]),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
let startIsSpecial = arrow.startIsSpecial ?? null; let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null; let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) => const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -742,6 +789,7 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point // Calculate the moving second point connection and add the start point
{ {
startIsSpecial = arrow.startIsSpecial && globalUpdatedPoints.length > 2;
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1]; const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2]; const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const startIsHorizontal = headingIsHorizontal(startHeading); const startIsHorizontal = headingIsHorizontal(startHeading);
@@ -802,6 +850,7 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection // Calculate the moving second to last point connection
{ {
endIsSpecial = arrow.endIsSpecial && globalUpdatedPoints.length > 2;
const secondToLastPoint = const secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)]; globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint = const thirdToLastPoint =
@@ -887,7 +936,7 @@ export const updateElbowArrowPoints = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
updates: { updates: {
points?: readonly LocalPoint[]; points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null; fixedSegments?: readonly FixedSegment[] | null;
startBinding?: FixedPointBinding | null; startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null;
}, },
@@ -1273,14 +1322,12 @@ const getElbowArrowData = (
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
startGlobalPoint, startGlobalPoint,
endGlobalPoint, endGlobalPoint,
elementsMap,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
elementsMap,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
); );
@@ -1296,29 +1343,28 @@ const getElbowArrowData = (
endGlobalPoint[0] + 2, endGlobalPoint[0] + 2,
endGlobalPoint[1] + 2, endGlobalPoint[1] + 2,
] as Bounds; ] as Bounds;
const BASE_PADDING = calculatePadding(
aabbForPoints([startGlobalPoint, endGlobalPoint]),
hoveredStartElement
? aabbForElement(hoveredStartElement)
: [10, 10, 10, 10],
hoveredEndElement ? aabbForElement(hoveredEndElement) : [10, 10, 10, 10],
);
const startOffsets = offsetFromHeading(
startHeading,
arrow.startArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
1,
);
const endOffsets = offsetFromHeading(
endHeading,
arrow.endArrowhead ? FIXED_BINDING_DISTANCE * 4 : FIXED_BINDING_DISTANCE,
1,
);
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(hoveredStartElement, startOffsets)
hoveredStartElement,
offsetFromHeading(
startHeading,
arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: startPointBounds; : startPointBounds;
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(hoveredEndElement, endOffsets)
hoveredEndElement,
offsetFromHeading(
endHeading,
arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2,
1,
),
)
: endPointBounds; : endPointBounds;
const boundsOverlap = const boundsOverlap =
pointInsideBounds( pointInsideBounds(
@@ -1361,7 +1407,7 @@ const getElbowArrowData = (
: BASE_PADDING - : BASE_PADDING -
(arrow.startArrowhead (arrow.startArrowhead
? FIXED_BINDING_DISTANCE * 6 ? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2), : FIXED_BINDING_DISTANCE),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap boundsOverlap
@@ -1377,13 +1423,29 @@ const getElbowArrowData = (
: BASE_PADDING - : BASE_PADDING -
(arrow.endArrowhead (arrow.endArrowhead
? FIXED_BINDING_DISTANCE * 6 ? FIXED_BINDING_DISTANCE * 6
: FIXED_BINDING_DISTANCE * 2), : FIXED_BINDING_DISTANCE),
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement
hoveredEndElement && aabbForElement(hoveredEndElement), ? aabbForElement(hoveredStartElement)
: startPointBounds,
hoveredEndElement ? aabbForElement(hoveredEndElement) : endPointBounds,
); );
debugDrawBounds(startElementBounds, {
permanent: false,
color: "red",
});
debugDrawBounds(endElementBounds, {
permanent: false,
color: "green",
});
debugDrawBounds(dynamicAABBs, {
permanent: false,
color: "blue",
});
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
startHeading, startHeading,
@@ -1654,11 +1716,11 @@ const generateDynamicAABBs = (
a: Bounds, a: Bounds,
b: Bounds, b: Bounds,
common: Bounds, common: Bounds,
startDifference?: [number, number, number, number], startDifference: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference: [number, number, number, number],
disableSideHack?: boolean, disableSideHack: boolean,
startElementBounds?: Bounds | null, startElementBounds: Bounds,
endElementBounds?: Bounds | null, endElementBounds: Bounds,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a; const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b; const endEl = endElementBounds ?? b;
@@ -1738,15 +1800,24 @@ const generateDynamicAABBs = (
(second[0] + second[2]) / 2, (second[0] + second[2]) / 2,
(second[1] + second[3]) / 2, (second[1] + second[3]) / 2,
]; ];
if (b[0] > a[2] && a[1] > b[3]) { if (
endElementBounds[0] > startElementBounds[2] &&
startElementBounds[1] > endElementBounds[3]
) {
// BOTTOM LEFT // BOTTOM LEFT
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2; const cY = second[3] + (first[1] - second[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY), vector(
vector(a[0] - endCenterX, a[3] - endCenterY), startElementBounds[2] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[0] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@@ -1759,15 +1830,24 @@ const generateDynamicAABBs = (
[first[0], cY, first[2], first[3]], [first[0], cY, first[2], first[3]],
[second[0], second[1], second[2], cY], [second[0], second[1], second[2], cY],
]; ];
} else if (a[2] < b[0] && a[3] < b[1]) { } else if (
startElementBounds[2] < endElementBounds[0] &&
startElementBounds[3] < endElementBounds[1]
) {
// TOP LEFT // TOP LEFT
const cX = first[2] + (second[0] - first[2]) / 2; const cX = first[2] + (second[0] - first[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2; const cY = first[3] + (second[1] - first[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY), vector(
vector(a[2] - endCenterX, a[3] - endCenterY), startElementBounds[0] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[2] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@@ -1780,15 +1860,24 @@ const generateDynamicAABBs = (
[first[0], first[1], cX, first[3]], [first[0], first[1], cX, first[3]],
[cX, second[1], second[2], second[3]], [cX, second[1], second[2], second[3]],
]; ];
} else if (a[0] > b[2] && a[3] < b[1]) { } else if (
startElementBounds[0] > endElementBounds[2] &&
startElementBounds[3] < endElementBounds[1]
) {
// TOP RIGHT // TOP RIGHT
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = first[3] + (second[1] - first[3]) / 2; const cY = first[3] + (second[1] - first[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[2] - endCenterX, a[1] - endCenterY), vector(
vector(a[0] - endCenterX, a[3] - endCenterY), startElementBounds[2] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[0] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@@ -1801,15 +1890,24 @@ const generateDynamicAABBs = (
[first[0], first[1], first[2], cY], [first[0], first[1], first[2], cY],
[second[0], cY, second[2], second[3]], [second[0], cY, second[2], second[3]],
]; ];
} else if (a[0] > b[2] && a[1] > b[3]) { } else if (
startElementBounds[0] > endElementBounds[2] &&
startElementBounds[1] > endElementBounds[3]
) {
// BOTTOM RIGHT // BOTTOM RIGHT
const cX = second[2] + (first[0] - second[2]) / 2; const cX = second[2] + (first[0] - second[2]) / 2;
const cY = second[3] + (first[1] - second[3]) / 2; const cY = second[3] + (first[1] - second[3]) / 2;
if ( if (
vectorCross( vectorCross(
vector(a[0] - endCenterX, a[1] - endCenterY), vector(
vector(a[2] - endCenterX, a[3] - endCenterY), startElementBounds[0] - endCenterX,
startElementBounds[1] - endCenterY,
),
vector(
startElementBounds[2] - endCenterX,
startElementBounds[3] - endCenterY,
),
) > 0 ) > 0
) { ) {
return [ return [
@@ -2091,16 +2189,11 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null, nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"], endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): { ): ElementUpdate<ExcalidrawElbowArrowElement> => {
points: LocalPoint[]; if (global.length === 0) {
x: number; return {};
y: number; }
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0]; const offsetX = global[0][0];
const offsetY = global[0][1]; const offsetY = global[0][1];
let points = global.map((p) => let points = global.map((p) =>
@@ -2188,7 +2281,10 @@ const removeElbowArrowShortSegments = (
const prev = points[idx - 1]; const prev = points[idx - 1];
const prevDist = pointDistance(prev, p); const prevDist = pointDistance(prev, p);
return prevDist > DEDUP_TRESHOLD; return (
prevDist >
calculateDedupTreshhold(points[idx - 2] ?? prev, points[idx + 1] ?? p)
);
}); });
} }
@@ -2250,7 +2346,6 @@ const getGlobalPoint = (
const getBindPointHeading = ( const getBindPointHeading = (
p: GlobalPoint, p: GlobalPoint,
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
): Heading => ): Heading =>
@@ -2268,7 +2363,6 @@ const getBindPointHeading = (
number, number,
], ],
), ),
elementsMap,
origPoint, origPoint,
); );
@@ -2293,13 +2387,16 @@ const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>( export const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[], points: readonly P[],
tolerance: number = DEDUP_TRESHOLD, tolerance?: number,
) => ) =>
points points
.slice(1) .slice(1)
.map( .map((p, i) => {
(p, i) => const t =
Math.abs(p[0] - points[i][0]) < tolerance || tolerance ??
Math.abs(p[1] - points[i][1]) < tolerance, calculateDedupTreshhold(points[i - 1] ?? points[i], points[i + 2] ?? p);
) return (
Math.abs(p[0] - points[i][0]) < t || Math.abs(p[1] - points[i][1]) < t
);
})
.every(Boolean); .every(Boolean);
+20 -28
View File
@@ -39,6 +39,8 @@ import {
type OrderedExcalidrawElement, type OrderedExcalidrawElement,
} from "./types"; } from "./types";
import type Scene from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left"; type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100; const VERTICAL_OFFSET = 100;
@@ -236,10 +238,11 @@ const getOffsets = (
const addNewNode = ( const addNewNode = (
element: ExcalidrawFlowchartNodeElement, element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState, appState: AppState,
direction: LinkDirection, direction: LinkDirection,
scene: Scene,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const successors = getSuccessors(element, elementsMap, direction); const successors = getSuccessors(element, elementsMap, direction);
const predeccessors = getPredecessors(element, elementsMap, direction); const predeccessors = getPredecessors(element, elementsMap, direction);
@@ -274,9 +277,9 @@ const addNewNode = (
const bindingArrow = createBindingArrow( const bindingArrow = createBindingArrow(
element, element,
nextNode, nextNode,
elementsMap,
direction, direction,
appState, appState,
scene,
); );
return { return {
@@ -287,9 +290,9 @@ const addNewNode = (
export const addNewNodes = ( export const addNewNodes = (
startNode: ExcalidrawFlowchartNodeElement, startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState, appState: AppState,
direction: LinkDirection, direction: LinkDirection,
scene: Scene,
numberOfNodes: number, numberOfNodes: number,
) => { ) => {
// always start from 0 and distribute evenly // always start from 0 and distribute evenly
@@ -352,9 +355,9 @@ export const addNewNodes = (
const bindingArrow = createBindingArrow( const bindingArrow = createBindingArrow(
startNode, startNode,
nextNode, nextNode,
elementsMap,
direction, direction,
appState, appState,
scene,
); );
newNodes.push(nextNode); newNodes.push(nextNode);
@@ -367,9 +370,9 @@ export const addNewNodes = (
const createBindingArrow = ( const createBindingArrow = (
startBindingElement: ExcalidrawFlowchartNodeElement, startBindingElement: ExcalidrawFlowchartNodeElement,
endBindingElement: ExcalidrawFlowchartNodeElement, endBindingElement: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
direction: LinkDirection, direction: LinkDirection,
appState: AppState, appState: AppState,
scene: Scene,
) => { ) => {
let startX: number; let startX: number;
let startY: number; let startY: number;
@@ -440,18 +443,10 @@ const createBindingArrow = (
elbowed: true, elbowed: true,
}); });
bindLinearElement( const elementsMap = scene.getNonDeletedElementsMap();
bindingArrow,
startBindingElement, bindLinearElement(bindingArrow, startBindingElement, "start", scene);
"start", bindLinearElement(bindingArrow, endBindingElement, "end", scene);
elementsMap as NonDeletedSceneElementsMap,
);
bindLinearElement(
bindingArrow,
endBindingElement,
"end",
elementsMap as NonDeletedSceneElementsMap,
);
const changedElements = new Map<string, OrderedExcalidrawElement>(); const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set( changedElements.set(
@@ -467,7 +462,7 @@ const createBindingArrow = (
bindingArrow as OrderedExcalidrawElement, bindingArrow as OrderedExcalidrawElement,
); );
LinearElementEditor.movePoints(bindingArrow, [ LinearElementEditor.movePoints(bindingArrow, scene, [
{ {
index: 1, index: 1,
point: bindingArrow.points[1], point: bindingArrow.points[1],
@@ -632,16 +627,17 @@ export class FlowChartCreator {
createNodes( createNodes(
startNode: ExcalidrawFlowchartNodeElement, startNode: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
appState: AppState, appState: AppState,
direction: LinkDirection, direction: LinkDirection,
scene: Scene,
) { ) {
const elementsMap = scene.getNonDeletedElementsMap();
if (direction !== this.direction) { if (direction !== this.direction) {
const { nextNode, bindingArrow } = addNewNode( const { nextNode, bindingArrow } = addNewNode(
startNode, startNode,
elementsMap,
appState, appState,
direction, direction,
scene,
); );
this.numberOfNodes = 1; this.numberOfNodes = 1;
@@ -652,9 +648,9 @@ export class FlowChartCreator {
this.numberOfNodes += 1; this.numberOfNodes += 1;
const newNodes = addNewNodes( const newNodes = addNewNodes(
startNode, startNode,
elementsMap,
appState, appState,
direction, direction,
scene,
this.numberOfNodes, this.numberOfNodes,
); );
@@ -682,13 +678,9 @@ export class FlowChartCreator {
) )
) { ) {
this.pendingNodes = this.pendingNodes.map((node) => this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement( mutateElement(node, elementsMap, {
node, frameId: startNode.frameId,
{ }),
frameId: startNode.frameId,
},
false,
),
); );
} }
} }
+8 -4
View File
@@ -7,6 +7,7 @@ import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
import type { import type {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
@@ -152,9 +153,10 @@ export const orderByFractionalIndex = (
*/ */
export const syncMovedIndices = ( export const syncMovedIndices = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>, movedElements: ElementsMap,
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
try { try {
const elementsMap = arrayToMap(elements);
const indicesGroups = getMovedIndicesGroups(elements, movedElements); const indicesGroups = getMovedIndicesGroups(elements, movedElements);
// try generatating indices, throws on invalid movedElements // try generatating indices, throws on invalid movedElements
@@ -176,7 +178,7 @@ export const syncMovedIndices = (
// split mutation so we don't end up in an incosistent state // split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false); mutateElement(element, elementsMap, update);
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@@ -194,10 +196,12 @@ export const syncMovedIndices = (
export const syncInvalidIndices = ( export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
): OrderedExcalidrawElement[] => { ): OrderedExcalidrawElement[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false); mutateElement(element, elementsMap, update);
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
@@ -210,7 +214,7 @@ export const syncInvalidIndices = (
*/ */
const getMovedIndicesGroups = ( const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>, movedElements: ElementsMap,
) => { ) => {
const indicesGroups: number[][] = []; const indicesGroups: number[][] = [];
+11 -23
View File
@@ -3,8 +3,6 @@ import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds"; import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type { ExcalidrawElementsIncludingDeleted } from "@excalidraw/excalidraw/scene/Scene";
import type { import type {
AppClassProperties, AppClassProperties,
AppState, AppState,
@@ -29,6 +27,8 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { ExcalidrawElementsIncludingDeleted } from "./Scene";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
@@ -56,13 +56,9 @@ export const bindElementsToFramesAfterDuplication = (
const nextFrameId = origIdToDuplicateId.get(element.frameId); const nextFrameId = origIdToDuplicateId.get(element.frameId);
const nextElement = nextElementId && nextElementMap.get(nextElementId); const nextElement = nextElementId && nextElementMap.get(nextElementId);
if (nextElement) { if (nextElement) {
mutateElement( mutateElement(nextElement, nextElementMap, {
nextElement, frameId: nextFrameId ?? null,
{ });
frameId: nextFrameId ?? null,
},
false,
);
} }
} }
} }
@@ -565,13 +561,9 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
} }
for (const element of finalElementsToAdd) { for (const element of finalElementsToAdd) {
mutateElement( mutateElement(element, elementsMap, {
element, frameId: frame.id,
{ });
frameId: frame.id,
},
false,
);
} }
return allElements; return allElements;
@@ -609,13 +601,9 @@ export const removeElementsFromFrame = (
} }
for (const [, element] of _elementsToRemove) { for (const [, element] of _elementsToRemove) {
mutateElement( mutateElement(element, elementsMap, {
element, frameId: null,
{ });
frameId: null,
},
false,
);
} }
}; };
+65 -66
View File
@@ -20,10 +20,6 @@ import {
tupleToCoors, tupleToCoors,
} from "@excalidraw/common"; } 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 { Store } from "@excalidraw/excalidraw/store"; import type { Store } from "@excalidraw/excalidraw/store";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@@ -50,10 +46,8 @@ import {
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
} from "./bounds"; } from "./bounds";
import { updateElbowArrowPoints } from "./elbowArrow";
import { headingIsHorizontal, vectorToHeading } from "./heading"; import { headingIsHorizontal, vectorToHeading } from "./heading";
import { bumpVersion, mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { import {
isBindingElement, isBindingElement,
@@ -73,6 +67,8 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type Scene from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { import type {
NonDeleted, NonDeleted,
@@ -84,7 +80,6 @@ import type {
ElementsMap, ElementsMap,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
FixedPointBinding, FixedPointBinding,
SceneElementsMap,
FixedSegment, FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
@@ -127,15 +122,17 @@ export class LinearElementEditor {
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) { constructor(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
}; };
if (!pointsEqual(element.points[0], pointFrom(0, 0))) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element); LinearElementEditor.normalizePoints(element, elementsMap);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
this.isDragging = false; this.isDragging = false;
@@ -309,7 +306,7 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
); );
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, scene, [
{ {
index: selectedIndex, index: selectedIndex,
point: pointFrom( point: pointFrom(
@@ -333,6 +330,7 @@ export class LinearElementEditor {
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene,
selectedPointsIndices.map((pointIndex) => { selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint = const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint pointIndex === lastClickedPoint
@@ -358,7 +356,7 @@ export class LinearElementEditor {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) { if (boundTextElement) {
handleBindTextResize(element, elementsMap, false); handleBindTextResize(element, scene, false);
} }
// suggest bindings for first and last point if selected // suggest bindings for first and last point if selected
@@ -453,7 +451,7 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, scene, [
{ {
index: selectedPoint, index: selectedPoint,
point: point:
@@ -795,7 +793,7 @@ export class LinearElementEditor {
); );
} else if (event.altKey && appState.editingLinearElement) { } else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) { if (linearElementEditor.lastUncommittedPoint == null) {
mutateElement(element, { scene.mutateElement(element, {
points: [ points: [
...element.points, ...element.points,
LinearElementEditor.createPointAt( LinearElementEditor.createPointAt(
@@ -861,7 +859,6 @@ export class LinearElementEditor {
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
scene, scene,
); );
} }
@@ -934,13 +931,13 @@ export class LinearElementEditor {
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
app: AppClassProperties, app: AppClassProperties,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): LinearElementEditor | null { ): LinearElementEditor | null {
const appState = app.state; const appState = app.state;
if (!appState.editingLinearElement) { if (!appState.editingLinearElement) {
return null; return null;
} }
const { elementId, lastUncommittedPoint } = appState.editingLinearElement; const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) { if (!element) {
return appState.editingLinearElement; return appState.editingLinearElement;
@@ -951,7 +948,9 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, [points.length - 1]); LinearElementEditor.deletePoints(element, app.scene, [
points.length - 1,
]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -989,14 +988,14 @@ export class LinearElementEditor {
} }
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, app.scene, [
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: newPoint, point: newPoint,
}, },
]); ]);
} else { } else {
LinearElementEditor.addPoints(element, [{ point: newPoint }]); LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -1160,23 +1159,26 @@ export class LinearElementEditor {
y: element.y + offsetY, y: element.y + offsetY,
}; };
} }
// element-mutating methods // element-mutating methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static normalizePoints(
static normalizePoints(element: NonDeleted<ExcalidrawLinearElement>) { element: NonDeleted<ExcalidrawLinearElement>,
mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); elementsMap: ElementsMap,
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
} }
static duplicateSelectedPoints( static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
appState: AppState,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
): AppState {
invariant( invariant(
appState.editingLinearElement, appState.editingLinearElement,
"Not currently editing a linear element", "Not currently editing a linear element",
); );
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.editingLinearElement; const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
@@ -1219,13 +1221,13 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
mutateElement(element, { points: nextPoints }); scene.mutateElement(element, { points: nextPoints });
// temp hack to ensure the line doesn't move when adding point to the end, // temp hack to ensure the line doesn't move when adding point to the end,
// potentially expanding the bounding box // potentially expanding the bounding box
if (pointAddedToEnd) { if (pointAddedToEnd) {
const lastPoint = element.points[element.points.length - 1]; const lastPoint = element.points[element.points.length - 1];
LinearElementEditor.movePoints(element, [ LinearElementEditor.movePoints(element, scene, [
{ {
index: element.points.length - 1, index: element.points.length - 1,
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30), point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
@@ -1244,6 +1246,7 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
let offsetX = 0; let offsetX = 0;
@@ -1274,28 +1277,41 @@ export class LinearElementEditor {
return acc; return acc;
}, []); }, []);
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
);
} }
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
targetPoints: { point: LocalPoint }[], targetPoints: { point: LocalPoint }[],
) { ) {
const offsetX = 0; const offsetX = 0;
const offsetY = 0; const offsetY = 0;
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)]; const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints(element, nextPoints, offsetX, offsetY); LinearElementEditor._updatePoints(
element,
scene,
nextPoints,
offsetX,
offsetY,
);
} }
static movePoints( static movePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
otherUpdates?: { otherUpdates?: {
startBinding?: PointBinding | null; startBinding?: PointBinding | null;
endBinding?: PointBinding | null; endBinding?: PointBinding | null;
}, },
sceneElementsMap?: NonDeletedSceneElementsMap,
) { ) {
const { points } = element; const { points } = element;
@@ -1329,6 +1345,7 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene,
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
@@ -1339,7 +1356,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true, dragging || targetPoint.isDragging === true,
false, false,
), ),
sceneElementsMap,
}, },
); );
} }
@@ -1394,8 +1410,9 @@ export class LinearElementEditor {
pointerCoords: PointerCoords, pointerCoords: PointerCoords,
app: AppClassProperties, app: AppClassProperties,
snapToGrid: boolean, snapToGrid: boolean,
elementsMap: ElementsMap, scene: Scene,
) { ) {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
linearElementEditor.elementId, linearElementEditor.elementId,
elementsMap, elementsMap,
@@ -1425,9 +1442,7 @@ export class LinearElementEditor {
...element.points.slice(segmentMidpoint.index!), ...element.points.slice(segmentMidpoint.index!),
]; ];
mutateElement(element, { scene.mutateElement(element, { points });
points,
});
ret.pointerDownState = { ret.pointerDownState = {
...linearElementEditor.pointerDownState, ...linearElementEditor.pointerDownState,
@@ -1443,6 +1458,7 @@ export class LinearElementEditor {
private static _updatePoints( private static _updatePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offsetX: number, offsetX: number,
offsetY: number, offsetY: number,
@@ -1479,28 +1495,10 @@ export class LinearElementEditor {
updates.points = Array.from(nextPoints); updates.points = Array.from(nextPoints);
if (!options?.sceneElementsMap || Scene.getScene(element)) { scene.mutateElement(element, updates, {
mutateElement(element, updates, true, { informMutation: true,
isDragging: options?.isDragging, isDragging: options?.isDragging ?? false,
}); });
} 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 { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
@@ -1515,7 +1513,7 @@ export class LinearElementEditor {
pointFrom(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
); );
mutateElement(element, { scene.mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotated[0],
@@ -1574,7 +1572,7 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
if (points.length < 2) { if (points.length < 2) {
mutateElement(boundTextElement, { isDeleted: true }); mutateElement(boundTextElement, elementsMap, { isDeleted: true });
} }
let x = 0; let x = 0;
let y = 0; let y = 0;
@@ -1781,8 +1779,9 @@ export class LinearElementEditor {
index: number, index: number,
x: number, x: number,
y: number, y: number,
elementsMap: ElementsMap, scene: Scene,
): LinearElementEditor { ): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement( const element = LinearElementEditor.getElement(
linearElement.elementId, linearElement.elementId,
elementsMap, elementsMap,
@@ -1825,7 +1824,7 @@ export class LinearElementEditor {
.map((segment) => segment.index) .map((segment) => segment.index)
.reduce((count, idx) => (idx < index ? count + 1 : count), 0); .reduce((count, idx) => (idx < index ? count + 1 : count), 0);
mutateElement(element, { scene.mutateElement(element, {
fixedSegments: nextFixedSegments, fixedSegments: nextFixedSegments,
}); });
@@ -1859,14 +1858,14 @@ export class LinearElementEditor {
static deleteFixedSegment( static deleteFixedSegment(
element: ExcalidrawElbowArrowElement, element: ExcalidrawElbowArrowElement,
scene: Scene,
index: number, index: number,
): void { ): void {
mutateElement(element, { scene.mutateElement(element, {
fixedSegments: element.fixedSegments?.filter( fixedSegments: element.fixedSegments?.filter(
(segment) => segment.index !== index, (segment) => segment.index !== index,
), ),
}); });
mutateElement(element, {}, true);
} }
} }
+21 -34
View File
@@ -2,13 +2,8 @@ import {
getSizeFromPoints, getSizeFromPoints,
randomInteger, randomInteger,
getUpdatedTimestamp, getUpdatedTimestamp,
toBrandedType,
} from "@excalidraw/common"; } 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 { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
@@ -16,35 +11,42 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./ShapeCache"; import { ShapeCache } from "./ShapeCache";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks"; import { isElbowArrow } from "./typeChecks";
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types"; import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "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 * This function tracks updates of text elements for the purposes for collaboration.
// the same drawing. Note: this will trigger the component to update. Make sure you * The version is used to compare updates when more than one user is working in
// are calling it either from a React event handler or within unstable_batchedUpdates(). * 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.
*/
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>( export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement, element: TElement,
elementsMap: ElementsMap,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
informMutation = true,
options?: { 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; isDragging?: boolean;
}, },
): TElement => { ) => {
let didChange = false; let didChange = false;
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId, startBinding, endBinding } = const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any; updates as any;
if ( if (
@@ -55,10 +57,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
typeof startBinding !== "undefined" || typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element typeof endBinding !== "undefined") // manual binding to element
) { ) {
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
);
updates = { updates = {
...updates, ...updates,
angle: 0 as Radians, angle: 0 as Radians,
@@ -68,16 +66,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
x: updates.x || element.x, x: updates.x || element.x,
y: updates.y || element.y, y: updates.y || element.y,
}, },
elementsMap, elementsMap as NonDeletedSceneElementsMap,
{ updates as ElementUpdate<ExcalidrawElbowArrowElement>,
fixedSegments, options,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
},
), ),
}; };
} else if (typeof points !== "undefined") { } else if (typeof points !== "undefined") {
@@ -150,10 +141,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element.versionNonce = randomInteger(); element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
if (informMutation) {
Scene.getScene(element)?.triggerUpdate();
}
return element; return element;
}; };
+1 -2
View File
@@ -44,7 +44,6 @@ import type {
ExcalidrawIframeElement, ExcalidrawIframeElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
FixedSegment,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
@@ -478,7 +477,7 @@ export const newArrowElement = <T extends boolean>(
endArrowhead?: Arrowhead | null; endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"]; points?: ExcalidrawArrowElement["points"];
elbowed?: T; elbowed?: T;
fixedSegments?: FixedSegment[] | null; fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): T extends true ): T extends true
? NonDeleted<ExcalidrawElbowArrowElement> ? NonDeleted<ExcalidrawElbowArrowElement>
+51 -63
View File
@@ -17,8 +17,6 @@ import {
import type { GlobalPoint } from "@excalidraw/math"; import type { GlobalPoint } from "@excalidraw/math";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import type { PointerDownState } from "@excalidraw/excalidraw/types"; import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
@@ -32,7 +30,6 @@ import {
getElementBounds, getElementBounds,
} from "./bounds"; } from "./bounds";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getBoundTextElementId, getBoundTextElementId,
@@ -60,6 +57,8 @@ import {
import { isInGroup } from "./groups"; import { isInGroup } from "./groups";
import type Scene from "./Scene";
import type { BoundingBox } from "./bounds"; import type { BoundingBox } from "./bounds";
import type { import type {
MaybeTransformHandleType, MaybeTransformHandleType,
@@ -74,7 +73,6 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
SceneElementsMap,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
} from "./types"; } from "./types";
@@ -83,7 +81,6 @@ export const transformElements = (
originalElements: PointerDownState["originalElements"], originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene, scene: Scene,
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
@@ -93,31 +90,31 @@ export const transformElements = (
centerX: number, centerX: number,
centerY: number, centerY: number,
): boolean => { ): boolean => {
const elementsMap = scene.getNonDeletedElementsMap();
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const [element] = selectedElements; const [element] = selectedElements;
if (transformHandleType === "rotation") { if (transformHandleType === "rotation") {
if (!isElbowArrow(element)) { if (!isElbowArrow(element)) {
rotateSingleElement( rotateSingleElement(
element, element,
elementsMap,
scene, scene,
pointerX, pointerX,
pointerY, pointerY,
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, scene);
} }
} else if (isTextElement(element) && transformHandleType) { } else if (isTextElement(element) && transformHandleType) {
resizeSingleTextElement( resizeSingleTextElement(
originalElements, originalElements,
element, element,
elementsMap, scene,
transformHandleType, transformHandleType,
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
pointerY, pointerY,
); );
updateBoundElements(element, elementsMap); updateBoundElements(element, scene);
return true; return true;
} else if (transformHandleType) { } else if (transformHandleType) {
const elementId = selectedElements[0].id; const elementId = selectedElements[0].id;
@@ -129,8 +126,6 @@ export const transformElements = (
getNextSingleWidthAndHeightFromPointer( getNextSingleWidthAndHeightFromPointer(
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElements,
transformHandleType, transformHandleType,
pointerX, pointerX,
pointerY, pointerY,
@@ -145,8 +140,8 @@ export const transformElements = (
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElements, originalElements,
scene,
transformHandleType, transformHandleType,
{ {
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
@@ -161,7 +156,6 @@ export const transformElements = (
rotateMultipleElements( rotateMultipleElements(
originalElements, originalElements,
selectedElements, selectedElements,
elementsMap,
scene, scene,
pointerX, pointerX,
pointerY, pointerY,
@@ -210,13 +204,15 @@ export const transformElements = (
const rotateSingleElement = ( const rotateSingleElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
scene: Scene, scene: Scene,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(
element,
scene.getNonDeletedElementsMap(),
);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle: Radians; let angle: Radians;
@@ -233,13 +229,13 @@ const rotateSingleElement = (
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
mutateElement(element, { angle }); scene.mutateElement(element, { angle });
if (boundTextElementId) { if (boundTextElementId) {
const textElement = const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) { if (textElement && !isArrowElement(element)) {
mutateElement(textElement, { angle }); scene.mutateElement(textElement, { angle });
} }
} }
}; };
@@ -289,12 +285,13 @@ export const measureFontSizeFromWidth = (
const resizeSingleTextElement = ( const resizeSingleTextElement = (
originalElements: PointerDownState["originalElements"], originalElements: PointerDownState["originalElements"],
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap, scene: Scene,
transformHandleType: TransformHandleDirection, transformHandleType: TransformHandleDirection,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
@@ -393,7 +390,7 @@ const resizeSingleTextElement = (
); );
const [nextX, nextY] = newTopLeft; const [nextX, nextY] = newTopLeft;
mutateElement(element, { scene.mutateElement(element, {
fontSize: metrics.size, fontSize: metrics.size,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
@@ -508,14 +505,13 @@ const resizeSingleTextElement = (
autoResize: false, autoResize: false,
}; };
mutateElement(element, resizedElement); scene.mutateElement(element, resizedElement);
} }
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (
originalElements: PointerDownState["originalElements"], originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
elementsMap: SceneElementsMap,
scene: Scene, scene: Scene,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@@ -523,6 +519,7 @@ const rotateMultipleElements = (
centerX: number, centerX: number,
centerY: number, centerY: number,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
let centerAngle = let centerAngle =
(5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX); (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
if (shouldRotateWithDiscreteAngle) { if (shouldRotateWithDiscreteAngle) {
@@ -543,38 +540,30 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
if (isElbowArrow(element)) { const updates = isElbowArrow(element)
// Needed to re-route the arrow ? {
mutateElement(element, { // Needed to re-route the arrow
points: getArrowLocalFixedPoints(element, elementsMap), points: getArrowLocalFixedPoints(element, elementsMap),
}); }
} else { : {
mutateElement(
element,
{
x: element.x + (rotatedCX - cx), x: element.x + (rotatedCX - cx),
y: element.y + (rotatedCY - cy), y: element.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians), angle: normalizeRadians((centerAngle + origAngle) as Radians),
}, };
false,
);
}
updateBoundElements(element, elementsMap, { scene.mutateElement(element, updates);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elements, simultaneouslyUpdated: elements,
}); });
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
mutateElement( scene.mutateElement(boundText, {
boundText, x: boundText.x + (rotatedCX - cx),
{ y: boundText.y + (rotatedCY - cy),
x: boundText.x + (rotatedCX - cx), angle: normalizeRadians((centerAngle + origAngle) as Radians),
y: boundText.y + (rotatedCY - cy), });
angle: normalizeRadians((centerAngle + origAngle) as Radians),
},
false,
);
} }
} }
} }
@@ -819,8 +808,8 @@ export const resizeSingleElement = (
nextHeight: number, nextHeight: number,
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
handleDirection: TransformHandleDirection, handleDirection: TransformHandleDirection,
{ {
shouldInformMutation = true, shouldInformMutation = true,
@@ -833,6 +822,7 @@ export const resizeSingleElement = (
} = {}, } = {},
) => { ) => {
let boundTextFont: { fontSize?: number } = {}; let boundTextFont: { fontSize?: number } = {};
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement) { if (boundTextElement) {
@@ -932,7 +922,7 @@ export const resizeSingleElement = (
} }
if ("scale" in latestElement && "scale" in origElement) { if ("scale" in latestElement && "scale" in origElement) {
mutateElement(latestElement, { scene.mutateElement(latestElement, {
scale: [ scale: [
// defaulting because scaleX/Y can be 0/-0 // defaulting because scaleX/Y can be 0/-0
(Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0], (Math.sign(nextWidth) || origElement.scale[0]) * origElement.scale[0],
@@ -967,21 +957,24 @@ export const resizeSingleElement = (
...rescaledPoints, ...rescaledPoints,
}; };
mutateElement(latestElement, updates, shouldInformMutation); scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation,
isDragging: false,
});
updateBoundElements(latestElement, elementsMap as SceneElementsMap, { updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense // TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight }, newSize: { width: nextWidth, height: nextHeight },
}); });
if (boundTextElement && boundTextFont != null) { if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, { scene.mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize, fontSize: boundTextFont.fontSize,
}); });
} }
handleBindTextResize( handleBindTextResize(
latestElement, latestElement,
elementsMap, scene,
handleDirection, handleDirection,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
); );
@@ -991,8 +984,6 @@ export const resizeSingleElement = (
const getNextSingleWidthAndHeightFromPointer = ( const getNextSingleWidthAndHeightFromPointer = (
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: ElementsMap,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection, handleDirection: TransformHandleDirection,
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@@ -1527,27 +1518,24 @@ export const resizeMultipleElements = (
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { width, height, angle } = update; const { width, height, angle } = update;
mutateElement(element, update, false, { scene.mutateElement(element, update, {
informMutation: true,
// needed for the fixed binding point udpate to take effect // needed for the fixed binding point udpate to take effect
isDragging: true, isDragging: true,
}); });
updateBoundElements(element, elementsMap as SceneElementsMap, { updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height }, newSize: { width, height },
}); });
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) { if (boundTextElement && boundTextFontSize) {
mutateElement( scene.mutateElement(boundTextElement, {
boundTextElement, fontSize: boundTextFontSize,
{ angle: isLinearElement(element) ? undefined : angle,
fontSize: boundTextFontSize, });
angle: isLinearElement(element) ? undefined : angle, handleBindTextResize(element, scene, handleDirection, true);
},
false,
);
handleBindTextResize(element, elementsMap, handleDirection, true);
} }
} }
+4 -3
View File
@@ -1,4 +1,4 @@
import { isShallowEqual } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type { import type {
AppState, AppState,
@@ -264,6 +264,7 @@ export const makeNextSelectedElementIds = (
const _getLinearElementEditor = ( const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[], targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
) => { ) => {
const linears = targetElements.filter(isLinearElement); const linears = targetElements.filter(isLinearElement);
if (linears.length === 1) { if (linears.length === 1) {
@@ -274,7 +275,7 @@ const _getLinearElementEditor = (
); );
if (onlySingleLinearSelected) { if (onlySingleLinearSelected) {
return new LinearElementEditor(linear); return new LinearElementEditor(linear, arrayToMap(allElements));
} }
} }
@@ -287,7 +288,7 @@ export const getSelectionStateForElements = (
appState: AppState, appState: AppState,
) => { ) => {
return { return {
selectedLinearElement: _getLinearElementEditor(targetElements), selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
...selectGroupsForSelectedElements( ...selectGroupsForSelectedElements(
{ {
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
+9
View File
@@ -282,6 +282,15 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
); );
}; };
export const aabbForPoints = <Point extends GlobalPoint | LocalPoint>(
points: Point[],
): Bounds => [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
/** /**
* Get the axis-aligned bounding box for a given element * Get the axis-aligned bounding box for a given element
*/ */
-36
View File
@@ -6,7 +6,6 @@ import {
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
@@ -170,41 +169,6 @@ export const getLockedLinearCursorAlignSize = (
return { width, height }; 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 = ( export const getNormalizedDimensions = (
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">, element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
): { ): {
+19 -11
View File
@@ -14,12 +14,14 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types"; import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { import {
resetOriginalContainerCache, resetOriginalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import { measureText } from "./textMeasurements"; import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
@@ -28,7 +30,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type { Radians } from "../../math/src"; import type Scene from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
import type { import type {
@@ -44,9 +46,10 @@ import type {
export const redrawTextBoundingBox = ( export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null, container: ExcalidrawElement | null,
elementsMap: ElementsMap, scene: Scene,
informMutation = true,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
let maxWidth = undefined; let maxWidth = undefined;
if (!isProdEnv()) { if (!isProdEnv()) {
@@ -106,38 +109,43 @@ export const redrawTextBoundingBox = (
metrics.height, metrics.height,
container.type, container.type,
); );
mutateElement(container, { height: nextHeight }, informMutation); scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight); updateOriginalContainerCache(container.id, nextHeight);
} }
if (metrics.width > maxContainerWidth) { if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText( const nextWidth = computeContainerDimensionForBoundText(
metrics.width, metrics.width,
container.type, container.type,
); );
mutateElement(container, { width: nextWidth }, informMutation); scene.mutateElement(container, { width: nextWidth });
} }
const updatedTextElement = { const updatedTextElement = {
...textElement, ...textElement,
...boundTextUpdates, ...boundTextUpdates,
} as ExcalidrawTextElementWithContainer; } as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition( const { x, y } = computeBoundTextPosition(
container, container,
updatedTextElement, updatedTextElement,
elementsMap, elementsMap,
); );
boundTextUpdates.x = x; boundTextUpdates.x = x;
boundTextUpdates.y = y; boundTextUpdates.y = y;
} }
mutateElement(textElement, boundTextUpdates, informMutation); scene.mutateElement(textElement, boundTextUpdates);
}; };
export const handleBindTextResize = ( export const handleBindTextResize = (
container: NonDeletedExcalidrawElement, container: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, scene: Scene,
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false, shouldMaintainAspectRatio = false,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) { if (!boundTextElementId) {
return; return;
@@ -190,20 +198,20 @@ export const handleBindTextResize = (
transformHandleType === "n") transformHandleType === "n")
? container.y - diff ? container.y - diff
: container.y; : container.y;
mutateElement(container, { scene.mutateElement(container, {
height: containerHeight, height: containerHeight,
y: updatedY, y: updatedY,
}); });
} }
mutateElement(textElement, { scene.mutateElement(textElement, {
text, text,
width: nextWidth, width: nextWidth,
height: nextHeight, height: nextHeight,
}); });
if (!isArrowElement(container)) { if (!isArrowElement(container)) {
mutateElement( scene.mutateElement(
textElement, textElement,
computeBoundTextPosition(container, textElement, elementsMap), computeBoundTextPosition(container, textElement, elementsMap),
); );
+18
View File
@@ -119,6 +119,20 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed; 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 = ( export const isLinearElementType = (
elementType: ElementOrToolType, elementType: ElementOrToolType,
): boolean => { ): boolean => {
@@ -271,6 +285,10 @@ export const isBoundToContainer = (
); );
}; };
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) => export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "rectangle" ||
type === "embeddable" || type === "embeddable" ||
+8
View File
@@ -412,3 +412,11 @@ export type NonDeletedSceneElementsMap = Map<
export type ElementsMapOrArray = export type ElementsMapOrArray =
| readonly ExcalidrawElement[] | readonly ExcalidrawElement[]
| Readonly<ElementsMap>; | Readonly<ElementsMap>;
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;
+2 -2
View File
@@ -2,8 +2,6 @@ import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { isFrameLikeElement } from "./typeChecks"; import { isFrameLikeElement } from "./typeChecks";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
@@ -12,6 +10,8 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import type Scene from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
+2 -3
View File
@@ -7,7 +7,7 @@ import {
isPrimitive, isPrimitive,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
@@ -24,7 +24,6 @@ import {
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { mutateElement } from "../src/mutateElement";
import { duplicateElement, duplicateElements } from "../src/duplicate"; import { duplicateElement, duplicateElements } from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types"; import type { ExcalidrawLinearElement } from "../src/types";
@@ -62,7 +61,7 @@ describe("duplicating single elements", () => {
// @ts-ignore // @ts-ignore
element.__proto__ = { hello: "world" }; element.__proto__ = { hello: "world" };
mutateElement(element, { mutateElement(element, new Map(), {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)], points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
}); });
+12 -11
View File
@@ -1,8 +1,7 @@
import { ARROW_TYPE } from "@excalidraw/common"; import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import Scene from "@excalidraw/excalidraw/scene/Scene";
import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
@@ -23,6 +22,8 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding"; import { bindLinearElement } from "../src/binding";
import Scene from "../src/Scene";
import type { import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
@@ -142,7 +143,7 @@ describe("elbow arrow routing", () => {
elbowed: true, elbowed: true,
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
scene.insertElement(arrow); scene.insertElement(arrow);
mutateElement(arrow, { h.app.scene.mutateElement(arrow, {
points: [ points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y), pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y), pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
@@ -187,14 +188,14 @@ describe("elbow arrow routing", () => {
scene.insertElement(rectangle1); scene.insertElement(rectangle1);
scene.insertElement(rectangle2); scene.insertElement(rectangle2);
scene.insertElement(arrow); scene.insertElement(arrow);
const elementsMap = scene.getNonDeletedElementsMap();
bindLinearElement(arrow, rectangle1, "start", elementsMap); bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", elementsMap); bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
mutateElement(arrow, { scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });
@@ -294,11 +295,11 @@ describe("elbow arrow ui", () => {
) as HTMLInputElement; ) as HTMLInputElement;
UI.updateInput(inputAngle, String("40")); UI.updateInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points).toCloselyEqualPoints([
[0, 0], [0, 0],
[35, 0], [34.7791, 0],
[35, 165], [34.7791, 164.67],
[103, 165], [102.931, 164.67],
]); ]);
}); });
@@ -14,6 +14,7 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { import type {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@@ -749,7 +750,7 @@ function testInvalidIndicesSync(args: {
function prepareArguments( function prepareArguments(
elementsLike: { id: string; index?: string }[], elementsLike: { id: string; index?: string }[],
movedElementsIds?: string[], movedElementsIds?: string[],
): [ExcalidrawElement[], Map<string, ExcalidrawElement> | undefined] { ): [ExcalidrawElement[], ElementsMap | undefined] {
const elements = elementsLike.map((x) => const elements = elementsLike.map((x) =>
API.createElement({ id: x.id, index: x.index as FractionalIndex }), API.createElement({ id: x.id, index: x.index as FractionalIndex }),
); );
@@ -764,7 +765,7 @@ function prepareArguments(
function test( function test(
name: string, name: string,
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement> | undefined, movedElements: ElementsMap | undefined,
expectUnchangedElements: Map<string, { id: string }>, expectUnchangedElements: Map<string, { id: string }>,
expectValidInput?: boolean, expectValidInput?: boolean,
) { ) {
+7 -7
View File
@@ -333,7 +333,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(), h.app.scene,
"ne", "ne",
); );
@@ -369,7 +369,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(), h.app.scene,
"se", "se",
); );
@@ -424,7 +424,7 @@ describe("line element", () => {
element, element,
element, element,
h.app.scene.getNonDeletedElementsMap(), h.app.scene.getNonDeletedElementsMap(),
h.app.scene.getNonDeletedElementsMap(), h.app.scene,
"e", "e",
{ {
shouldResizeFromCenter: true, shouldResizeFromCenter: true,
@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
+6 -4
View File
@@ -1,10 +1,12 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "../src/mutateElement"; import { mutateElement } from "@excalidraw/element/mutateElement";
import { normalizeElementOrder } from "../src/sortElements"; import { normalizeElementOrder } from "../src/sortElements";
import type { ExcalidrawElement } from "../src/types"; import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const assertOrder = ( const assertOrder = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
expectedOrder: string[], expectedOrder: string[],
@@ -35,7 +37,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [], boundElements: [],
}); });
mutateElement(container, { mutateElement(container, new Map(), {
boundElements: [{ type: "text", id: boundText.id }], boundElements: [{ type: "text", id: boundText.id }],
}); });
@@ -352,7 +354,7 @@ describe("normalizeElementsOrder", () => {
containerId: container.id, containerId: container.id,
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [ boundElements: [
{ type: "text", id: boundText.id }, { type: "text", id: boundText.id },
{ type: "text", id: "xxx" }, { type: "text", id: "xxx" },
@@ -387,7 +389,7 @@ describe("normalizeElementsOrder", () => {
boundElements: [], boundElements: [],
groupIds: ["C", "A"], groupIds: ["C", "A"],
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }], boundElements: [{ type: "text", id: boundText.id }],
}); });
+1 -7
View File
@@ -50,14 +50,8 @@ const alignSelectedElements = (
alignment: Alignment, alignment: Alignment,
) => { ) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const elementsMap = arrayToMap(elements);
const updatedElements = alignElements( const updatedElements = alignElements(selectedElements, alignment, app.scene);
selectedElements,
elementsMap,
alignment,
app.scene,
);
const updatedElementsMap = arrayToMap(updatedElements); const updatedElementsMap = arrayToMap(updatedElements);
+20 -29
View File
@@ -27,7 +27,6 @@ import {
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { measureText } from "@excalidraw/element/textMeasurements"; import { measureText } from "@excalidraw/element/textMeasurements";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
@@ -43,12 +42,12 @@ import type {
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import { CaptureUpdateAction } from "../store"; import { CaptureUpdateAction } from "../store";
import { register } from "./register"; import { register } from "./register";
import type { Radians } from "../../math/src";
import type { AppState } from "../types"; import type { AppState } from "../types";
export const actionUnbindText = register({ export const actionUnbindText = register({
@@ -80,7 +79,7 @@ export const actionUnbindText = register({
boundTextElement, boundTextElement,
elementsMap, elementsMap,
); );
mutateElement(boundTextElement as ExcalidrawTextElement, { app.scene.mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null, containerId: null,
width, width,
height, height,
@@ -88,7 +87,7 @@ export const actionUnbindText = register({
x, x,
y, y,
}); });
mutateElement(element, { app.scene.mutateElement(element, {
boundElements: element.boundElements?.filter( boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id, (ele) => ele.id !== boundTextElement.id,
), ),
@@ -153,25 +152,21 @@ export const actionBindText = register({
textElement = selectedElements[1] as ExcalidrawTextElement; textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer; container = selectedElements[0] as ExcalidrawTextContainer;
} }
mutateElement(textElement, { app.scene.mutateElement(textElement, {
containerId: container.id, containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE, verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER, textAlign: TEXT_ALIGN.CENTER,
autoResize: true, autoResize: true,
angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians, angle: (isArrowElement(container) ? 0 : container?.angle ?? 0) as Radians,
}); });
mutateElement(container, { app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
type: "text", type: "text",
id: textElement.id, id: textElement.id,
}), }),
}); });
const originalContainerHeight = container.height; const originalContainerHeight = container.height;
redrawTextBoundingBox( redrawTextBoundingBox(textElement, container, app.scene);
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
// overwritting the cache with original container height so // overwritting the cache with original container height so
// it can be restored when unbind // it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight); updateOriginalContainerCache(container.id, originalContainerHeight);
@@ -301,27 +296,23 @@ export const actionWrapTextInContainer = register({
} }
if (startBinding || endBinding) { if (startBinding || endBinding) {
mutateElement(ele, { startBinding, endBinding }, false); app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
} }
}); });
} }
mutateElement( app.scene.mutateElement(textElement, {
textElement, containerId: container.id,
{ verticalAlign: VERTICAL_ALIGN.MIDDLE,
containerId: container.id, boundElements: null,
verticalAlign: VERTICAL_ALIGN.MIDDLE, textAlign: TEXT_ALIGN.CENTER,
boundElements: null, autoResize: true,
textAlign: TEXT_ALIGN.CENTER, });
autoResize: true,
}, redrawTextBoundingBox(textElement, container, app.scene);
false,
);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
updatedElements = pushContainerBelowText( updatedElements = pushContainerBelowText(
[...updatedElements, container], [...updatedElements, container],
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Excalidraw, mutateElement } from "../index"; import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
import { act, assertElements, render } from "../tests/test-utils"; 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, frameId: f1.id,
}); });
mutateElement(r1, { h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }], boundElements: [{ type: "text", id: t1.id }],
}); });
@@ -94,7 +94,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null, frameId: null,
}); });
mutateElement(r1, { h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }], boundElements: [{ type: "text", id: t1.id }],
}); });
@@ -132,7 +132,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null, frameId: null,
}); });
mutateElement(r1, { h.app.scene.mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }], boundElements: [{ type: "text", id: t1.id }],
}); });
@@ -170,7 +170,7 @@ describe("deleting selected elements when frame selected should keep children +
frameId: null, frameId: null,
}); });
mutateElement(a1, { h.app.scene.mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }], boundElements: [{ type: "text", id: t1.id }],
}); });
@@ -3,10 +3,7 @@ import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element/binding"; import { fixBindingsAfterDeletion } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement"; import { getContainerElement } from "@excalidraw/element/textElement";
import { import {
isBoundToContainer, isBoundToContainer,
@@ -94,7 +91,7 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => { el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id); const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) { if (bound && isElbowArrow(bound)) {
mutateElement(bound, { app.scene.mutateElement(bound, {
startBinding: startBinding:
el.id === bound.startBinding?.elementId el.id === bound.startBinding?.elementId
? null ? null
@@ -102,7 +99,6 @@ const deleteSelectedElements = (
endBinding: endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding, el.id === bound.endBinding?.elementId ? null : bound.endBinding,
}); });
mutateElement(bound, { points: bound.points });
} }
}); });
} }
@@ -261,7 +257,11 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints(element, selectedPointsIndices); LinearElementEditor.deletePoints(
element,
app.scene,
selectedPointsIndices,
);
return { return {
elements, elements,
@@ -43,7 +43,7 @@ export const actionDuplicateSelection = register({
try { try {
const newAppState = LinearElementEditor.duplicateSelectedPoints( const newAppState = LinearElementEditor.duplicateSelectedPoints(
appState, appState,
app.scene.getNonDeletedElementsMap(), app.scene,
); );
return { return {
+13 -13
View File
@@ -5,7 +5,7 @@ import {
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
isBindingElement, isBindingElement,
isLinearElement, isLinearElement,
@@ -46,7 +46,6 @@ export const actionFinalize = register({
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
scene, scene,
); );
} }
@@ -72,7 +71,11 @@ export const actionFinalize = register({
scene.getElement(appState.pendingImageElementId); scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) { if (pendingImageElement) {
mutateElement(pendingImageElement, { isDeleted: true }, false); scene.mutateElement(
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
} }
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
@@ -96,7 +99,7 @@ export const actionFinalize = register({
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
mutateElement(multiPointElement, { scene.mutateElement(multiPointElement, {
points: multiPointElement.points.slice(0, -1), points: multiPointElement.points.slice(0, -1),
}); });
} }
@@ -120,7 +123,7 @@ export const actionFinalize = register({
if (isLoop) { if (isLoop) {
const linePoints = multiPointElement.points; const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
mutateElement(multiPointElement, { scene.mutateElement(multiPointElement, {
points: linePoints.map((p, index) => points: linePoints.map((p, index) =>
index === linePoints.length - 1 index === linePoints.length - 1
? pointFrom(firstPoint[0], firstPoint[1]) ? pointFrom(firstPoint[0], firstPoint[1])
@@ -140,13 +143,7 @@ export const actionFinalize = register({
-1, -1,
arrayToMap(elements), arrayToMap(elements),
); );
maybeBindLinearElement( maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
multiPointElement,
appState,
{ x, y },
elementsMap,
elements,
);
} }
} }
@@ -202,7 +199,10 @@ export const actionFinalize = register({
// To select the linear element when user has finished mutipoint editing // To select the linear element when user has finished mutipoint editing
selectedLinearElement: selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement) multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement) ? new LinearElementEditor(
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },
@@ -73,12 +73,12 @@ describe("flipping re-centers selection", () => {
API.executeAction(actionFlipHorizontal); API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1")!; const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0); expect(rec1.x).toBeCloseTo(97.8678, 0);
expect(rec1.y).toBeCloseTo(100, 0); expect(rec1.y).toBeCloseTo(97.444, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!; const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0); expect(rec2.x).toBeCloseTo(218, 0);
expect(rec2.y).toBeCloseTo(250, 0); expect(rec2.y).toBeCloseTo(247, 0);
}); });
}); });
+4 -9
View File
@@ -4,10 +4,7 @@ import {
isBindingEnabled, isBindingEnabled,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { getCommonBoundingBox } from "@excalidraw/element/bounds"; import { getCommonBoundingBox } from "@excalidraw/element/bounds";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { deepCopyElement } from "@excalidraw/element/duplicate"; import { deepCopyElement } from "@excalidraw/element/duplicate";
import { resizeMultipleElements } from "@excalidraw/element/resizeElements"; import { resizeMultipleElements } from "@excalidraw/element/resizeElements";
import { import {
@@ -162,11 +159,9 @@ const flipElements = (
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement), selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState), isBindingEnabled(appState),
[], [],
app.scene,
appState.zoom, appState.zoom,
); );
@@ -194,13 +189,13 @@ const flipElements = (
getCommonBoundingBox(selectedElements); getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY]; const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) => otherElements.forEach((element) =>
mutateElement(element, { app.scene.mutateElement(element, {
x: element.x + diffX, x: element.x + diffX,
y: element.y + diffY, y: element.y + diffY,
}), }),
); );
elbowArrows.forEach((element) => elbowArrows.forEach((element) =>
mutateElement(element, { app.scene.mutateElement(element, {
x: element.x + diffX, x: element.x + diffX,
y: element.y + diffY, y: element.y + diffY,
}), }),
+5 -11
View File
@@ -173,11 +173,9 @@ export const actionWrapSelectionInFrame = register({
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const elementsMap = app.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getCommonBounds( const [x1, y1, x2, y2] = getCommonBounds(selectedElements, elementsMap);
selectedElements,
app.scene.getNonDeletedElementsMap(),
);
const PADDING = 16; const PADDING = 16;
const frame = newFrameElement({ const frame = newFrameElement({
x: x1 - PADDING, x: x1 - PADDING,
@@ -196,13 +194,9 @@ export const actionWrapSelectionInFrame = register({
for (const elementInGroup of elementsInGroup) { for (const elementInGroup of elementsInGroup) {
const index = elementInGroup.groupIds.indexOf(appState.editingGroupId); const index = elementInGroup.groupIds.indexOf(appState.editingGroupId);
mutateElement( mutateElement(elementInGroup, elementsMap, {
elementInGroup, groupIds: elementInGroup.groupIds.slice(0, index),
{ });
groupIds: elementInGroup.groupIds.slice(0, index),
},
false,
);
} }
} }
@@ -2,6 +2,8 @@ import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks"; import { isElbowArrow, isLinearElement } from "@excalidraw/element/typeChecks";
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
@@ -50,7 +52,7 @@ export const actionToggleLinearEditor = register({
const editingLinearElement = const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id appState.editingLinearElement?.elementId === selectedElement.id
? null ? null
: new LinearElementEditor(selectedElement); : new LinearElementEditor(selectedElement, arrayToMap(elements));
return { return {
appState: { appState: {
...appState, ...appState,
@@ -34,10 +34,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
@@ -61,6 +58,7 @@ import type { LocalPoint } from "@excalidraw/math";
import type { import type {
Arrowhead, Arrowhead,
ElementsMap,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -68,9 +66,10 @@ import type {
FontFamilyValues, FontFamilyValues,
TextAlign, TextAlign,
VerticalAlign, VerticalAlign,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
@@ -207,25 +206,22 @@ export const getFormValue = function <T extends Primitive>(
const offsetElementAfterFontResize = ( const offsetElementAfterFontResize = (
prevElement: ExcalidrawTextElement, prevElement: ExcalidrawTextElement,
nextElement: ExcalidrawTextElement, nextElement: ExcalidrawTextElement,
scene: Scene,
) => { ) => {
if (isBoundToContainer(nextElement) || !nextElement.autoResize) { if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
return nextElement; return nextElement;
} }
return mutateElement( return scene.mutateElement(nextElement, {
nextElement, x:
{ prevElement.textAlign === "left"
x: ? prevElement.x
prevElement.textAlign === "left" : prevElement.x +
? prevElement.x (prevElement.width - nextElement.width) /
: prevElement.x + (prevElement.textAlign === "center" ? 2 : 1),
(prevElement.width - nextElement.width) / // centering vertically is non-standard, but for Excalidraw I think
(prevElement.textAlign === "center" ? 2 : 1), // it makes sense
// centering vertically is non-standard, but for Excalidraw I think y: prevElement.y + (prevElement.height - nextElement.height) / 2,
// it makes sense });
y: prevElement.y + (prevElement.height - nextElement.height) / 2,
},
false,
);
}; };
const changeFontSize = ( const changeFontSize = (
@@ -251,10 +247,14 @@ const changeFontSize = (
redrawTextBoundingBox( redrawTextBoundingBox(
newElement, newElement,
app.scene.getContainerElement(oldElement), app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(), app.scene,
); );
newElement = offsetElementAfterFontResize(oldElement, newElement); newElement = offsetElementAfterFontResize(
oldElement,
newElement,
app.scene,
);
return newElement; return newElement;
} }
@@ -264,15 +264,11 @@ const changeFontSize = (
); );
// Update arrow elements after text elements have been updated // Update arrow elements after text elements have been updated
const updatedElementsMap = arrayToMap(updatedElements);
getSelectedElements(elements, appState, { getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
}).forEach((element) => { }).forEach((element) => {
if (isTextElement(element)) { if (isTextElement(element)) {
updateBoundElements( updateBoundElements(element, app.scene);
element,
updatedElementsMap as NonDeletedSceneElementsMap,
);
} }
}); });
@@ -778,7 +774,7 @@ type ChangeFontFamilyData = Partial<
> >
> & { > & {
/** cache of selected & editing elements populated on opened popup */ /** cache of selected & editing elements populated on opened popup */
cachedElements?: Map<string, ExcalidrawElement>; cachedElements?: ElementsMap;
/** flag to reset all elements to their cached versions */ /** flag to reset all elements to their cached versions */
resetAll?: true; resetAll?: true;
/** flag to reset all containers to their cached versions */ /** flag to reset all containers to their cached versions */
@@ -919,7 +915,7 @@ export const actionChangeFontFamily = register({
if (resetContainers && container && cachedContainer) { if (resetContainers && container && cachedContainer) {
// reset the container back to it's cached version // reset the container back to it's cached version
mutateElement(container, { ...cachedContainer }, false); app.scene.mutateElement(container, { ...cachedContainer });
} }
if (!skipFontFaceCheck) { if (!skipFontFaceCheck) {
@@ -950,12 +946,7 @@ 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 // 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) { for (const [element, container] of elementContainerMapping) {
// trigger synchronous redraw // trigger synchronous redraw
redrawTextBoundingBox( redrawTextBoundingBox(element, container, app.scene);
element,
container,
app.scene.getNonDeletedElementsMap(),
false,
);
} }
} else { } else {
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded // otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
@@ -972,8 +963,7 @@ export const actionChangeFontFamily = register({
redrawTextBoundingBox( redrawTextBoundingBox(
latestElement as ExcalidrawTextElement, latestElement as ExcalidrawTextElement,
latestContainer, latestContainer,
app.scene.getNonDeletedElementsMap(), app.scene,
false,
); );
} }
} }
@@ -987,7 +977,7 @@ export const actionChangeFontFamily = register({
return result; return result;
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map()); const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null); 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 // 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>({}); const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
@@ -996,7 +986,7 @@ export const actionChangeFontFamily = register({
const selectedFontFamily = useMemo(() => { const selectedFontFamily = useMemo(() => {
const getFontFamily = ( const getFontFamily = (
elementsArray: readonly ExcalidrawElement[], elementsArray: readonly ExcalidrawElement[],
elementsMap: Map<string, ExcalidrawElement>, elementsMap: ElementsMap,
) => ) =>
getFormValue( getFormValue(
elementsArray, elementsArray,
@@ -1179,7 +1169,7 @@ export const actionChangeTextAlign = register({
redrawTextBoundingBox( redrawTextBoundingBox(
newElement, newElement,
app.scene.getContainerElement(oldElement), app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(), app.scene,
); );
return newElement; return newElement;
} }
@@ -1270,7 +1260,7 @@ export const actionChangeVerticalAlign = register({
redrawTextBoundingBox( redrawTextBoundingBox(
newElement, newElement,
app.scene.getContainerElement(oldElement), app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(), app.scene,
); );
return newElement; return newElement;
} }
@@ -1670,10 +1660,10 @@ export const actionChangeArrowType = register({
newElement, newElement,
startHoveredElement, startHoveredElement,
"start", "start",
elementsMap, app.scene,
); );
endHoveredElement && endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", elementsMap); bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startBinding = const startBinding =
startElement && newElement.startBinding startElement && newElement.startBinding
@@ -1684,7 +1674,6 @@ export const actionChangeArrowType = register({
newElement, newElement,
startElement, startElement,
"start", "start",
elementsMap,
), ),
} }
: null; : null;
@@ -1697,7 +1686,6 @@ export const actionChangeArrowType = register({
newElement, newElement,
endElement, endElement,
"end", "end",
elementsMap,
), ),
} }
: null; : null;
@@ -1729,7 +1717,7 @@ export const actionChangeArrowType = register({
newElement.startBinding.elementId, newElement.startBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (startElement) { if (startElement) {
bindLinearElement(newElement, startElement, "start", elementsMap); bindLinearElement(newElement, startElement, "start", app.scene);
} }
} }
if (newElement.endBinding) { if (newElement.endBinding) {
@@ -1737,7 +1725,7 @@ export const actionChangeArrowType = register({
newElement.endBinding.elementId, newElement.endBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (endElement) { if (endElement) {
bindLinearElement(newElement, endElement, "end", elementsMap); bindLinearElement(newElement, endElement, "end", app.scene);
} }
} }
} }
@@ -1758,6 +1746,7 @@ export const actionChangeArrowType = register({
if (selected) { if (selected) {
newState.selectedLinearElement = new LinearElementEditor( newState.selectedLinearElement = new LinearElementEditor(
selected as ExcalidrawLinearElement, selected as ExcalidrawLinearElement,
arrayToMap(elements),
); );
} }
} }
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks"; import { isLinearElement, isTextElement } from "@excalidraw/element/typeChecks";
import { KEYS } from "@excalidraw/common"; import { arrayToMap, KEYS } from "@excalidraw/common";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups"; import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
@@ -53,7 +53,7 @@ export const actionSelectAll = register({
// single linear element selected // single linear element selected
Object.keys(selectedElementIds).length === 1 && Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0]) isLinearElement(elements[0])
? new LinearElementEditor(elements[0]) ? new LinearElementEditor(elements[0], arrayToMap(elements))
: null, : null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+2 -5
View File
@@ -139,11 +139,8 @@ export const actionPasteStyles = register({
element.id === newElement.containerId, element.id === newElement.containerId,
) || null; ) || null;
} }
redrawTextBoundingBox(
newElement, redrawTextBoundingBox(newElement, container, app.scene);
container,
app.scene.getNonDeletedElementsMap(),
);
} }
if ( if (
@@ -0,0 +1,34 @@
import type { ExcalidrawElement } from "@excalidraw/element/types";
import {
getConversionTypeFromElements,
convertElementTypePopupAtom,
} from "../components/ConvertElementTypePopup";
import { editorJotaiStore } from "../editor-jotai";
import { CaptureUpdateAction } from "../store";
import { register } from "./register";
export const actionToggleShapeSwitch = register({
name: "toggleShapeSwitch",
label: "labels.shapeSwitch",
icon: () => null,
viewMode: true,
trackEvent: {
category: "shape_switch",
action: "toggle",
},
keywords: ["change", "switch", "swap"],
perform(elements, appState, _, app) {
editorJotaiStore.set(convertElementTypePopupAtom, {
type: "panel",
});
return {
captureUpdate: CaptureUpdateAction.NEVER,
};
},
checked: (appState) => appState.gridModeEnabled,
predicate: (elements, appState, props) =>
getConversionTypeFromElements(elements as ExcalidrawElement[]) !== null,
});
+4 -2
View File
@@ -140,7 +140,8 @@ export type ActionName =
| "linkToElement" | "linkToElement"
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "toggleLassoTool"; | "toggleLassoTool"
| "toggleShapeSwitch";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -195,7 +196,8 @@ export interface Action {
| "menu" | "menu"
| "collab" | "collab"
| "hyperlink" | "hyperlink"
| "search_menu"; | "search_menu"
| "shape_switch";
action?: string; action?: string;
predicate?: ( predicate?: (
appState: Readonly<AppState>, appState: Readonly<AppState>,
-1
View File
@@ -129,7 +129,6 @@ export class AnimatedTrail implements Trail {
} }
private update() { private update() {
this.pastTrails = [];
this.start(); this.start();
if (this.trailAnimation) { if (this.trailAnimation) {
this.trailAnimation.setAttribute("begin", "indefinite"); this.trailAnimation.setAttribute("begin", "indefinite");
+18 -9
View File
@@ -37,6 +37,8 @@ import {
syncMovedIndices, syncMovedIndices,
} from "@excalidraw/element/fractionalIndex"; } from "@excalidraw/element/fractionalIndex";
import Scene from "@excalidraw/element/Scene";
import type { BindableProp, BindingProp } from "@excalidraw/element/binding"; import type { BindableProp, BindingProp } from "@excalidraw/element/binding";
import type { ElementUpdate } from "@excalidraw/element/mutateElement"; import type { ElementUpdate } from "@excalidraw/element/mutateElement";
@@ -490,6 +492,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get( nextElements.get(
selectedLinearElementId, selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
) )
: null; : null;
@@ -499,6 +502,7 @@ export class AppStateChange implements Change<AppState> {
nextElements.get( nextElements.get(
editingLinearElementId, editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
) )
: null; : null;
@@ -1132,9 +1136,6 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
try { try {
// TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state
ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements);
// the following reorder performs also mutations, but only on new instances of changed elements // the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices) // (unless something goes really bad and it fallbacks to fixing all invalid indices)
nextElements = ElementsChange.reorderElements( nextElements = ElementsChange.reorderElements(
@@ -1143,8 +1144,14 @@ export class ElementsChange implements Change<SceneElementsMap> {
flags, flags,
); );
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsChange.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues // Need ordered nextElements to avoid z-index binding issues
ElementsChange.redrawBoundArrows(nextElements, changedElements); ElementsChange.redrawBoundArrows(tempScene, changedElements);
} catch (e) { } catch (e) {
console.error( console.error(
`Couldn't mutate elements after applying elements change`, `Couldn't mutate elements after applying elements change`,
@@ -1337,8 +1344,9 @@ export class ElementsChange implements Change<SceneElementsMap> {
} else { } else {
affectedElement = mutateElement( affectedElement = mutateElement(
nextElement, nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>, updates as ElementUpdate<OrderedExcalidrawElement>,
); ) as OrderedExcalidrawElement;
} }
nextAffectedElements.set(affectedElement.id, affectedElement); nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1456,9 +1464,10 @@ export class ElementsChange implements Change<SceneElementsMap> {
} }
private static redrawTextBoundingBoxes( private static redrawTextBoundingBoxes(
elements: SceneElementsMap, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
) { ) {
const elements = scene.getNonDeletedElementsMap();
const boxesToRedraw = new Map< const boxesToRedraw = new Map<
string, string,
{ container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement }
@@ -1498,17 +1507,17 @@ export class ElementsChange implements Change<SceneElementsMap> {
continue; continue;
} }
redrawTextBoundingBox(boundText, container, elements, false); redrawTextBoundingBox(boundText, container, scene);
} }
} }
private static redrawBoundArrows( private static redrawBoundArrows(
elements: SceneElementsMap, scene: Scene,
changed: Map<string, OrderedExcalidrawElement>, changed: Map<string, OrderedExcalidrawElement>,
) { ) {
for (const element of changed.values()) { for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) { if (!element.isDeleted && isBindableElement(element)) {
updateBoundElements(element, elements, { updateBoundElements(element, scene, {
changedElements: changed, changedElements: changed,
}); });
} }
+1 -1
View File
@@ -172,7 +172,7 @@ export const serializeAsClipboardJSON = ({
!framesToCopy.has(getContainingFrame(element, elementsMap)!) !framesToCopy.has(getContainingFrame(element, elementsMap)!)
) { ) {
const copiedElement = deepCopyElement(element); const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, { mutateElement(copiedElement, elementsMap, {
frameId: null, frameId: null,
}); });
return copiedElement; return copiedElement;
+223 -111
View File
@@ -100,6 +100,7 @@ import {
arrayToMap, arrayToMap,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
randomInteger, randomInteger,
CLASSES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -122,10 +123,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { import { newElementWith } from "@excalidraw/element/mutateElement";
mutateElement,
newElementWith,
} from "@excalidraw/element/mutateElement";
import { import {
newFrameElement, newFrameElement,
@@ -303,6 +301,10 @@ import {
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import Scene from "@excalidraw/element/Scene";
import type { ElementUpdate } from "@excalidraw/element/mutateElement";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { import type {
@@ -328,9 +330,10 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { import {
actionAddToLibrary, actionAddToLibrary,
@@ -403,7 +406,6 @@ import {
hasBackground, hasBackground,
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import Scene from "../scene/Scene";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { import {
dataURLToFile, dataURLToFile,
@@ -430,7 +432,7 @@ import {
} from "../components/hyperlink/Hyperlink"; } from "../components/hyperlink/Hyperlink";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { editorJotaiStore } from "../editor-jotai"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai";
import { ImageSceneDataError } from "../errors"; import { ImageSceneDataError } from "../errors";
import { import {
getSnapLinesAtPointer, getSnapLinesAtPointer,
@@ -466,6 +468,12 @@ import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser"; import { EraserTrail } from "../eraser";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
convertElementTypePopupAtom,
convertElementTypes,
} from "./ConvertElementTypePopup";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import BraveMeasureTextError from "./BraveMeasureTextError"; import BraveMeasureTextError from "./BraveMeasureTextError";
import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
@@ -497,7 +505,6 @@ import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu"; import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem"; import type { FileSystemHandle } from "../data/filesystem";
import type { ExcalidrawElementSkeleton } from "../data/transform"; import type { ExcalidrawElementSkeleton } from "../data/transform";
import type { import type {
AppClassProperties, AppClassProperties,
AppProps, AppProps,
@@ -760,6 +767,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) { if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = { const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene, updateScene: this.updateScene,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary, updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles, addFiles: this.addFiles,
resetScene: this.resetScene, resetScene: this.resetScene,
@@ -813,6 +821,15 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
updateEditorAtom = <Value, Args extends unknown[], Result>(
atom: WritableAtom<Value, Args, Result>,
...args: Args
): Result => {
const result = editorJotaiStore.set(atom, ...args);
this.triggerRender();
return result;
};
private onWindowMessage(event: MessageEvent) { private onWindowMessage(event: MessageEvent) {
if ( if (
event.origin !== "https://player.vimeo.com" && event.origin !== "https://player.vimeo.com" &&
@@ -1387,7 +1404,7 @@ class App extends React.Component<AppProps, AppState> {
private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => { private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
if (frame) { if (frame) {
mutateElement(frame, { name: frame.name?.trim() || null }); this.scene.mutateElement(frame, { name: frame.name?.trim() || null });
} }
this.setState({ editingFrame: null }); this.setState({ editingFrame: null });
}; };
@@ -1444,7 +1461,7 @@ class App extends React.Component<AppProps, AppState> {
autoFocus autoFocus
value={frameNameInEdit} value={frameNameInEdit}
onChange={(e) => { onChange={(e) => {
mutateElement(f, { this.scene.mutateElement(f, {
name: e.target.value, name: e.target.value,
}); });
}} }}
@@ -1581,6 +1598,9 @@ class App extends React.Component<AppProps, AppState> {
const firstSelectedElement = selectedElements[0]; const firstSelectedElement = selectedElements[0];
const showShapeSwitchPanel =
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel";
return ( return (
<div <div
className={clsx("excalidraw excalidraw-container", { className={clsx("excalidraw excalidraw-container", {
@@ -1670,7 +1690,7 @@ class App extends React.Component<AppProps, AppState> {
<Hyperlink <Hyperlink
key={firstSelectedElement.id} key={firstSelectedElement.id}
element={firstSelectedElement} element={firstSelectedElement}
elementsMap={allElementsMap} scene={this.scene}
setAppState={this.setAppState} setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen} onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast} setToast={this.setToast}
@@ -1855,6 +1875,9 @@ class App extends React.Component<AppProps, AppState> {
/> />
)} )}
{this.renderFrameNames()} {this.renderFrameNames()}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider> </ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()} {this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider> </ExcalidrawElementsContext.Provider>
@@ -1938,16 +1961,20 @@ class App extends React.Component<AppProps, AppState> {
// state only. // state only.
// Thus reset so that we prefer local cache (if there was some // Thus reset so that we prefer local cache (if there was some
// generationData set previously) // generationData set previously)
mutateElement( this.scene.mutateElement(
frameElement, frameElement,
{ customData: { generationData: undefined } }, {
false, customData: { generationData: undefined },
},
{ informMutation: false, isDragging: false },
); );
} else { } else {
mutateElement( this.scene.mutateElement(
frameElement, frameElement,
{ customData: { generationData: data } }, {
false, customData: { generationData: data },
},
{ informMutation: false, isDragging: false },
); );
} }
this.magicGenerations.set(frameElement.id, data); this.magicGenerations.set(frameElement.id, data);
@@ -2119,7 +2146,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.insertElement(frame); this.scene.insertElement(frame);
for (const child of selectedElements) { for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id }); this.scene.mutateElement(child, { frameId: frame.id });
} }
this.setState({ this.setState({
@@ -2132,7 +2159,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
editorJotaiStore.set(activeEyeDropperAtom, { this.updateEditorAtom(activeEyeDropperAtom, {
swapPreviewOnAlt: true, swapPreviewOnAlt: true,
colorPickerType: colorPickerType:
type === "stroke" ? "elementStroke" : "elementBackground", type === "stroke" ? "elementStroke" : "elementBackground",
@@ -2918,8 +2945,7 @@ class App extends React.Component<AppProps, AppState> {
nonDeletedElementsMap, nonDeletedElementsMap,
), ),
), ),
this.scene.getNonDeletedElementsMap(), this.scene,
this.scene.getNonDeletedElements(),
); );
} }
@@ -3317,11 +3343,7 @@ class App extends React.Component<AppProps, AppState> {
newElement, newElement,
this.scene.getElementsMapIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
); );
redrawTextBoundingBox( redrawTextBoundingBox(newElement, container, this.scene);
newElement,
container,
this.scene.getElementsMapIncludingDeleted(),
);
} }
}); });
@@ -3444,7 +3466,11 @@ class App extends React.Component<AppProps, AppState> {
} }
// hack to reset the `y` coord because we vertically center during // hack to reset the `y` coord because we vertically center during
// insertImageElement // insertImageElement
mutateElement(initializedImageElement, { y }, false); this.scene.mutateElement(
initializedImageElement,
{ y },
{ informMutation: false, isDragging: false },
);
y = imageElement.y + imageElement.height + 25; y = imageElement.y + imageElement.height + 25;
@@ -3998,6 +4024,17 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
informMutation = true,
) => {
return this.scene.mutateElement(element, updates, {
informMutation,
isDragging: false,
});
};
private triggerRender = ( private triggerRender = (
/** force always re-renders canvas even if no change */ /** force always re-renders canvas even if no change */
force?: boolean, force?: boolean,
@@ -4141,6 +4178,40 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// Shape switching
if (event.key === KEYS.ESCAPE) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
} else if (
event.key === KEYS.TAB &&
(document.activeElement === this.excalidrawContainerRef?.current ||
document.activeElement?.classList.contains(
CLASSES.CONVERT_ELEMENT_TYPE_POPUP,
))
) {
event.preventDefault();
const conversionType =
getConversionTypeFromElements(selectedElements);
if (
editorJotaiStore.get(convertElementTypePopupAtom)?.type === "panel"
) {
if (
convertElementTypes(this, {
conversionType,
direction: event.shiftKey ? "left" : "right",
})
) {
this.store.shouldCaptureIncrement();
}
}
if (conversionType) {
this.updateEditorAtom(convertElementTypePopupAtom, {
type: "panel",
});
}
}
if ( if (
event.key === KEYS.ESCAPE && event.key === KEYS.ESCAPE &&
this.flowChartCreator.isCreatingChart this.flowChartCreator.isCreatingChart
@@ -4166,9 +4237,9 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
this.flowChartCreator.createNodes( this.flowChartCreator.createNodes(
selectedElements[0], selectedElements[0],
this.scene.getNonDeletedElementsMap(),
this.state, this.state,
getLinkDirectionFromKey(event.key), getLinkDirectionFromKey(event.key),
this.scene,
); );
} }
@@ -4410,16 +4481,16 @@ class App extends React.Component<AppProps, AppState> {
} }
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
mutateElement( this.scene.mutateElement(
element, element,
{ {
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
}, },
false, { informMutation: false, isDragging: false },
); );
updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { updateBoundElements(element, this.scene, {
simultaneouslyUpdated: selectedElements, simultaneouslyUpdated: selectedElements,
}); });
}); });
@@ -4453,6 +4524,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor( editingLinearElement: new LinearElementEditor(
selectedElement, selectedElement,
this.scene.getNonDeletedElementsMap(),
), ),
}); });
} }
@@ -4598,7 +4670,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
) { ) {
editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); this.updateEditorAtom(activeConfirmDialogAtom, "clearCanvas");
} }
// eye dropper // eye dropper
@@ -4646,11 +4718,9 @@ class App extends React.Component<AppProps, AppState> {
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
this.scene.getSelectedElements(this.state).filter(isLinearElement), this.scene.getSelectedElements(this.state).filter(isLinearElement),
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.scene,
this.state.zoom, this.state.zoom,
); );
this.setState({ suggestedBindings: [] }); this.setState({ suggestedBindings: [] });
@@ -4957,7 +5027,7 @@ class App extends React.Component<AppProps, AppState> {
onChange: withBatchedUpdates((nextOriginalText) => { onChange: withBatchedUpdates((nextOriginalText) => {
updateElement(nextOriginalText, false); updateElement(nextOriginalText, false);
if (isNonDeletedElement(element)) { if (isNonDeletedElement(element)) {
updateBoundElements(element, this.scene.getNonDeletedElementsMap()); updateBoundElements(element, this.scene);
} }
}), }),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
@@ -5320,7 +5390,10 @@ class App extends React.Component<AppProps, AppState> {
const minHeight = getApproxMinLineHeight(fontSize, lineHeight); const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
const newHeight = Math.max(container.height, minHeight); const newHeight = Math.max(container.height, minHeight);
const newWidth = Math.max(container.width, minWidth); const newWidth = Math.max(container.width, minWidth);
mutateElement(container, { height: newHeight, width: newWidth }); this.scene.mutateElement(container, {
height: newHeight,
width: newWidth,
});
sceneX = container.x + newWidth / 2; sceneX = container.x + newWidth / 2;
sceneY = container.y + newHeight / 2; sceneY = container.y + newHeight / 2;
if (parentCenterPosition) { if (parentCenterPosition) {
@@ -5371,7 +5444,7 @@ class App extends React.Component<AppProps, AppState> {
}); });
if (!existingTextElement && shouldBindToContainer && container) { if (!existingTextElement && shouldBindToContainer && container) {
mutateElement(container, { this.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
type: "text", type: "text",
id: element.id, id: element.id,
@@ -5447,7 +5520,10 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
this.setState({ this.setState({
editingLinearElement: new LinearElementEditor(selectedElements[0]), editingLinearElement: new LinearElementEditor(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
),
}); });
return; return;
} else if ( } else if (
@@ -5471,7 +5547,11 @@ class App extends React.Component<AppProps, AppState> {
if (midPoint && midPoint > -1) { if (midPoint && midPoint > -1) {
this.store.shouldCaptureIncrement(); this.store.shouldCaptureIncrement();
LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint); LinearElementEditor.deleteFixedSegment(
selectedElements[0],
this.scene,
midPoint,
);
const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
{ {
@@ -5854,7 +5934,6 @@ class App extends React.Component<AppProps, AppState> {
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
this, this,
this.scene.getNonDeletedElementsMap(),
); );
if ( if (
@@ -5916,7 +5995,7 @@ class App extends React.Component<AppProps, AppState> {
lastPoint, lastPoint,
) >= LINE_CONFIRM_THRESHOLD ) >= LINE_CONFIRM_THRESHOLD
) { ) {
mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
points: [ points: [
@@ -5924,7 +6003,7 @@ class App extends React.Component<AppProps, AppState> {
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry), pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
], ],
}, },
false, { informMutation: false, isDragging: false },
); );
} else { } else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -5940,12 +6019,12 @@ class App extends React.Component<AppProps, AppState> {
) < LINE_CONFIRM_THRESHOLD ) < LINE_CONFIRM_THRESHOLD
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
points: points.slice(0, -1), points: points.slice(0, -1),
}, },
false, { informMutation: false, isDragging: false },
); );
} else { } else {
const [gridX, gridY] = getGridPoint( const [gridX, gridY] = getGridPoint(
@@ -5977,8 +6056,9 @@ class App extends React.Component<AppProps, AppState> {
if (isPathALoop(points, this.state.zoom.value)) { if (isPathALoop(points, this.state.zoom.value)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
} }
// update last uncommitted point // update last uncommitted point
mutateElement( this.scene.mutateElement(
multiElement, multiElement,
{ {
points: [ points: [
@@ -5989,9 +6069,9 @@ class App extends React.Component<AppProps, AppState> {
), ),
], ],
}, },
false,
{ {
isDragging: true, isDragging: true,
informMutation: false,
}, },
); );
@@ -6339,7 +6419,11 @@ class App extends React.Component<AppProps, AppState> {
focus: false, focus: false,
})), })),
})); }));
editorJotaiStore.set(searchItemInFocusAtom, null); this.updateEditorAtom(searchItemInFocusAtom, null);
}
if (editorJotaiStore.get(convertElementTypePopupAtom)) {
this.updateEditorAtom(convertElementTypePopupAtom, null);
} }
// since contextMenu options are potentially evaluated on each render, // since contextMenu options are potentially evaluated on each render,
@@ -6578,7 +6662,7 @@ class App extends React.Component<AppProps, AppState> {
const frame = this.getTopLayerFrameAtSceneCoords({ x, y }); const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
mutateElement(pendingImageElement, { this.scene.mutateElement(pendingImageElement, {
x, x,
y, y,
frameId: frame ? frame.id : null, frameId: frame ? frame.id : null,
@@ -7633,7 +7717,7 @@ class App extends React.Component<AppProps, AppState> {
multiElement.type === "line" && multiElement.type === "line" &&
isPathALoop(multiElement.points, this.state.zoom.value) isPathALoop(multiElement.points, this.state.zoom.value)
) { ) {
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: lastCommittedPoint:
multiElement.points[multiElement.points.length - 1], multiElement.points[multiElement.points.length - 1],
}); });
@@ -7644,7 +7728,7 @@ class App extends React.Component<AppProps, AppState> {
// Elbow arrows cannot be created by putting down points // Elbow arrows cannot be created by putting down points
// only the start and end points can be defined // only the start and end points can be defined
if (isElbowArrow(multiElement) && multiElement.points.length > 1) { if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: lastCommittedPoint:
multiElement.points[multiElement.points.length - 1], multiElement.points[multiElement.points.length - 1],
}); });
@@ -7681,7 +7765,7 @@ class App extends React.Component<AppProps, AppState> {
})); }));
// clicking outside commit zone → update reference for last committed // clicking outside commit zone → update reference for last committed
// point // point
mutateElement(multiElement, { this.scene.mutateElement(multiElement, {
lastCommittedPoint: multiElement.points[multiElement.points.length - 1], lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
}); });
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -7767,7 +7851,7 @@ class App extends React.Component<AppProps, AppState> {
), ),
}; };
}); });
mutateElement(element, { this.scene.mutateElement(element, {
points: [...element.points, pointFrom<LocalPoint>(0, 0)], points: [...element.points, pointFrom<LocalPoint>(0, 0)],
}); });
const boundElement = getHoveredElementForBinding( const boundElement = getHoveredElementForBinding(
@@ -8017,7 +8101,7 @@ class App extends React.Component<AppProps, AppState> {
index, index,
gridX, gridX,
gridY, gridY,
this.scene.getNonDeletedElementsMap(), this.scene,
); );
flushSync(() => { flushSync(() => {
@@ -8122,7 +8206,7 @@ class App extends React.Component<AppProps, AppState> {
pointerCoords, pointerCoords,
this, this,
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
elementsMap, this.scene,
); );
if (!ret) { if (!ret) {
return; return;
@@ -8349,7 +8433,7 @@ class App extends React.Component<AppProps, AppState> {
), ),
}; };
mutateElement(croppingElement, { this.scene.mutateElement(croppingElement, {
crop: nextCrop, crop: nextCrop,
}); });
@@ -8606,13 +8690,16 @@ class App extends React.Component<AppProps, AppState> {
? newElement.pressures ? newElement.pressures
: [...newElement.pressures, event.pressure]; : [...newElement.pressures, event.pressure];
mutateElement( this.scene.mutateElement(
newElement, newElement,
{ {
points: [...points, pointFrom<LocalPoint>(dx, dy)], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
}, },
false, {
informMutation: false,
isDragging: false,
},
); );
this.setState({ this.setState({
@@ -8635,24 +8722,23 @@ class App extends React.Component<AppProps, AppState> {
} }
if (points.length === 1) { if (points.length === 1) {
mutateElement( this.scene.mutateElement(
newElement, newElement,
{ {
points: [...points, pointFrom<LocalPoint>(dx, dy)], points: [...points, pointFrom<LocalPoint>(dx, dy)],
}, },
false, { informMutation: false, isDragging: false },
); );
} else if ( } else if (
points.length === 2 || points.length === 2 ||
(points.length > 1 && isElbowArrow(newElement)) (points.length > 1 && isElbowArrow(newElement))
) { ) {
mutateElement( this.scene.mutateElement(
newElement, newElement,
{ {
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)], points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
}, },
false, { isDragging: true, informMutation: false },
{ isDragging: true },
); );
} }
@@ -8763,7 +8849,10 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement: selectedLinearElement:
elementsWithinSelection.length === 1 && elementsWithinSelection.length === 1 &&
isLinearElement(elementsWithinSelection[0]) isLinearElement(elementsWithinSelection[0])
? new LinearElementEditor(elementsWithinSelection[0]) ? new LinearElementEditor(
elementsWithinSelection[0],
this.scene.getNonDeletedElementsMap(),
)
: null, : null,
showHyperlinkPopup: showHyperlinkPopup:
elementsWithinSelection.length === 1 && elementsWithinSelection.length === 1 &&
@@ -8869,7 +8958,7 @@ class App extends React.Component<AppProps, AppState> {
.map((e) => elementsMap.get(e.id)) .map((e) => elementsMap.get(e.id))
.filter((e) => isElbowArrow(e)) .filter((e) => isElbowArrow(e))
.forEach((e) => { .forEach((e) => {
!!e && mutateElement(e, {}, true); !!e && this.scene.mutateElement(e, {});
}); });
} }
} }
@@ -8905,7 +8994,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(), this.scene.getNonDeletedElementsMap(),
); );
if (element) { if (element) {
mutateElement(element, {}, true); this.scene.mutateElement(
element as ExcalidrawElbowArrowElement,
{},
);
} }
} }
@@ -8934,7 +9026,6 @@ class App extends React.Component<AppProps, AppState> {
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
this.scene, this.scene,
); );
} }
@@ -9001,7 +9092,7 @@ class App extends React.Component<AppProps, AppState> {
? [] ? []
: [...newElement.pressures, childEvent.pressure]; : [...newElement.pressures, childEvent.pressure];
mutateElement(newElement, { this.scene.mutateElement(newElement, {
points: [...points, pointFrom<LocalPoint>(dx, dy)], points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures, pressures,
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy), lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
@@ -9048,15 +9139,20 @@ class App extends React.Component<AppProps, AppState> {
); );
if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) { if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
mutateElement(newElement, { this.scene.mutateElement(
points: [ newElement,
...newElement.points, {
pointFrom<LocalPoint>( points: [
pointerCoords.x - newElement.x, ...newElement.points,
pointerCoords.y - newElement.y, pointFrom<LocalPoint>(
), pointerCoords.x - newElement.x,
], pointerCoords.y - newElement.y,
}); ),
],
},
{ informMutation: false, isDragging: false },
);
this.setState({ this.setState({
multiElement: newElement, multiElement: newElement,
newElement, newElement,
@@ -9070,8 +9166,7 @@ class App extends React.Component<AppProps, AppState> {
newElement, newElement,
this.state, this.state,
pointerCoords, pointerCoords,
this.scene.getNonDeletedElementsMap(), this.scene,
this.scene.getNonDeletedElements(),
); );
} }
this.setState({ suggestedBindings: [], startBoundElement: null }); this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -9089,7 +9184,10 @@ class App extends React.Component<AppProps, AppState> {
}, },
prevState, prevState,
), ),
selectedLinearElement: new LinearElementEditor(newElement), selectedLinearElement: new LinearElementEditor(
newElement,
this.scene.getNonDeletedElementsMap(),
),
})); }));
} else { } else {
this.setState((prevState) => ({ this.setState((prevState) => ({
@@ -9112,7 +9210,7 @@ class App extends React.Component<AppProps, AppState> {
); );
if (newElement.width < minWidth) { if (newElement.width < minWidth) {
mutateElement(newElement, { this.scene.mutateElement(newElement, {
autoResize: true, autoResize: true,
}); });
} }
@@ -9162,7 +9260,14 @@ class App extends React.Component<AppProps, AppState> {
} }
if (newElement) { if (newElement) {
mutateElement(newElement, getNormalizedDimensions(newElement)); this.scene.mutateElement(
newElement,
getNormalizedDimensions(newElement),
{
informMutation: false,
isDragging: false,
},
);
// the above does not guarantee the scene to be rendered again, hence the trigger below // the above does not guarantee the scene to be rendered again, hence the trigger below
this.scene.triggerUpdate(); this.scene.triggerUpdate();
} }
@@ -9194,7 +9299,7 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
// remove the linear element from all groups // remove the linear element from all groups
// before removing it from the frame as well // before removing it from the frame as well
mutateElement(linearElement, { this.scene.mutateElement(linearElement, {
groupIds: [], groupIds: [],
}); });
@@ -9223,12 +9328,12 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingGroupId!, this.state.editingGroupId!,
); );
mutateElement( this.scene.mutateElement(
element, element,
{ {
groupIds: element.groupIds.slice(0, index), groupIds: element.groupIds.slice(0, index),
}, },
false, { informMutation: false, isDragging: false },
); );
} }
@@ -9240,12 +9345,12 @@ class App extends React.Component<AppProps, AppState> {
element.groupIds[element.groupIds.length - 1], element.groupIds[element.groupIds.length - 1],
).length < 2 ).length < 2
) { ) {
mutateElement( this.scene.mutateElement(
element, element,
{ {
groupIds: [], groupIds: [],
}, },
false, { informMutation: false, isDragging: false },
); );
} }
}); });
@@ -9355,7 +9460,10 @@ class App extends React.Component<AppProps, AppState> {
// the one we've hit // the one we've hit
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
this.setState({ this.setState({
selectedLinearElement: new LinearElementEditor(hitElement), selectedLinearElement: new LinearElementEditor(
hitElement,
this.scene.getNonDeletedElementsMap(),
),
}); });
} }
} }
@@ -9480,7 +9588,10 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement: selectedLinearElement:
newSelectedElements.length === 1 && newSelectedElements.length === 1 &&
isLinearElement(newSelectedElements[0]) isLinearElement(newSelectedElements[0])
? new LinearElementEditor(newSelectedElements[0]) ? new LinearElementEditor(
newSelectedElements[0],
this.scene.getNonDeletedElementsMap(),
)
: prevState.selectedLinearElement, : prevState.selectedLinearElement,
}; };
}); });
@@ -9554,7 +9665,10 @@ class App extends React.Component<AppProps, AppState> {
// Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1. // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
// Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
prevState.selectedLinearElement?.elementId !== hitElement.id prevState.selectedLinearElement?.elementId !== hitElement.id
? new LinearElementEditor(hitElement) ? new LinearElementEditor(
hitElement,
this.scene.getNonDeletedElementsMap(),
)
: prevState.selectedLinearElement, : prevState.selectedLinearElement,
})); }));
} }
@@ -9647,11 +9761,9 @@ class App extends React.Component<AppProps, AppState> {
bindOrUnbindLinearElements( bindOrUnbindLinearElements(
linearElements, linearElements,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.scene,
isBindingEnabled(this.state), isBindingEnabled(this.state),
this.state.selectedLinearElement?.selectedPointsIndices ?? [], this.state.selectedLinearElement?.selectedPointsIndices ?? [],
this.scene,
this.state.zoom, this.state.zoom,
); );
} }
@@ -9808,12 +9920,12 @@ class App extends React.Component<AppProps, AppState> {
const dataURL = const dataURL =
this.files[fileId]?.dataURL || (await getDataURL(imageFile)); this.files[fileId]?.dataURL || (await getDataURL(imageFile));
const imageElement = mutateElement( const imageElement = this.scene.mutateElement(
_imageElement, _imageElement,
{ {
fileId, fileId,
}, },
false, { informMutation: false, isDragging: false },
) as NonDeleted<InitializedExcalidrawImageElement>; ) as NonDeleted<InitializedExcalidrawImageElement>;
return new Promise<NonDeleted<InitializedExcalidrawImageElement>>( return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
@@ -9879,7 +9991,7 @@ class App extends React.Component<AppProps, AppState> {
showCursorImagePreview, showCursorImagePreview,
}); });
} catch (error: any) { } catch (error: any) {
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
isDeleted: true, isDeleted: true,
}); });
this.actionManager.executeAction(actionFinalize); this.actionManager.executeAction(actionFinalize);
@@ -10025,7 +10137,7 @@ class App extends React.Component<AppProps, AppState> {
imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
) { ) {
const placeholderSize = 100 / this.state.zoom.value; const placeholderSize = 100 / this.state.zoom.value;
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
x: imageElement.x - placeholderSize / 2, x: imageElement.x - placeholderSize / 2,
y: imageElement.y - placeholderSize / 2, y: imageElement.y - placeholderSize / 2,
width: placeholderSize, width: placeholderSize,
@@ -10059,7 +10171,7 @@ class App extends React.Component<AppProps, AppState> {
const x = imageElement.x + imageElement.width / 2 - width / 2; const x = imageElement.x + imageElement.width / 2 - width / 2;
const y = imageElement.y + imageElement.height / 2 - height / 2; const y = imageElement.y + imageElement.height / 2 - height / 2;
mutateElement(imageElement, { this.scene.mutateElement(imageElement, {
x, x,
y, y,
width, width,
@@ -10490,7 +10602,10 @@ class App extends React.Component<AppProps, AppState> {
this, this,
), ),
selectedLinearElement: isLinearElement(element) selectedLinearElement: isLinearElement(element)
? new LinearElementEditor(element) ? new LinearElementEditor(
element,
this.scene.getNonDeletedElementsMap(),
)
: null, : null,
} }
: this.state), : this.state),
@@ -10523,8 +10638,9 @@ class App extends React.Component<AppProps, AppState> {
height: distance(pointerDownState.origin.y, pointerCoords.y), height: distance(pointerDownState.origin.y, pointerCoords.y),
shouldMaintainAspectRatio: shouldMaintainAspectRatio(event), shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
shouldResizeFromCenter: false, shouldResizeFromCenter: false,
scene: this.scene,
zoom: this.state.zoom.value, zoom: this.state.zoom.value,
informMutation, informMutation: false,
}); });
return; return;
} }
@@ -10588,6 +10704,7 @@ class App extends React.Component<AppProps, AppState> {
: shouldMaintainAspectRatio(event), : shouldMaintainAspectRatio(event),
shouldResizeFromCenter: shouldResizeFromCenter(event), shouldResizeFromCenter: shouldResizeFromCenter(event),
zoom: this.state.zoom.value, zoom: this.state.zoom.value,
scene: this.scene,
widthAspectRatio: aspectRatio, widthAspectRatio: aspectRatio,
originOffset: this.state.originSnapOffset, originOffset: this.state.originSnapOffset,
informMutation, informMutation,
@@ -10675,7 +10792,7 @@ class App extends React.Component<AppProps, AppState> {
transformHandleType, transformHandleType,
); );
mutateElement( this.scene.mutateElement(
croppingElement, croppingElement,
cropElement( cropElement(
croppingElement, croppingElement,
@@ -10690,16 +10807,12 @@ class App extends React.Component<AppProps, AppState> {
), ),
); );
updateBoundElements( updateBoundElements(croppingElement, this.scene, {
croppingElement, newSize: {
this.scene.getNonDeletedElementsMap(), width: croppingElement.width,
{ height: croppingElement.height,
newSize: {
width: croppingElement.width,
height: croppingElement.height,
},
}, },
); });
this.setState({ this.setState({
isCropping: transformHandleType && transformHandleType !== "rotation", isCropping: transformHandleType && transformHandleType !== "rotation",
@@ -10813,7 +10926,6 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.originalElements, pointerDownState.originalElements,
transformHandleType, transformHandleType,
selectedElements, selectedElements,
this.scene.getElementsMapIncludingDeleted(),
this.scene, this.scene,
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
@@ -11,6 +11,8 @@ import {
isWritableElement, isWritableElement,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import type { MarkRequired } from "@excalidraw/common/utility-types"; import type { MarkRequired } from "@excalidraw/common/utility-types";
import { import {
@@ -410,6 +412,14 @@ function CommandPaletteInner({
actionManager.executeAction(actionToggleSearchMenu); actionManager.executeAction(actionToggleSearchMenu);
}, },
}, },
{
label: t("labels.shapeSwitch"),
category: DEFAULT_CATEGORIES.elements,
icon: boltIcon,
perform: () => {
actionManager.executeAction(actionToggleShapeSwitch);
},
},
{ {
label: t("labels.changeStroke"), label: t("labels.changeStroke"),
keywords: ["color", "outline"], keywords: ["color", "outline"],
@@ -0,0 +1,18 @@
@import "../css//variables.module.scss";
.excalidraw {
.ConvertElementTypePopup {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.2rem;
border-radius: 0.5rem;
background: var(--island-bg-color);
box-shadow: var(--shadow-island);
padding: 0.5rem;
&:focus {
outline: none;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -6,9 +6,10 @@ import {
defaultGetElementLinkFromSelection, defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection, getLinkIdAndTypeFromSelection,
} from "@excalidraw/element/elementLink"; } from "@excalidraw/element/elementLink";
import { mutateElement } from "@excalidraw/element/mutateElement";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { t } from "../i18n"; import { t } from "../i18n";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
@@ -21,20 +22,20 @@ import { TrashIcon } from "./icons";
import "./ElementLinkDialog.scss"; import "./ElementLinkDialog.scss";
import type { AppProps, AppState, UIAppState } from "../types"; import type { AppProps, AppState, UIAppState } from "../types";
const ElementLinkDialog = ({ const ElementLinkDialog = ({
sourceElementId, sourceElementId,
onClose, onClose,
elementsMap,
appState, appState,
scene,
generateLinkForSelection = defaultGetElementLinkFromSelection, generateLinkForSelection = defaultGetElementLinkFromSelection,
}: { }: {
sourceElementId: ExcalidrawElement["id"]; sourceElementId: ExcalidrawElement["id"];
elementsMap: ElementsMap;
appState: UIAppState; appState: UIAppState;
scene: Scene;
onClose?: () => void; onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"]; generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap();
const originalLink = elementsMap.get(sourceElementId)?.link ?? null; const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink); const [nextLink, setNextLink] = useState<string | null>(originalLink);
@@ -70,7 +71,7 @@ const ElementLinkDialog = ({
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) { if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId); const elementToLink = elementsMap.get(sourceElementId);
elementToLink && elementToLink &&
mutateElement(elementToLink, { scene.mutateElement(elementToLink, {
link: nextLink, link: nextLink,
}); });
} }
@@ -78,13 +79,13 @@ const ElementLinkDialog = ({
if (!nextLink && linkEdited && sourceElementId) { if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId); const elementToLink = elementsMap.get(sourceElementId);
elementToLink && elementToLink &&
mutateElement(elementToLink, { scene.mutateElement(elementToLink, {
link: null, link: null,
}); });
} }
onClose?.(); onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]); }, [sourceElementId, nextLink, elementsMap, linkEdited, scene, onClose]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -21,7 +21,7 @@ const Header = () => (
className="HelpDialog__btn" className="HelpDialog__btn"
href="https://docs.excalidraw.com" href="https://docs.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
> >
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.documentation")} {t("helpDialog.documentation")}
@@ -30,7 +30,7 @@ const Header = () => (
className="HelpDialog__btn" className="HelpDialog__btn"
href="https://plus.excalidraw.com/blog" href="https://plus.excalidraw.com/blog"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
> >
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.blog")} {t("helpDialog.blog")}
@@ -247,6 +247,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.link")} label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]} shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/> />
<Shortcut
label={t("toolBar.convertElementType")}
shortcuts={["Tab", "Shift+Tab"]}
isOr={true}
/>
</ShortcutIsland> </ShortcutIsland>
<ShortcutIsland <ShortcutIsland
className="HelpDialog__island--view" className="HelpDialog__island--view"
+12 -16
View File
@@ -5,6 +5,7 @@ import {
CLASSES, CLASSES,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
TOOL_TYPE, TOOL_TYPE,
arrayToMap,
capitalizeString, capitalizeString,
isShallowEqual, isShallowEqual,
} from "@excalidraw/common"; } from "@excalidraw/common";
@@ -17,7 +18,6 @@ import { ShapeCache } from "@excalidraw/element/ShapeCache";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import Scene from "../scene/Scene";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { isHandToolActive } from "../appState"; import { isHandToolActive } from "../appState";
@@ -446,22 +446,18 @@ const LayerUI = ({
if (selectedElements.length) { if (selectedElements.length) {
for (const element of selectedElements) { for (const element of selectedElements) {
mutateElement( mutateElement(element, arrayToMap(elements), {
element, [altKey && eyeDropperState.swapPreviewOnAlt
{ ? colorPickerType === "elementBackground"
[altKey && eyeDropperState.swapPreviewOnAlt ? "strokeColor"
? colorPickerType === "elementBackground" : "backgroundColor"
? "strokeColor" : colorPickerType === "elementBackground"
: "backgroundColor" ? "backgroundColor"
: colorPickerType === "elementBackground" : "strokeColor"]: color,
? "backgroundColor" });
: "strokeColor"]: color,
},
false,
);
ShapeCache.delete(element); ShapeCache.delete(element);
} }
Scene.getScene(selectedElements[0])?.triggerUpdate(); app.scene.triggerUpdate();
} else if (colorPickerType === "elementBackground") { } else if (colorPickerType === "elementBackground") {
setAppState({ setAppState({
currentItemBackgroundColor: color, currentItemBackgroundColor: color,
@@ -494,7 +490,7 @@ const LayerUI = ({
openDialog: null, openDialog: null,
}); });
}} }}
elementsMap={app.scene.getNonDeletedElementsMap()} scene={app.scene}
appState={appState} appState={appState}
generateLinkForSelection={generateLinkForSelection} generateLinkForSelection={generateLinkForSelection}
/> />
@@ -389,7 +389,7 @@ const PublishLibrary = ({
<a <a
href="https://libraries.excalidraw.com" href="https://libraries.excalidraw.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
> >
{el} {el}
</a> </a>
+11 -11
View File
@@ -1,7 +1,5 @@
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math"; import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement"; import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement, isElbowArrow } from "@excalidraw/element/typeChecks"; import { isArrowElement, isElbowArrow } from "@excalidraw/element/typeChecks";
@@ -9,13 +7,16 @@ import type { Degrees } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import { updateBindings } from "../../../element/src/binding";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface AngleProps { interface AngleProps {
@@ -35,7 +36,6 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene, scene,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
if (origElement && !isElbowArrow(origElement)) { if (origElement && !isElbowArrow(origElement)) {
const latestElement = elementsMap.get(origElement.id); const latestElement = elementsMap.get(origElement.id);
@@ -45,14 +45,14 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextAngle = degreesToRadians(nextValue as Degrees); const nextAngle = degreesToRadians(nextValue as Degrees);
mutateElement(latestElement, { scene.mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap, elements, scene); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }); scene.mutateElement(boundTextElement, { angle: nextAngle });
} }
return; return;
@@ -71,14 +71,14 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement(latestElement, { scene.mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, elementsMap, elements, scene); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }); scene.mutateElement(boundTextElement, { angle: nextAngle });
} }
} }
}; };
@@ -1,9 +1,10 @@
import type Scene from "@excalidraw/element/Scene";
import { getNormalizedGridStep } from "../../scene"; import { getNormalizedGridStep } from "../../scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface PositionProps { interface PositionProps {
@@ -5,17 +5,17 @@ import {
MINIMAL_CROP_SIZE, MINIMAL_CROP_SIZE,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
} from "@excalidraw/element/cropElement"; } from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { resizeSingleElement } from "@excalidraw/element/resizeElements"; import { resizeSingleElement } from "@excalidraw/element/resizeElements";
import { isImageElement } from "@excalidraw/element/typeChecks"; import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface DimensionDragInputProps { interface DimensionDragInputProps {
@@ -113,7 +113,7 @@ const handleDimensionChange: DragInputCallbackType<
}; };
} }
mutateElement(element, { scene.mutateElement(element, {
crop: nextCrop, crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
@@ -144,7 +144,7 @@ const handleDimensionChange: DragInputCallbackType<
height: nextCropHeight, height: nextCropHeight,
}; };
mutateElement(element, { scene.mutateElement(element, {
crop: nextCrop, crop: nextCrop,
width: nextCrop.width / (crop.naturalWidth / uncroppedWidth), width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
height: nextCrop.height / (crop.naturalHeight / uncroppedHeight), height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
@@ -176,8 +176,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
property === "width" ? "e" : "s", property === "width" ? "e" : "s",
{ {
shouldMaintainAspectRatio: keepAspectRatio, shouldMaintainAspectRatio: keepAspectRatio,
@@ -223,8 +223,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
property === "width" ? "e" : "s", property === "width" ? "e" : "s",
{ {
shouldMaintainAspectRatio: keepAspectRatio, shouldMaintainAspectRatio: keepAspectRatio,
@@ -7,6 +7,8 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { CaptureUpdateAction } from "../../store"; import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App"; import { useApp } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
@@ -16,7 +18,6 @@ import { SMALLEST_DELTA } from "./utils";
import "./DragInput.scss"; import "./DragInput.scss";
import type { StatsInputProperty } from "./utils"; import type { StatsInputProperty } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
export type DragInputCallbackType< export type DragInputCallbackType<
@@ -216,13 +217,12 @@ const StatsDragInput = <
y: number; y: number;
} | null = null; } | null = null;
let originalElementsMap: Map<string, ExcalidrawElement> | null = let originalElementsMap: ElementsMap | null = app.scene
app.scene .getNonDeletedElements()
.getNonDeletedElements() .reduce((acc: ElementsMap, element) => {
.reduce((acc: ElementsMap, element) => { acc.set(element.id, deepCopyElement(element));
acc.set(element.id, deepCopyElement(element)); return acc;
return acc; }, new Map());
}, new Map());
let originalElements: readonly E[] | null = elements.map( let originalElements: readonly E[] | null = elements.map(
(element) => originalElementsMap!.get(element.id) as E, (element) => originalElementsMap!.get(element.id) as E,
@@ -1,4 +1,3 @@
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
@@ -13,13 +12,14 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons"; import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface FontSizeProps { interface FontSizeProps {
@@ -68,13 +68,13 @@ const handleFontSizeChange: DragInputCallbackType<
} }
if (nextFontSize) { if (nextFontSize) {
mutateElement(latestElement, { scene.mutateElement(latestElement, {
fontSize: nextFontSize, fontSize: nextFontSize,
}); });
redrawTextBoundingBox( redrawTextBoundingBox(
latestElement, latestElement,
scene.getContainerElement(latestElement), scene.getContainerElement(latestElement),
scene.getNonDeletedElementsMap(), scene,
); );
} }
} }
@@ -1,7 +1,5 @@
import { degreesToRadians, radiansToDegrees } from "@excalidraw/math"; import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement"; import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement } from "@excalidraw/element/typeChecks"; import { isArrowElement } from "@excalidraw/element/typeChecks";
@@ -11,13 +9,14 @@ import type { Degrees } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { angleIcon } from "../icons"; import { angleIcon } from "../icons";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface MultiAngleProps { interface MultiAngleProps {
@@ -54,17 +53,13 @@ const handleDegreeChange: DragInputCallbackType<
if (!element) { if (!element) {
continue; continue;
} }
mutateElement( scene.mutateElement(element, {
element, angle: nextAngle,
{ });
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && !isArrowElement(element)) { if (boundTextElement && !isArrowElement(element)) {
mutateElement(boundTextElement, { angle: nextAngle }, false); scene.mutateElement(boundTextElement, { angle: nextAngle });
} }
} }
@@ -92,17 +87,13 @@ const handleDegreeChange: DragInputCallbackType<
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
mutateElement( scene.mutateElement(latestElement, {
latestElement, angle: nextAngle,
{ });
angle: nextAngle,
},
false,
);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle }, false); scene.mutateElement(boundTextElement, { angle: nextAngle });
} }
} }
scene.triggerUpdate(); scene.triggerUpdate();
@@ -3,7 +3,6 @@ import { useMemo } from "react";
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common"; import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
import { updateBoundElements } from "@excalidraw/element/binding"; import { updateBoundElements } from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
rescalePointsInElement, rescalePointsInElement,
resizeSingleElement, resizeSingleElement,
@@ -23,13 +22,14 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils"; import { getElementsInAtomicUnit } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface MultiDimensionProps { interface MultiDimensionProps {
@@ -75,33 +75,31 @@ const resizeElementInGroup = (
scale: number, scale: number,
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
origElement: ExcalidrawElement, origElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement); const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
mutateElement(latestElement, updates, false); scene.mutateElement(latestElement, updates);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
origElement, origElement,
originalElementsMap, originalElementsMap,
); );
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, elementsMap, { updateBoundElements(latestElement, scene, {
newSize: { width: updates.width, height: updates.height }, newSize: { width: updates.width, height: updates.height },
}); });
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
mutateElement( scene.mutateElement(latestBoundTextElement, {
latestBoundTextElement, fontSize: newFontSize,
{ });
fontSize: newFontSize,
},
false,
);
handleBindTextResize( handleBindTextResize(
latestElement, latestElement,
elementsMap, scene,
property === "width" ? "e" : "s", property === "width" ? "e" : "s",
true, true,
); );
@@ -118,8 +116,8 @@ const resizeGroup = (
property: MultiDimensionProps["property"], property: MultiDimensionProps["property"],
latestElements: ExcalidrawElement[], latestElements: ExcalidrawElement[],
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene,
) => { ) => {
// keep aspect ratio for groups // keep aspect ratio for groups
if (property === "width") { if (property === "width") {
@@ -141,8 +139,8 @@ const resizeGroup = (
scale, scale,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} }
}; };
@@ -194,8 +192,8 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
latestElements, latestElements,
originalElements, originalElements,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} else { } else {
const [el] = elementsInUnit; const [el] = elementsInUnit;
@@ -237,8 +235,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
property === "width" ? "e" : "s", property === "width" ? "e" : "s",
{ {
shouldInformMutation: false, shouldInformMutation: false,
@@ -301,8 +299,8 @@ const handleDimensionChange: DragInputCallbackType<
property, property,
latestElements, latestElements,
originalElements, originalElements,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
); );
} else { } else {
const [el] = elementsInUnit; const [el] = elementsInUnit;
@@ -340,8 +338,8 @@ const handleDimensionChange: DragInputCallbackType<
nextHeight, nextHeight,
latestElement, latestElement,
origElement, origElement,
elementsMap,
originalElementsMap, originalElementsMap,
scene,
property === "width" ? "e" : "s", property === "width" ? "e" : "s",
{ {
shouldInformMutation: false, shouldInformMutation: false,
@@ -1,4 +1,3 @@
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
@@ -16,13 +15,14 @@ import type {
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { fontSizeIcon } from "../icons"; import { fontSizeIcon } from "../icons";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue } from "./utils"; import { getStepSizedValue } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface MultiFontSizeProps { interface MultiFontSizeProps {
@@ -84,19 +84,14 @@ const handleFontSizeChange: DragInputCallbackType<
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE); nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
for (const textElement of latestTextElements) { for (const textElement of latestTextElements) {
mutateElement( scene.mutateElement(textElement, {
textElement, fontSize: nextFontSize,
{ });
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox( redrawTextBoundingBox(
textElement, textElement,
scene.getContainerElement(textElement), scene.getContainerElement(textElement),
elementsMap, scene,
false,
); );
} }
@@ -117,19 +112,14 @@ const handleFontSizeChange: DragInputCallbackType<
if (shouldChangeByStepSize) { if (shouldChangeByStepSize) {
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE); nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
} }
mutateElement( scene.mutateElement(latestElement, {
latestElement, fontSize: nextFontSize,
{ });
fontSize: nextFontSize,
},
false,
);
redrawTextBoundingBox( redrawTextBoundingBox(
latestElement, latestElement,
scene.getContainerElement(latestElement), scene.getContainerElement(latestElement),
elementsMap, scene,
false,
); );
} }
@@ -5,20 +5,21 @@ import { isTextElement } from "@excalidraw/element/typeChecks";
import { getCommonBounds } from "@excalidraw/element/bounds"; import { getCommonBounds } from "@excalidraw/element/bounds";
import type { import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
ElementsMap,
ExcalidrawElement, import type Scene from "@excalidraw/element/Scene";
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import {
getAtomicUnits,
getStepSizedValue,
isPropertyEditable,
STEP_SIZE,
} from "./utils";
import { getElementsInAtomicUnit, moveElement } from "./utils"; import { getElementsInAtomicUnit, moveElement } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface MultiPositionProps { interface MultiPositionProps {
@@ -30,19 +31,15 @@ interface MultiPositionProps {
appState: AppState; appState: AppState;
} }
const STEP_SIZE = 10;
const moveElements = ( const moveElements = (
property: MultiPositionProps["property"], property: MultiPositionProps["property"],
changeInTopX: number, changeInTopX: number,
changeInTopY: number, changeInTopY: number,
elements: readonly ExcalidrawElement[],
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
) => { ) => {
for (let i = 0; i < elements.length; i++) { for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
const [cx, cy] = [ const [cx, cy] = [
@@ -65,8 +62,6 @@ const moveElements = (
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@@ -78,11 +73,10 @@ const moveGroupTo = (
nextX: number, nextX: number,
nextY: number, nextY: number,
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, ,] = getCommonBounds(originalElements); const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1; const offsetX = nextX - x1;
const offsetY = nextY - y1; const offsetY = nextY - y1;
@@ -112,8 +106,6 @@ const moveGroupTo = (
topLeftX + offsetX, topLeftX + offsetX,
topLeftY + offsetY, topLeftY + offsetY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@@ -135,7 +127,6 @@ const handlePositionChange: DragInputCallbackType<
originalAppState, originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) { if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits( for (const atomicUnit of getAtomicUnits(
@@ -159,8 +150,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
elementsInUnit.map((el) => el.original), elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap, originalElementsMap,
scene, scene,
); );
@@ -188,8 +177,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@@ -214,8 +201,6 @@ const handlePositionChange: DragInputCallbackType<
changeInTopX, changeInTopX,
changeInTopY, changeInTopY,
originalElements, originalElements,
originalElements,
elementsMap,
originalElementsMap, originalElementsMap,
scene, scene,
); );
@@ -4,16 +4,16 @@ import {
getFlipAdjustedCropPosition, getFlipAdjustedCropPosition,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
} from "@excalidraw/element/cropElement"; } from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { isImageElement } from "@excalidraw/element/typeChecks"; import { isImageElement } from "@excalidraw/element/typeChecks";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import StatsDragInput from "./DragInput"; import StatsDragInput from "./DragInput";
import { getStepSizedValue, moveElement } from "./utils"; import { getStepSizedValue, moveElement, STEP_SIZE } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type { DragInputCallbackType } from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface PositionProps { interface PositionProps {
@@ -24,8 +24,6 @@ interface PositionProps {
appState: AppState; appState: AppState;
} }
const STEP_SIZE = 10;
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
accumulatedChange, accumulatedChange,
instantChange, instantChange,
@@ -38,7 +36,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
originalAppState, originalAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0]; const origElement = originalElements[0];
const [cx, cy] = [ const [cx, cy] = [
origElement.x + origElement.width / 2, origElement.x + origElement.width / 2,
@@ -101,7 +98,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
}; };
} }
mutateElement(element, { scene.mutateElement(element, {
crop: nextCrop, crop: nextCrop,
}); });
@@ -119,7 +116,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height), y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
}; };
mutateElement(element, { scene.mutateElement(element, {
crop: nextCrop, crop: nextCrop,
}); });
@@ -133,8 +130,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
); );
@@ -166,8 +161,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
); );
@@ -17,7 +17,7 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { Excalidraw, getCommonBounds, mutateElement } from "../.."; import { Excalidraw, getCommonBounds } from "../..";
import { actionGroup } from "../../actions"; import { actionGroup } from "../../actions";
import { t } from "../../i18n"; import { t } from "../../i18n";
import * as StaticScene from "../../renderer/staticScene"; import * as StaticScene from "../../renderer/staticScene";
@@ -478,7 +478,7 @@ describe("stats for a non-generic element", () => {
containerId: container.id, containerId: container.id,
fontSize: 20, fontSize: 20,
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: text.id }], boundElements: [{ type: "text", id: text.id }],
}); });
API.setElements([container, text]); API.setElements([container, text]);
+75 -41
View File
@@ -1,14 +1,8 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import {
bindOrUnbindLinearElements,
updateBoundElements,
} from "@excalidraw/element/binding";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement"; import { getBoundTextElement } from "@excalidraw/element/textElement";
import { import {
isFrameLikeElement, isFrameLikeElement,
isLinearElement,
isTextElement, isTextElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
@@ -18,16 +12,20 @@ import {
isInGroup, isInGroup,
} from "@excalidraw/element/groups"; } from "@excalidraw/element/groups";
import { getFrameChildren } from "@excalidraw/element/frame";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "../../scene/Scene"; import type Scene from "@excalidraw/element/Scene";
import { updateBindings } from "../../../element/src/binding";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
export type StatsInputProperty = export type StatsInputProperty =
@@ -40,6 +38,7 @@ export type StatsInputProperty =
| "gridStep"; | "gridStep";
export const SMALLEST_DELTA = 0.01; export const SMALLEST_DELTA = 0.01;
export const STEP_SIZE = 10;
export const isPropertyEditable = ( export const isPropertyEditable = (
element: ExcalidrawElement, element: ExcalidrawElement,
@@ -119,12 +118,11 @@ export const moveElement = (
newTopLeftX: number, newTopLeftX: number,
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene, scene: Scene,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap();
const latestElement = elementsMap.get(originalElement.id); const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) { if (!latestElement) {
return; return;
@@ -148,15 +146,15 @@ export const moveElement = (
-originalElement.angle as Radians, -originalElement.angle as Radians,
); );
mutateElement( scene.mutateElement(
latestElement, latestElement,
{ {
x, x,
y, y,
}, },
shouldInformMutation, { informMutation: shouldInformMutation, isDragging: false },
); );
updateBindings(latestElement, elementsMap, elements, scene); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@@ -165,15 +163,77 @@ export const moveElement = (
if (boundTextElement) { if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement && latestBoundTextElement &&
mutateElement( scene.mutateElement(
latestBoundTextElement, latestBoundTextElement,
{ {
x: boundTextElement.x + changeInX, x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY, y: boundTextElement.y + changeInY,
}, },
shouldInformMutation, { informMutation: shouldInformMutation, isDragging: false },
); );
} }
if (isFrameLikeElement(originalElement)) {
const originalChildren = getFrameChildren(
originalElementsMap,
originalElement.id,
);
originalChildren.forEach((child) => {
const latestChildElement = elementsMap.get(child.id);
if (!latestChildElement) {
return;
}
const [childCX, childCY] = [
child.x + child.width / 2,
child.y + child.height / 2,
];
const [childTopLeftX, childTopLeftY] = pointRotateRads(
pointFrom(child.x, child.y),
pointFrom(childCX, childCY),
child.angle,
);
const childNewTopLeftX = Math.round(childTopLeftX + changeInX);
const childNewTopLeftY = Math.round(childTopLeftY + changeInY);
const [childX, childY] = pointRotateRads(
pointFrom(childNewTopLeftX, childNewTopLeftY),
pointFrom(childCX + changeInX, childCY + changeInY),
-child.angle as Radians,
);
scene.mutateElement(
latestChildElement,
{
x: childX,
y: childY,
},
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestChildElement, scene, {
simultaneouslyUpdated: originalChildren,
});
const boundTextElement = getBoundTextElement(
latestChildElement,
originalElementsMap,
);
if (boundTextElement) {
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
latestBoundTextElement &&
scene.mutateElement(
latestBoundTextElement,
{
x: boundTextElement.x + changeInX,
y: boundTextElement.y + changeInY,
},
{ informMutation: shouldInformMutation, isDragging: false },
);
}
});
}
}; };
export const getAtomicUnits = ( export const getAtomicUnits = (
@@ -196,29 +256,3 @@ export const getAtomicUnits = (
}); });
return _atomicUnits; return _atomicUnits;
}; };
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
newSize?: { width: number; height: number };
zoom?: AppState["zoom"];
},
) => {
if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements(
[latestElement],
elementsMap,
elements,
scene,
true,
[],
options?.zoom,
);
} else {
updateBoundElements(latestElement, elementsMap, options);
}
};
@@ -16,7 +16,7 @@ const DropdownMenuItemLink = ({
onSelect, onSelect,
className = "", className = "",
selected, selected,
rel = "noreferrer", rel = "noopener",
...rest ...rest
}: { }: {
href: string; href: string;
@@ -31,11 +31,12 @@ const DropdownMenuItemLink = ({
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return ( return (
// eslint-disable-next-line react/jsx-no-target-blank
<a <a
{...rest} {...rest}
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel={rel || "noopener"}
className={getDropdownMenuItemClassName(className, selected)} className={getDropdownMenuItemClassName(className, selected)}
title={rest.title ?? rest["aria-label"]} title={rest.title ?? rest["aria-label"]}
onClick={handleClick} onClick={handleClick}
@@ -21,8 +21,6 @@ import {
embeddableURLValidator, embeddableURLValidator,
} from "@excalidraw/element/embeddable"; } from "@excalidraw/element/embeddable";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
@@ -33,6 +31,8 @@ import {
import { isEmbeddableElement } from "@excalidraw/element/typeChecks"; import { isEmbeddableElement } from "@excalidraw/element/typeChecks";
import type Scene from "@excalidraw/element/Scene";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
@@ -70,14 +70,14 @@ const embeddableLinkCache = new Map<
export const Hyperlink = ({ export const Hyperlink = ({
element, element,
elementsMap, scene,
setAppState, setAppState,
onLinkOpen, onLinkOpen,
setToast, setToast,
updateEmbedValidationStatus, updateEmbedValidationStatus,
}: { }: {
element: NonDeletedExcalidrawElement; element: NonDeletedExcalidrawElement;
elementsMap: ElementsMap; scene: Scene;
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
onLinkOpen: ExcalidrawProps["onLinkOpen"]; onLinkOpen: ExcalidrawProps["onLinkOpen"];
setToast: ( setToast: (
@@ -88,6 +88,7 @@ export const Hyperlink = ({
status: boolean, status: boolean,
) => void; ) => void;
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap();
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const appProps = useAppProps(); const appProps = useAppProps();
const device = useDevice(); const device = useDevice();
@@ -114,7 +115,7 @@ export const Hyperlink = ({
setAppState({ activeEmbeddable: null }); setAppState({ activeEmbeddable: null });
} }
if (!link) { if (!link) {
mutateElement(element, { scene.mutateElement(element, {
link: null, link: null,
}); });
updateEmbedValidationStatus(element, false); updateEmbedValidationStatus(element, false);
@@ -126,7 +127,7 @@ export const Hyperlink = ({
setToast({ message: t("toast.unableToEmbed"), closable: true }); setToast({ message: t("toast.unableToEmbed"), closable: true });
} }
element.link && embeddableLinkCache.set(element.id, element.link); element.link && embeddableLinkCache.set(element.id, element.link);
mutateElement(element, { scene.mutateElement(element, {
link, link,
}); });
updateEmbedValidationStatus(element, false); updateEmbedValidationStatus(element, false);
@@ -144,7 +145,7 @@ export const Hyperlink = ({
: 1; : 1;
const hasLinkChanged = const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link; embeddableLinkCache.get(element.id) !== element.link;
mutateElement(element, { scene.mutateElement(element, {
...(hasLinkChanged ...(hasLinkChanged
? { ? {
width: width:
@@ -169,10 +170,11 @@ export const Hyperlink = ({
} }
} }
} else { } else {
mutateElement(element, { link }); scene.mutateElement(element, { link });
} }
}, [ }, [
element, element,
scene,
setToast, setToast,
appProps.validateEmbeddable, appProps.validateEmbeddable,
appState.activeEmbeddable, appState.activeEmbeddable,
@@ -229,9 +231,9 @@ export const Hyperlink = ({
const handleRemove = useCallback(() => { const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete"); trackEvent("hyperlink", "delete");
mutateElement(element, { link: null }); scene.mutateElement(element, { link: null });
setAppState({ showHyperlinkPopup: false }); setAppState({ showHyperlinkPopup: false });
}, [setAppState, element]); }, [setAppState, element, scene]);
const onEdit = () => { const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui"); trackEvent("hyperlink", "edit", "popup-ui");
@@ -78,7 +78,7 @@ const WelcomeScreenMenuItemLink = ({
className={`welcome-screen-menu-item ${className}`} className={`welcome-screen-menu-item ${className}`}
href={href} href={href}
target="_blank" target="_blank"
rel="noreferrer" rel="noopener"
> >
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}> <WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
{children} {children}
+2 -2
View File
@@ -29,6 +29,7 @@ import { bumpVersion } from "@excalidraw/element/mutateElement";
import { getContainerElement } from "@excalidraw/element/textElement"; import { getContainerElement } from "@excalidraw/element/textElement";
import { detectLineHeight } from "@excalidraw/element/textMeasurements"; import { detectLineHeight } from "@excalidraw/element/textMeasurements";
import { import {
isArrowBoundToElement,
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
@@ -594,8 +595,7 @@ export const restoreElements = (
return restoredElements.map((element) => { return restoredElements.map((element) => {
if ( if (
isElbowArrow(element) && isElbowArrow(element) &&
element.startBinding == null && !isArrowBoundToElement(element) &&
element.endBinding == null &&
!validateElbowPoints(element.points) !validateElbowPoints(element.points)
) { ) {
return { return {
+16 -11
View File
@@ -38,10 +38,13 @@ import { redrawTextBoundingBox } from "@excalidraw/element/textElement";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { getCommonBounds } from "@excalidraw/element/bounds";
import Scene from "@excalidraw/element/Scene";
import type { ElementConstructorOpts } from "@excalidraw/element/newElement"; import type { ElementConstructorOpts } from "@excalidraw/element/newElement";
import type { import type {
ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
@@ -63,8 +66,6 @@ import type {
import type { MarkOptional } from "@excalidraw/common/utility-types"; import type { MarkOptional } from "@excalidraw/common/utility-types";
import { getCommonBounds } from "..";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
x: number; x: number;
@@ -221,7 +222,7 @@ const DEFAULT_DIMENSION = 100;
const bindTextToContainer = ( const bindTextToContainer = (
container: ExcalidrawElement, container: ExcalidrawElement,
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">, textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
elementsMap: ElementsMap, scene: Scene,
) => { ) => {
const textElement: ExcalidrawTextElement = newTextElement({ const textElement: ExcalidrawTextElement = newTextElement({
x: 0, x: 0,
@@ -240,7 +241,8 @@ const bindTextToContainer = (
}), }),
}); });
redrawTextBoundingBox(textElement, container, elementsMap); redrawTextBoundingBox(textElement, container, scene);
return [container, textElement] as const; return [container, textElement] as const;
}; };
@@ -249,7 +251,7 @@ const bindLinearElementToElement = (
start: ValidLinearElement["start"], start: ValidLinearElement["start"],
end: ValidLinearElement["end"], end: ValidLinearElement["end"],
elementStore: ElementStore, elementStore: ElementStore,
elementsMap: NonDeletedSceneElementsMap, scene: Scene,
): { ): {
linearElement: ExcalidrawLinearElement; linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement; startBoundElement?: ExcalidrawElement;
@@ -335,7 +337,7 @@ const bindLinearElementToElement = (
linearElement, linearElement,
startBoundElement as ExcalidrawBindableElement, startBoundElement as ExcalidrawBindableElement,
"start", "start",
elementsMap, scene,
); );
} }
} }
@@ -410,7 +412,7 @@ const bindLinearElementToElement = (
linearElement, linearElement,
endBoundElement as ExcalidrawBindableElement, endBoundElement as ExcalidrawBindableElement,
"end", "end",
elementsMap, scene,
); );
} }
} }
@@ -651,6 +653,9 @@ export const convertToExcalidrawElements = (
} }
const elementsMap = elementStore.getElementsMap(); const elementsMap = elementStore.getElementsMap();
// we don't have a real scene, so we just use a temp scene to query and mutate elements
const scene = new Scene(elementsMap);
// Add labels and arrow bindings // Add labels and arrow bindings
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
const excalidrawElement = elementStore.getElement(id)!; const excalidrawElement = elementStore.getElement(id)!;
@@ -664,7 +669,7 @@ export const convertToExcalidrawElements = (
let [container, text] = bindTextToContainer( let [container, text] = bindTextToContainer(
excalidrawElement, excalidrawElement,
element?.label, element?.label,
elementsMap, scene,
); );
elementStore.add(container); elementStore.add(container);
elementStore.add(text); elementStore.add(text);
@@ -692,7 +697,7 @@ export const convertToExcalidrawElements = (
originalStart, originalStart,
originalEnd, originalEnd,
elementStore, elementStore,
elementsMap, scene,
); );
container = linearElement; container = linearElement;
elementStore.add(linearElement); elementStore.add(linearElement);
@@ -717,7 +722,7 @@ export const convertToExcalidrawElements = (
start, start,
end, end,
elementStore, elementStore,
elementsMap, scene,
); );
elementStore.add(linearElement); elementStore.add(linearElement);
+7 -2
View File
@@ -1,10 +1,15 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { atom, createStore, type PrimitiveAtom } from "jotai"; import {
atom,
createStore,
type PrimitiveAtom,
type WritableAtom,
} from "jotai";
import { createIsolation } from "jotai-scope"; import { createIsolation } from "jotai-scope";
const jotai = createIsolation(); const jotai = createIsolation();
export { atom, PrimitiveAtom }; export { atom, PrimitiveAtom, WritableAtom };
export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai; export const { useAtom, useSetAtom, useAtomValue, useStore } = jotai;
export const EditorJotaiProvider: ReturnType< export const EditorJotaiProvider: ReturnType<
typeof createIsolation typeof createIsolation
+2 -2
View File
@@ -28,6 +28,8 @@ import type {
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ValueOf } from "@excalidraw/common/utility-types";
import type Scene from "@excalidraw/element/Scene";
import { CascadiaFontFaces } from "./Cascadia"; import { CascadiaFontFaces } from "./Cascadia";
import { ComicShannsFontFaces } from "./ComicShanns"; import { ComicShannsFontFaces } from "./ComicShanns";
import { EmojiFontFaces } from "./Emoji"; import { EmojiFontFaces } from "./Emoji";
@@ -40,8 +42,6 @@ import { NunitoFontFaces } from "./Nunito";
import { VirgilFontFaces } from "./Virgil"; import { VirgilFontFaces } from "./Virgil";
import { XiaolaiFontFaces } from "./Xiaolai"; import { XiaolaiFontFaces } from "./Xiaolai";
import type Scene from "../scene/Scene";
export class Fonts { export class Fonts {
// it's ok to track fonts across multiple instances only once, so let's use // it's ok to track fonts across multiple instances only once, so let's use
// a static member to reduce memory footprint // a static member to reduce memory footprint
+1
View File
@@ -149,6 +149,7 @@ export class LassoTrail extends AnimatedTrail {
this.app.scene.getNonDeletedElement( this.app.scene.getNonDeletedElement(
selectedIds[0], selectedIds[0],
) as NonDeleted<ExcalidrawLinearElement>, ) as NonDeleted<ExcalidrawLinearElement>,
this.app.scene.getNonDeletedElementsMap(),
) )
: null, : null,
}; };
+5 -2
View File
@@ -165,7 +165,9 @@
"unCroppedDimension": "Uncropped dimension", "unCroppedDimension": "Uncropped dimension",
"copyElementLink": "Copy link to object", "copyElementLink": "Copy link to object",
"linkToElement": "Link to object", "linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame" "wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab",
"shapeSwitch": "Switch shape"
}, },
"elementLink": { "elementLink": {
"title": "Link to object", "title": "Link to object",
@@ -296,7 +298,8 @@
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools", "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw" "mermaidToExcalidraw": "Mermaid to Excalidraw",
"convertElementType": "Toggle shape type"
}, },
"element": { "element": {
"rectangle": "Rectangle", "rectangle": "Rectangle",
+421 -2
View File
@@ -1,7 +1,30 @@
import { THEME, THEME_FILTER } from "@excalidraw/common"; import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element/binding";
import { getDiamondPoints } from "@excalidraw/element/bounds";
import { getCornerRadius } from "@excalidraw/element/shapes";
import {
bezierEquation,
curve,
curveTangent,
type GlobalPoint,
pointFrom,
pointFromVector,
pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import type {
ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement,
} from "@excalidraw/element/types";
import type { StaticCanvasRenderConfig } from "../scene/types"; import type { StaticCanvasRenderConfig } from "../scene/types";
import type { StaticCanvasAppState, AppState } from "../types"; import type { AppState, StaticCanvasAppState } from "../types";
export const fillCircle = ( export const fillCircle = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@@ -72,3 +95,399 @@ export const bootstrapCanvas = ({
return context; return context;
}; };
function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
}
}
}
function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
segments = 20,
) {
ctx.lineTo(points[0][0], points[0][1]);
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
}
}
}
export const drawHighlightForRectWithRotation = (
context: CanvasRenderingContext2D,
element: ExcalidrawRectanguloidElement,
padding: number,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (radius === 0) {
radius = 0.01;
}
context.beginPath();
{
const topLeftApprox = offsetQuadraticBezier(
pointFrom(0, 0 + radius),
pointFrom(0, 0),
pointFrom(0 + radius, 0),
padding,
);
const topRightApprox = offsetQuadraticBezier(
pointFrom(element.width - radius, 0),
pointFrom(element.width, 0),
pointFrom(element.width, radius),
padding,
);
const bottomRightApprox = offsetQuadraticBezier(
pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height),
padding,
);
const bottomLeftApprox = offsetQuadraticBezier(
pointFrom(radius, element.height),
pointFrom(0, element.height),
pointFrom(0, element.height - radius),
padding,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const topLeftApprox = offsetQuadraticBezier(
pointFrom(0 + radius, 0),
pointFrom(0, 0),
pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE,
);
const topRightApprox = offsetQuadraticBezier(
pointFrom(element.width, radius),
pointFrom(element.width, 0),
pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE,
);
const bottomRightApprox = offsetQuadraticBezier(
pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE,
);
const bottomLeftApprox = offsetQuadraticBezier(
pointFrom(0, element.height - radius),
pointFrom(0, element.height),
pointFrom(radius, element.height),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
context.closePath();
context.fill();
context.restore();
};
export const strokeEllipseWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
export const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
if (fill) {
context.fillRect(x - cx, y - cy, width, height);
}
if (radius && context.roundRect) {
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore();
};
export const drawHighlightForDiamondWithRotation = (
context: CanvasRenderingContext2D,
padding: number,
element: ExcalidrawDiamondElement,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
{
context.beginPath();
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
padding,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
padding,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
padding,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
padding,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const rightApprox = offsetCubicBezier(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const bottomApprox = offsetCubicBezier(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
const leftApprox = offsetCubicBezier(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(leftApprox[0][0], leftApprox[0][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
context.closePath();
context.fill();
context.restore();
};
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
+21 -107
View File
@@ -1,23 +1,22 @@
import oc from "open-color";
import { import {
pointFrom, pointFrom,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type Radians, type Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import oc from "open-color";
import { import {
arrayToMap,
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE, FRAME_STYLE,
THEME,
arrayToMap,
invariant, invariant,
THEME,
throttleRAF, throttleRAF,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
BINDING_HIGHLIGHT_OFFSET, FIXED_BINDING_DISTANCE,
BINDING_HIGHLIGHT_THICKNESS,
maxBindingGap, maxBindingGap,
} from "@excalidraw/element/binding"; } from "@excalidraw/element/binding";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
@@ -35,14 +34,12 @@ import {
isTextElement, isTextElement,
} from "@excalidraw/element/typeChecks"; } from "@excalidraw/element/typeChecks";
import { getCornerRadius } from "@excalidraw/element/shapes";
import { renderSelectionElement } from "@excalidraw/element/renderElement"; import { renderSelectionElement } from "@excalidraw/element/renderElement";
import { import {
isSelectedViaGroup,
getSelectedGroupIds,
getElementsInGroup, getElementsInGroup,
getSelectedGroupIds,
isSelectedViaGroup,
selectGroupsFromGivenElements, selectGroupsFromGivenElements,
} from "@excalidraw/element/groups"; } from "@excalidraw/element/groups";
@@ -86,8 +83,12 @@ import { getClientColor, renderRemoteCursors } from "../clients";
import { import {
bootstrapCanvas, bootstrapCanvas,
drawHighlightForDiamondWithRotation,
drawHighlightForRectWithRotation,
fillCircle, fillCircle,
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
strokeEllipseWithRotation,
strokeRectWithRotation,
} from "./helpers"; } from "./helpers";
import type { import type {
@@ -160,57 +161,6 @@ const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
); );
}; };
const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
fill: boolean = false,
/** should account for zoom */
radius: number = 0,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
if (fill) {
context.fillRect(x - cx, y - cy, width, height);
}
if (radius && context.roundRect) {
context.beginPath();
context.roundRect(x - cx, y - cy, width, height, radius);
context.stroke();
context.closePath();
} else {
context.strokeRect(x - cx, y - cy, width, height);
}
context.restore();
};
const strokeDiamondWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.save();
context.translate(cx, cy);
context.rotate(angle);
context.beginPath();
context.moveTo(0, height / 2);
context.lineTo(width / 2, 0);
context.lineTo(0, -height / 2);
context.lineTo(-width / 2, 0);
context.closePath();
context.stroke();
context.restore();
};
const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>( const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
@@ -237,19 +187,6 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
); );
}; };
const strokeEllipseWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
const renderBindingHighlightForBindableElement = ( const renderBindingHighlightForBindableElement = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
@@ -261,16 +198,10 @@ const renderBindingHighlightForBindableElement = (
const height = y2 - y1; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)"; context.strokeStyle = "rgba(0,0,0,.05)";
// When zooming out, make line width greater for visibility context.fillStyle = "rgba(0,0,0,.05)";
const zoomValue = zoom.value < 1 ? zoom.value : 1;
context.lineWidth = BINDING_HIGHLIGHT_THICKNESS / zoomValue;
// To ensure the binding highlight doesn't overlap the element itself
const padding = context.lineWidth / 2 + BINDING_HIGHLIGHT_OFFSET;
const radius = getCornerRadius( // To ensure the binding highlight doesn't overlap the element itself
Math.min(element.width, element.height), const padding = maxBindingGap(element, element.width, element.height, zoom);
element,
);
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
@@ -280,37 +211,20 @@ const renderBindingHighlightForBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
strokeRectWithRotation( drawHighlightForRectWithRotation(context, element, padding);
context,
x1 - padding,
y1 - padding,
width + padding * 2,
height + padding * 2,
x1 + width / 2,
y1 + height / 2,
element.angle,
undefined,
radius,
);
break; break;
case "diamond": case "diamond":
const side = Math.hypot(width, height); drawHighlightForDiamondWithRotation(context, padding, element);
const wPadding = (padding * side) / height;
const hPadding = (padding * side) / width;
strokeDiamondWithRotation(
context,
width + wPadding * 2,
height + hPadding * 2,
x1 + width / 2,
y1 + height / 2,
element.angle,
);
break; break;
case "ellipse": case "ellipse":
context.lineWidth =
maxBindingGap(element, element.width, element.height, zoom) -
FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,
width + padding * 2, width + padding + FIXED_BINDING_DISTANCE,
height + padding * 2, height + padding + FIXED_BINDING_DISTANCE,
x1 + width / 2, x1 + width / 2,
y1 + height / 2, y1 + height / 2,
element.angle, element.angle,
+2 -1
View File
@@ -9,10 +9,11 @@ import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/element/Scene";
import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
import { renderStaticSceneThrottled } from "../renderer/staticScene"; import { renderStaticSceneThrottled } from "../renderer/staticScene";
import type Scene from "./Scene";
import type { RenderableElementsMap } from "./types"; import type { RenderableElementsMap } from "./types";
import type { AppState } from "../types"; import type { AppState } from "../types";
@@ -21,7 +21,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<a <a
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="blog.excalidaw.com" href="blog.excalidaw.com"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
> >
<div <div
@@ -392,7 +392,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="GitHub" aria-label="GitHub"
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="https://github.com/excalidraw/excalidraw" href="https://github.com/excalidraw/excalidraw"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
title="GitHub" title="GitHub"
> >
@@ -426,7 +426,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="X" aria-label="X"
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="https://x.com/excalidraw" href="https://x.com/excalidraw"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
title="X" title="X"
> >
@@ -472,7 +472,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-label="Discord" aria-label="Discord"
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
href="https://discord.gg/UexuTaE" href="https://discord.gg/UexuTaE"
rel="noreferrer" rel="noopener"
target="_blank" target="_blank"
title="Discord" title="Discord"
> >
@@ -171,7 +171,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 19, "version": 9,
"width": 100, "width": 100,
"x": 100, "x": 100,
"y": -50, "y": -50,
@@ -198,7 +198,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "102.35417", "height": "102.45605",
"id": "id172", "id": "id172",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -212,8 +212,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"101.77517", "102.80179",
"102.35417", "102.45605",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -227,9 +227,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 40, "version": 37,
"width": "101.77517", "width": "102.80179",
"x": "0.70711", "x": "-0.42182",
"y": 0, "y": 0,
} }
`; `;
@@ -264,7 +264,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 6, "version": 14,
"width": 50, "width": 50,
"x": 100, "x": 100,
"y": 100, "y": 100,
@@ -291,22 +291,39 @@ History {
"added": Map {}, "added": Map {},
"removed": Map {}, "removed": Map {},
"updated": Map { "updated": Map {
"id171" => Delta {
"deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [
{
"id": "id172",
"type": "arrow",
},
],
},
},
"id172" => Delta { "id172" => Delta {
"deleted": { "deleted": {
"endBinding": { "endBinding": {
"elementId": "id171", "elementId": "id175",
"focus": "0.00990", "fixedPoint": [
"0.50000",
1,
],
"focus": 0,
"gap": 1, "gap": 1,
}, },
"height": "0.98586", "height": "70.45017",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.58579", "100.70774",
"-0.98586", "70.45017",
], ],
], ],
"startBinding": { "startBinding": {
@@ -321,7 +338,7 @@ History {
"focus": "-0.02000", "focus": "-0.02000",
"gap": 1, "gap": 1,
}, },
"height": "0.00000", "height": "0.09250",
"points": [ "points": [
[ [
0, 0,
@@ -329,7 +346,7 @@ History {
], ],
[ [
"98.58579", "98.58579",
"0.00000", "0.09250",
], ],
], ],
"startBinding": { "startBinding": {
@@ -339,6 +356,19 @@ History {
}, },
}, },
}, },
"id175" => Delta {
"deleted": {
"boundElements": [
{
"id": "id172",
"type": "arrow",
},
],
},
"inserted": {
"boundElements": [],
},
},
}, },
}, },
}, },
@@ -366,59 +396,32 @@ History {
], ],
}, },
}, },
"id171" => Delta {
"deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [
{
"id": "id172",
"type": "arrow",
},
],
},
},
"id172" => Delta { "id172" => Delta {
"deleted": { "deleted": {
"endBinding": { "height": "102.45584",
"elementId": "id175",
"fixedPoint": [
"0.50000",
1,
],
"focus": 0,
"gap": 1,
},
"height": "102.35417",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"101.77517", "102.79971",
"102.35417", "102.45584",
], ],
], ],
"startBinding": null, "startBinding": null,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
"endBinding": { "height": "70.33521",
"elementId": "id171",
"focus": "0.00990",
"gap": 1,
},
"height": "0.98586",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"98.58579", "100.78887",
"-0.98586", "70.33521",
], ],
], ],
"startBinding": { "startBinding": {
@@ -426,20 +429,7 @@ History {
"focus": "0.02970", "focus": "0.02970",
"gap": 1, "gap": 1,
}, },
"y": "0.99364", "y": "35.20327",
},
},
"id175" => Delta {
"deleted": {
"boundElements": [
{
"id": "id172",
"type": "arrow",
},
],
},
"inserted": {
"boundElements": [],
}, },
}, },
}, },
@@ -739,7 +729,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 9, "version": 19,
"width": 100, "width": 100,
"x": 150, "x": 150,
"y": -50, "y": -50,
@@ -819,8 +809,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 30, "version": 33,
"width": 0, "width": 100,
"x": "149.29289", "x": "149.29289",
"y": 0, "y": 0,
} }
@@ -846,20 +836,22 @@ History {
"added": Map {}, "added": Map {},
"removed": Map {}, "removed": Map {},
"updated": Map { "updated": Map {
"id167" => Delta { "id166" => Delta {
"deleted": { "deleted": {
"points": [ "boundElements": [],
[
0,
0,
],
[
0,
0,
],
],
}, },
"inserted": { "inserted": {
"boundElements": [
{
"id": "id167",
"type": "arrow",
},
],
},
},
"id167" => Delta {
"deleted": {
"endBinding": null,
"points": [ "points": [
[ [
0, 0,
@@ -871,6 +863,23 @@ History {
], ],
], ],
}, },
"inserted": {
"endBinding": {
"elementId": "id166",
"focus": -0,
"gap": 1,
},
"points": [
[
0,
0,
],
[
0,
0,
],
],
},
}, },
}, },
}, },
@@ -899,22 +908,8 @@ History {
], ],
}, },
}, },
"id166" => Delta {
"deleted": {
"boundElements": [],
},
"inserted": {
"boundElements": [
{
"id": "id167",
"type": "arrow",
},
],
},
},
"id167" => Delta { "id167" => Delta {
"deleted": { "deleted": {
"endBinding": null,
"points": [ "points": [
[ [
0, 0,
@@ -928,18 +923,13 @@ History {
"startBinding": null, "startBinding": null,
}, },
"inserted": { "inserted": {
"endBinding": {
"elementId": "id166",
"focus": -0,
"gap": 1,
},
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
0, 100,
0, 0,
], ],
], ],
@@ -7490,7 +7480,7 @@ History {
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `10`; exports[`history > multiplayer undo/redo > should iterate through the history when selected or editing linear element was remotely deleted > [end of test] number of renders 1`] = `9`;
exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = ` exports[`history > multiplayer undo/redo > should iterate through the history when when element change relates to remotely deleted element > [end of test] appState 1`] = `
{ {
@@ -10561,7 +10551,7 @@ History {
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of elements 1`] = `1`;
exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `15`; exports[`history > multiplayer undo/redo > should override remotely added points on undo, but restore them on redo > [end of test] number of renders 1`] = `14`;
exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = ` exports[`history > multiplayer undo/redo > should redistribute deltas when element gets removed locally but is restored remotely > [end of test] appState 1`] = `
{ {
@@ -20188,4 +20178,4 @@ History {
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of elements 1`] = `1`;
exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `21`; exports[`history > singleplayer undo/redo > should support linear element creation and points manipulation through the editor > [end of test] number of renders 1`] = `20`;
@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 1604849351, "versionNonce": 400692809,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@@ -106,7 +106,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 1604849351, "versionNonce": 400692809,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@@ -6832,7 +6832,7 @@ History {
exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`; exports[`regression tests > draw every type of shape > [end of test] number of elements 1`] = `0`;
exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `33`; exports[`regression tests > draw every type of shape > [end of test] number of renders 1`] = `31`;
exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = ` exports[`regression tests > given a group of selected elements with an element that is not selected inside the group common bounding box when element that is not selected is clicked should switch selection to not selected element on pointer up > [end of test] appState 1`] = `
{ {
@@ -14550,7 +14550,7 @@ History {
exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`; exports[`regression tests > undo/redo drawing an element > [end of test] number of elements 1`] = `0`;
exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `20`; exports[`regression tests > undo/redo drawing an element > [end of test] number of renders 1`] = `19`;
exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = ` exports[`regression tests > updates fontSize & fontFamily appState > [end of test] appState 1`] = `
{ {
@@ -313,7 +313,7 @@ describe("Test dragCreate", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `6`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -342,7 +342,7 @@ describe("Test dragCreate", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `6`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0); expect(h.elements.length).toEqual(0);
}); });
@@ -1,7 +1,5 @@
import React from "react"; import React from "react";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { actionSelectAll } from "../actions"; import { actionSelectAll } from "../actions";
@@ -298,7 +296,7 @@ describe("element locking", () => {
height: textSize, height: textSize,
containerId: container.id, containerId: container.id,
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }], boundElements: [{ id: text.id, type: "text" }],
}); });
@@ -339,7 +337,7 @@ describe("element locking", () => {
containerId: container.id, containerId: container.id,
locked: true, locked: true,
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }], boundElements: [{ id: text.id, type: "text" }],
}); });
API.setElements([container, text]); API.setElements([container, text]);
@@ -373,7 +371,7 @@ describe("element locking", () => {
containerId: container.id, containerId: container.id,
locked: true, locked: true,
}); });
mutateElement(container, { h.app.scene.mutateElement(container, {
boundElements: [{ id: text.id, type: "text" }], boundElements: [{ id: text.id, type: "text" }],
}); });
API.setElements([container, text]); API.setElements([container, text]);
+4 -7
View File
@@ -6,7 +6,6 @@ import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
newArrowElement, newArrowElement,
newElement, newElement,
@@ -100,10 +99,10 @@ export class API {
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
static updateElement = <T extends ExcalidrawElement>( static updateElement = <T extends ExcalidrawElement>(
...args: Parameters<typeof mutateElement<T>> ...args: Parameters<typeof h.app.scene.mutateElement<T>>
) => { ) => {
act(() => { act(() => {
mutateElement<T>(...args); h.app.scene.mutateElement(...args);
}); });
}; };
@@ -419,12 +418,11 @@ export class API {
}); });
mutateElement( h.app.scene.mutateElement(
rectangle, rectangle,
{ {
boundElements: [{ type: "text", id: text.id }], boundElements: [{ type: "text", id: text.id }],
}, },
false,
); );
return [rectangle, text]; return [rectangle, text];
@@ -453,12 +451,11 @@ export class API {
: opts?.label?.frameId ?? null, : opts?.label?.frameId ?? null,
}); });
mutateElement( h.app.scene.mutateElement(
arrow, arrow,
{ {
boundElements: [{ type: "text", id: text.id }], boundElements: [{ type: "text", id: text.id }],
}, },
false,
); );
return [arrow, text]; return [arrow, text];
+1 -2
View File
@@ -5,7 +5,6 @@ import {
getElementPointsCoords, getElementPointsCoords,
} from "@excalidraw/element/bounds"; } from "@excalidraw/element/bounds";
import { cropElement } from "@excalidraw/element/cropElement"; import { cropElement } from "@excalidraw/element/cropElement";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { import {
getTransformHandles, getTransformHandles,
getTransformHandlesFromCoords, getTransformHandlesFromCoords,
@@ -526,7 +525,7 @@ export class UI {
if (angle !== 0) { if (angle !== 0) {
act(() => { act(() => {
mutateElement(origElement, { angle }); h.app.scene.mutateElement(origElement, { angle });
}); });
} }
@@ -30,7 +30,7 @@ import type {
FontString, FontString,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { Excalidraw, mutateElement } from "../index"; import { Excalidraw } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene"; import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene"; import * as StaticScene from "../renderer/staticScene";
import { API } from "../tests/helpers/api"; import { API } from "../tests/helpers/api";
@@ -118,7 +118,7 @@ describe("Test Linear Elements", () => {
], ],
roundness, roundness,
}); });
mutateElement(line, { points: line.points }); h.app.scene.mutateElement(line, { points: line.points });
API.setElements([line]); API.setElements([line]);
mouse.clickAt(p1[0], p1[1]); mouse.clickAt(p1[0], p1[1]);
return line; return line;
@@ -177,7 +177,7 @@ describe("Test Linear Elements", () => {
pointFrom<LocalPoint>(0.5, 0), pointFrom<LocalPoint>(0.5, 0),
pointFrom<LocalPoint>(100, 100), pointFrom<LocalPoint>(100, 100),
]); ]);
new LinearElementEditor(element); new LinearElementEditor(element, arrayToMap(h.elements));
expect(element.points).toEqual([ expect(element.points).toEqual([
pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(99.5, 100), pointFrom<LocalPoint>(99.5, 100),
@@ -1271,7 +1271,7 @@ describe("Test Linear Elements", () => {
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[0], h.elements[0],
arrayToMap(h.elements), h.app.scene,
"nw", "nw",
false, false,
); );
@@ -1384,7 +1384,7 @@ describe("Test Linear Elements", () => {
const [origStartX, origStartY] = [line.x, line.y]; const [origStartX, origStartY] = [line.x, line.y];
act(() => { act(() => {
LinearElementEditor.movePoints(line, [ LinearElementEditor.movePoints(line, h.app.scene, [
{ {
index: 0, index: 0,
point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10), point: pointFrom(line.points[0][0] + 10, line.points[0][1] + 10),
+1 -7
View File
@@ -13,8 +13,6 @@ import type {
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type Scene from "@excalidraw/excalidraw/scene/Scene";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as InteractiveCanvas from "../renderer/interactiveScene"; import * as InteractiveCanvas from "../renderer/interactiveScene";
import * as StaticScene from "../renderer/staticScene"; import * as StaticScene from "../renderer/staticScene";
@@ -85,15 +83,13 @@ describe("move element", () => {
const rectA = UI.createElement("rectangle", { size: 100 }); const rectA = UI.createElement("rectangle", { size: 100 });
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 }); const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
const elementsMap = h.app.scene.getNonDeletedElementsMap();
act(() => { act(() => {
// bind line to two rectangles // bind line to two rectangles
bindOrUnbindLinearElement( bindOrUnbindLinearElement(
arrow.get() as NonDeleted<ExcalidrawLinearElement>, arrow.get() as NonDeleted<ExcalidrawLinearElement>,
rectA.get() as ExcalidrawRectangleElement, rectA.get() as ExcalidrawRectangleElement,
rectB.get() as ExcalidrawRectangleElement, rectB.get() as ExcalidrawRectangleElement,
elementsMap, h.app.scene,
{} as Scene,
); );
}); });
@@ -170,8 +166,6 @@ describe("duplicate element on move when ALT is clicked", () => {
fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 }); fireEvent.pointerMove(canvas, { clientX: 10, clientY: 60 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
// TODO: This used to be 4, but binding made it go up to 5. Do we need
// that additional render?
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`4`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`4`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`3`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`3`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
@@ -119,7 +119,7 @@ describe("multi point mode in linear elements", () => {
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@@ -162,7 +162,7 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
+2
View File
@@ -714,6 +714,7 @@ export type AppClassProperties = {
excalidrawContainerValue: App["excalidrawContainerValue"]; excalidrawContainerValue: App["excalidrawContainerValue"];
onPointerUpEmitter: App["onPointerUpEmitter"]; onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -784,6 +785,7 @@ export type UnsubscribeCallback = () => void;
export interface ExcalidrawImperativeAPI { export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"]; updateScene: InstanceType<typeof App>["updateScene"];
mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"]; updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"]; resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType< getSceneElementsIncludingDeleted: InstanceType<
+15 -17
View File
@@ -15,7 +15,7 @@ import {
} from "@excalidraw/element/containerCache"; } from "@excalidraw/element/containerCache";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { bumpVersion, mutateElement } from "@excalidraw/element/mutateElement"; import { bumpVersion } from "@excalidraw/element/mutateElement";
import { import {
getBoundTextElementId, getBoundTextElementId,
getContainerElement, getContainerElement,
@@ -45,7 +45,6 @@ import type {
import { actionSaveToActiveFile } from "../actions"; import { actionSaveToActiveFile } from "../actions";
import Scene from "../scene/Scene";
import { parseClipboard } from "../clipboard"; import { parseClipboard } from "../clipboard";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
@@ -81,6 +80,8 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
id, id,
onChange, onChange,
@@ -107,7 +108,7 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
}) => { }): SubmitHandler => {
const textPropertiesUpdated = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@@ -130,8 +131,7 @@ export const textWysiwyg = ({
const updateWysiwygStyle = () => { const updateWysiwygStyle = () => {
const appState = app.state; const appState = app.state;
const updatedTextElement = const updatedTextElement = app.scene.getElement<ExcalidrawTextElement>(id);
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
if (!updatedTextElement) { if (!updatedTextElement) {
return; return;
@@ -188,7 +188,6 @@ export const textWysiwyg = ({
} }
maxWidth = getBoundTextMaxWidth(container, updatedTextElement); maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
maxHeight = getBoundTextMaxHeight( maxHeight = getBoundTextMaxHeight(
container, container,
updatedTextElement as ExcalidrawTextElementWithContainer, updatedTextElement as ExcalidrawTextElementWithContainer,
@@ -201,7 +200,7 @@ export const textWysiwyg = ({
container.type, container.type,
); );
mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, { height: targetContainerHeight });
return; return;
} else if ( } else if (
// autoshrink container height until original container height // autoshrink container height until original container height
@@ -214,7 +213,7 @@ export const textWysiwyg = ({
height, height,
container.type, container.type,
); );
mutateElement(container, { height: targetContainerHeight }); app.scene.mutateElement(container, { height: targetContainerHeight });
} else { } else {
const { y } = computeBoundTextPosition( const { y } = computeBoundTextPosition(
container, container,
@@ -287,7 +286,7 @@ export const textWysiwyg = ({
editable.style.fontFamily = getFontFamilyString(updatedTextElement); editable.style.fontFamily = getFontFamilyString(updatedTextElement);
} }
mutateElement(updatedTextElement, { x: coordX, y: coordY }); app.scene.mutateElement(updatedTextElement, { x: coordX, y: coordY });
} }
}; };
@@ -544,7 +543,7 @@ export const textWysiwyg = ({
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
// wysiwyg on update // wysiwyg on update
cleanup(); cleanup();
const updateElement = Scene.getScene(element)?.getElement( const updateElement = app.scene.getElement(
element.id, element.id,
) as ExcalidrawTextElement; ) as ExcalidrawTextElement;
if (!updateElement) { if (!updateElement) {
@@ -559,7 +558,7 @@ export const textWysiwyg = ({
if (editable.value.trim()) { if (editable.value.trim()) {
const boundTextElementId = getBoundTextElementId(container); const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId || boundTextElementId !== element.id) { if (!boundTextElementId || boundTextElementId !== element.id) {
mutateElement(container, { app.scene.mutateElement(container, {
boundElements: (container.boundElements || []).concat({ boundElements: (container.boundElements || []).concat({
type: "text", type: "text",
id: element.id, id: element.id,
@@ -570,7 +569,7 @@ export const textWysiwyg = ({
bumpVersion(container); bumpVersion(container);
} }
} else { } else {
mutateElement(container, { app.scene.mutateElement(container, {
boundElements: container.boundElements?.filter( boundElements: container.boundElements?.filter(
(ele) => (ele) =>
!isTextElement( !isTextElement(
@@ -579,11 +578,8 @@ export const textWysiwyg = ({
), ),
}); });
} }
redrawTextBoundingBox(
updateElement, redrawTextBoundingBox(updateElement, container, app.scene);
container,
app.scene.getNonDeletedElementsMap(),
);
} }
onSubmit({ onSubmit({
@@ -740,4 +736,6 @@ export const textWysiwyg = ({
excalidrawContainer excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")! ?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable); .appendChild(editable);
return handleSubmit;
}; };
+22 -1
View File
@@ -2,6 +2,7 @@ import type { Bounds } from "@excalidraw/element/bounds";
import { isPoint, pointDistance, pointFrom } from "./point"; import { isPoint, pointDistance, pointFrom } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
import { vector } from "./vector";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@@ -82,7 +83,7 @@ function solve(
return [t0, s0]; return [t0, s0];
} }
const bezierEquation = <Point extends GlobalPoint | LocalPoint>( export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>, c: Curve<Point>,
t: number, t: number,
) => ) =>
@@ -274,6 +275,26 @@ export function isCurve<P extends GlobalPoint | LocalPoint>(
); );
} }
export function curveTangent<Point extends GlobalPoint | LocalPoint>(
[p0, p1, p2, p3]: Curve<Point>,
t: number,
) {
return vector(
-3 * (1 - t) * (1 - t) * p0[0] +
3 * (1 - t) * (1 - t) * p1[0] -
6 * t * (1 - t) * p1[0] -
3 * t * t * p2[0] +
6 * t * (1 - t) * p2[0] +
3 * t * t * p3[0],
-3 * (1 - t) * (1 - t) * p0[1] +
3 * (1 - t) * (1 - t) * p1[1] -
6 * t * (1 - t) * p1[1] -
3 * t * t * p2[1] +
6 * t * (1 - t) * p2[1] +
3 * t * t * p3[1],
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>( function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>, c: Curve<Point>,
): Bounds { ): Bounds {
+5
View File
@@ -143,3 +143,8 @@ export const vectorNormalize = (v: Vector): Vector => {
return vector(v[0] / m, v[1] / m); return vector(v[0] / m, v[1] / m);
}; };
/**
* Calculate the right-hand normal of the vector.
*/
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);