Merge branch 'master' into mrazator/delta-based-sync

This commit is contained in:
Marcel Mraz 2025-04-23 14:33:43 +02:00
commit d2038b7c5a
No known key found for this signature in database
GPG key ID: 4EBD6E62DC830CD2
140 changed files with 5343 additions and 2292 deletions

View file

@ -48,3 +48,6 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB' HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=

View file

@ -2,7 +2,7 @@
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer. Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node. You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
**Usage** **Usage**
@ -25,7 +25,7 @@ function App() {
} }
``` ```
This will only for `Desktop` devices. This will only work for `Desktop` devices.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
@ -65,4 +65,4 @@ const App = () => (
// Need to render when code is span across multiple components // Need to render when code is span across multiple components
// in Live Code blocks editor // in Live Code blocks editor
render(<App />); render(<App />);
``` ```

View file

@ -31,6 +31,7 @@ All `props` are _optional_.
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas | | [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation | | [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` | | [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
### Storing custom data on Excalidraw elements ### Storing custom data on Excalidraw elements

View file

@ -104,6 +104,7 @@ export default function ExampleApp({
const [viewModeEnabled, setViewModeEnabled] = useState(false); const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false); const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>(""); const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>(""); const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
@ -192,6 +193,7 @@ export default function ExampleApp({
}) => setPointerData(payload), }) => setPointerData(payload),
viewModeEnabled, viewModeEnabled,
zenModeEnabled, zenModeEnabled,
renderScrollbars,
gridModeEnabled, gridModeEnabled,
theme, theme,
name: "Custom name of drawing", name: "Custom name of drawing",
@ -710,6 +712,14 @@ export default function ExampleApp({
/> />
Grid mode Grid mode
</label> </label>
<label>
<input
type="checkbox"
checked={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</label>
<label> <label>
<input <input
type="checkbox" type="checkbox"

View file

@ -15,7 +15,8 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"build:preview": "yarn build && vite preview --port 5002", "preview": "vite preview --port 5002",
"build:preview": "yarn build && yarn preview",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm" "build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
} }
} }

View file

@ -645,7 +645,13 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(), excalidrawAPI.getSceneElements(),
) )
) { ) {
preventUnload(event); if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
} }
}; };
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler); window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);

View file

@ -310,7 +310,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay // the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements); this.saveCollabRoomToFirebase(syncableElements);
preventUnload(event); if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
} }
}); });

View file

@ -25,7 +25,10 @@ export default defineConfig(({ mode }) => {
alias: [ alias: [
{ {
find: /^@excalidraw\/common$/, find: /^@excalidraw\/common$/,
replacement: path.resolve(__dirname, "../packages/common/src/index.ts"), replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
}, },
{ {
find: /^@excalidraw\/common\/(.*?)/, find: /^@excalidraw\/common\/(.*?)/,
@ -33,7 +36,10 @@ export default defineConfig(({ mode }) => {
}, },
{ {
find: /^@excalidraw\/element$/, find: /^@excalidraw\/element$/,
replacement: path.resolve(__dirname, "../packages/element/src/index.ts"), replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
}, },
{ {
find: /^@excalidraw\/element\/(.*?)/, find: /^@excalidraw\/element\/(.*?)/,
@ -41,7 +47,10 @@ export default defineConfig(({ mode }) => {
}, },
{ {
find: /^@excalidraw\/excalidraw$/, find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"), replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
}, },
{ {
find: /^@excalidraw\/excalidraw\/(.*?)/, find: /^@excalidraw\/excalidraw\/(.*?)/,
@ -57,7 +66,10 @@ export default defineConfig(({ mode }) => {
}, },
{ {
find: /^@excalidraw\/utils$/, find: /^@excalidraw\/utils$/,
replacement: path.resolve(__dirname, "../packages/utils/src/index.ts"), replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
}, },
{ {
find: /^@excalidraw\/utils\/(.*?)/, find: /^@excalidraw\/utils\/(.*?)/,
@ -213,7 +225,7 @@ export default defineConfig(({ mode }) => {
}, },
], ],
start_url: "/", start_url: "/",
id:"excalidraw", id: "excalidraw",
display: "standalone", display: "standalone",
theme_color: "#121212", theme_color: "#121212",
background_color: "#ffffff", background_color: "#ffffff",

View file

@ -2,6 +2,8 @@ import oc from "open-color";
import type { Merge } from "./utility-types"; import type { Merge } from "./utility-types";
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
source: R, source: R,

View file

@ -112,6 +112,7 @@ export const YOUTUBE_STATES = {
export const ENV = { export const ENV = {
TEST: "test", TEST: "test",
DEVELOPMENT: "development", DEVELOPMENT: "development",
PRODUCTION: "production",
}; };
export const CLASSES = { export const CLASSES = {
@ -318,6 +319,9 @@ export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024; export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
export const SVG_NS = "http://www.w3.org/2000/svg"; export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`;
export const ENCRYPTION_KEY_BITS = 128; export const ENCRYPTION_KEY_BITS = 128;
@ -419,6 +423,7 @@ export const LIBRARY_DISABLED_TYPES = new Set([
// use these constants to easily identify reference sites // use these constants to easily identify reference sites
export const TOOL_TYPE = { export const TOOL_TYPE = {
selection: "selection", selection: "selection",
lasso: "lasso",
rectangle: "rectangle", rectangle: "rectangle",
diamond: "diamond", diamond: "diamond",
ellipse: "ellipse", ellipse: "ellipse",

View file

@ -1,9 +1,10 @@
import { average } from "@excalidraw/math"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@ -385,7 +386,7 @@ export const updateActiveTool = (
type: ToolType; type: ToolType;
} }
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }) & { ) & { locked?: boolean; fromSelection?: boolean }) & {
lastActiveToolBeforeEraser?: ActiveTool | null; lastActiveToolBeforeEraser?: ActiveTool | null;
}, },
): AppState["activeTool"] => { ): AppState["activeTool"] => {
@ -407,6 +408,7 @@ export const updateActiveTool = (
type: data.type, type: data.type,
customType: null, customType: null,
locked: data.locked ?? appState.activeTool.locked, locked: data.locked ?? appState.activeTool.locked,
fromSelection: data.fromSelection ?? false,
}; };
}; };
@ -678,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 }>(
@ -737,6 +739,8 @@ export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT; export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
export const isProdEnv = () => import.meta.env.MODE === ENV.PRODUCTION;
export const isServerEnv = () => export const isServerEnv = () =>
typeof process !== "undefined" && !!process?.env?.NODE_ENV; typeof process !== "undefined" && !!process?.env?.NODE_ENV;
@ -1200,3 +1204,32 @@ export const escapeDoubleQuotes = (str: string) => {
export const castArray = <T>(value: T | T[]): T[] => export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value]; Array.isArray(value) ? value : [value];
export const elementCenterPoint = (
element: ExcalidrawElement,
xOffset: number = 0,
yOffset: number = 0,
) => {
const { x, y, width, height } = element;
const centerXPoint = x + width / 2 + xOffset;
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
};
/** hack for Array.isArray type guard not working with readonly value[] */
export const isReadonlyArray = (value?: any): value is readonly any[] => {
return Array.isArray(value);
};
export const sizeOf = (
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
): number => {
return isReadonlyArray(value)
? value.length
: value instanceof Map
? value.size
: Object.keys(value).length;
};

View file

@ -6,12 +6,14 @@ import {
toBrandedType, toBrandedType,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
isReadonlyArray,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element"; import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks"; 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,
@ -19,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,
@ -32,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;
@ -102,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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -198,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"];
@ -292,24 +268,25 @@ class Scene {
} }
replaceAllElements(nextElements: ElementsMapOrArray) { replaceAllElements(nextElements: ElementsMapOrArray) {
const _nextElements = // ts doesn't like `Array.isArray` of `instanceof Map`
// ts doesn't like `Array.isArray` of `instanceof Map` if (!isReadonlyArray(nextElements)) {
nextElements instanceof Array // need to order by fractional indices to get the correct order
? nextElements nextElements = orderByFractionalIndex(
: Array.from(nextElements.values()); Array.from(nextElements.values()) as OrderedExcalidrawElement[],
);
}
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements); validateIndicesThrottled(nextElements);
// CFDO: if this leads to modifying the indices, it should update the snapshot immediately (as it shall be an non-undoable change) 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;
@ -354,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();
@ -456,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;

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;

View file

@ -6,6 +6,7 @@ import {
invariant, invariant,
isDevEnv, isDevEnv,
isTestEnv, isTestEnv,
elementCenterPoint,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@ -30,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";
@ -67,6 +66,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 {
@ -83,7 +84,6 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPoint, FixedPoint,
SceneElementsMap,
FixedPointBinding, FixedPointBinding,
} from "./types"; } from "./types";
@ -129,7 +129,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();
@ -141,7 +140,7 @@ export const bindOrUnbindLinearElement = (
"start", "start",
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, scene,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@ -150,7 +149,7 @@ export const bindOrUnbindLinearElement = (
"end", "end",
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
elementsMap, scene,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@ -158,7 +157,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,
@ -176,7 +175,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") {
@ -185,7 +184,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);
} }
@ -208,16 +207,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);
} }
}; };
@ -361,11 +355,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) => {
@ -375,20 +367,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);
}); });
}; };
@ -428,15 +420,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,
); );
} }
@ -457,7 +451,7 @@ export const maybeBindLinearElement = (
"end", "end",
) )
) { ) {
bindLinearElement(linearElement, hoveredElement, "end", elementsMap); bindLinearElement(linearElement, hoveredElement, "end", scene);
} }
} }
}; };
@ -486,7 +480,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;
@ -499,7 +493,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap, scene.getNonDeletedElementsMap(),
), ),
hoveredElement, hoveredElement,
), ),
@ -512,18 +506,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",
@ -565,13 +558,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;
}; };
@ -739,7 +733,7 @@ const calculateFocusAndGap = (
// in explicitly. // 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 };
@ -755,6 +749,8 @@ export const updateBoundElements = (
return; return;
} }
const elementsMap = scene.getNonDeletedElementsMap();
boundElementsVisitor(elementsMap, changedElement, (element) => { boundElementsVisitor(elementsMap, changedElement, (element) => {
if (!isLinearElement(element) || element.isDeleted) { if (!isLinearElement(element) || element.isDeleted) {
return; return;
@ -795,7 +791,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;
} }
@ -842,23 +838,18 @@ 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);
} }
}); });
}; };
@ -884,7 +875,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 => {
@ -894,22 +884,11 @@ 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(
vectorFromPoint( vectorFromPoint(p, elementCenterPoint(bindableElement)),
p,
pointFrom<GlobalPoint>(
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
),
),
); );
} }
@ -919,7 +898,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,10 +1017,7 @@ export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@ -1139,10 +1114,9 @@ export const snapToMid = (
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = pointFrom<GlobalPoint>(
x + width / 2 - 0.1, const center = elementCenterPoint(element, -0.1, -0.1);
y + height / 2 - 0.1,
);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
@ -1225,12 +1199,8 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = pointFrom<GlobalPoint>( const globalMidPoint = elementCenterPoint(bindableElement);
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@ -1274,10 +1244,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(bindableElement);
bindableElement.x + bindableElement.width / 2,
bindableElement.y + bindableElement.height / 2,
);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@ -1335,7 +1302,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,
@ -1422,20 +1388,20 @@ const getLinearElementEdgeCoors = (
); );
}; };
export const fixBindingsAfterDuplication = ( export const fixDuplicatedBindingsAfterDuplication = (
newElements: ExcalidrawElement[], duplicatedElements: ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
duplicatedElementsMap: NonDeletedSceneElementsMap, duplicateElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
for (const element of newElements) { for (const duplicateElement of duplicatedElements) {
if ("boundElements" in element && element.boundElements) { if ("boundElements" in duplicateElement && duplicateElement.boundElements) {
Object.assign(element, { Object.assign(duplicateElement, {
boundElements: element.boundElements.reduce( boundElements: duplicateElement.boundElements.reduce(
( (
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>, acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
binding, binding,
) => { ) => {
const newBindingId = oldIdToDuplicatedId.get(binding.id); const newBindingId = origIdToDuplicateId.get(binding.id);
if (newBindingId) { if (newBindingId) {
acc.push({ ...binding, id: newBindingId }); acc.push({ ...binding, id: newBindingId });
} }
@ -1446,46 +1412,47 @@ export const fixBindingsAfterDuplication = (
}); });
} }
if ("containerId" in element && element.containerId) { if ("containerId" in duplicateElement && duplicateElement.containerId) {
Object.assign(element, { Object.assign(duplicateElement, {
containerId: oldIdToDuplicatedId.get(element.containerId) ?? null, containerId:
origIdToDuplicateId.get(duplicateElement.containerId) ?? null,
}); });
} }
if ("endBinding" in element && element.endBinding) { if ("endBinding" in duplicateElement && duplicateElement.endBinding) {
const newEndBindingId = oldIdToDuplicatedId.get( const newEndBindingId = origIdToDuplicateId.get(
element.endBinding.elementId, duplicateElement.endBinding.elementId,
); );
Object.assign(element, { Object.assign(duplicateElement, {
endBinding: newEndBindingId endBinding: newEndBindingId
? { ? {
...element.endBinding, ...duplicateElement.endBinding,
elementId: newEndBindingId, elementId: newEndBindingId,
} }
: null, : null,
}); });
} }
if ("startBinding" in element && element.startBinding) { if ("startBinding" in duplicateElement && duplicateElement.startBinding) {
const newEndBindingId = oldIdToDuplicatedId.get( const newEndBindingId = origIdToDuplicateId.get(
element.startBinding.elementId, duplicateElement.startBinding.elementId,
); );
Object.assign(element, { Object.assign(duplicateElement, {
startBinding: newEndBindingId startBinding: newEndBindingId
? { ? {
...element.startBinding, ...duplicateElement.startBinding,
elementId: newEndBindingId, elementId: newEndBindingId,
} }
: null, : null,
}); });
} }
if (isElbowArrow(element)) { if (isElbowArrow(duplicateElement)) {
Object.assign( Object.assign(
element, duplicateElement,
updateElbowArrowPoints(element, duplicatedElementsMap, { updateElbowArrowPoints(duplicateElement, duplicateElementsMap, {
points: [ points: [
element.points[0], duplicateElement.points[0],
element.points[element.points.length - 1], duplicateElement.points[duplicateElement.points.length - 1],
], ],
}), }),
); );
@ -1500,8 +1467,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),
);
} }
}; };
@ -1580,10 +1551,7 @@ const determineFocusDistance = (
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@ -1713,10 +1681,7 @@ const determineFocusPoint = (
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
if (focus === 0) { if (focus === 0) {
return center; return center;
@ -2147,10 +2112,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
pointFrom<GlobalPoint>( elementCenterPoint(element),
element.x + element.width / 2,
element.y + element.height / 2,
),
element.angle, element.angle,
); );
}; };

View file

@ -1,6 +1,11 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { rescalePoints, arrayToMap, invariant } from "@excalidraw/common"; import {
rescalePoints,
arrayToMap,
invariant,
sizeOf,
} from "@excalidraw/common";
import { import {
degreesToRadians, degreesToRadians,
@ -13,7 +18,10 @@ import {
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type { import type {
Curve,
Degrees, Degrees,
GlobalPoint, GlobalPoint,
LineSegment, LineSegment,
@ -37,6 +45,13 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { getElementShape } from "./shapes";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -45,6 +60,9 @@ import type {
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMap, ElementsMap,
ExcalidrawRectanguloidElement,
ExcalidrawEllipseElement,
ElementsMapOrArray,
} from "./types"; } 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";
@ -254,50 +272,82 @@ export const getElementAbsoluteCoords = (
* that can be used for visual collision detection (useful for frames) * that can be used for visual collision detection (useful for frames)
* as opposed to bounding box collision detection * as opposed to bounding box collision detection
*/ */
/**
* Given an element, return the line segments that make up the element.
*
* Uses helpers from /math
*/
export const getElementLineSegments = ( export const getElementLineSegments = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
): LineSegment<GlobalPoint>[] => { ): LineSegment<GlobalPoint>[] => {
const shape = getElementShape(element, elementsMap);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element, element,
elementsMap, elementsMap,
); );
const center = pointFrom<GlobalPoint>(cx, cy);
const center: GlobalPoint = pointFrom(cx, cy); if (shape.type === "polycurve") {
const curves = shape.data;
if (isLinearElement(element) || isFreeDrawElement(element)) { const points = curves
const segments: LineSegment<GlobalPoint>[] = []; .map((curve) => pointsOnBezierCurves(curve, 10))
.flat();
let i = 0; let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < element.points.length - 1) { while (i < points.length - 1) {
segments.push( segments.push(
lineSegment( lineSegment(
pointRotateRads( pointFrom(points[i][0], points[i][1]),
pointFrom( pointFrom(points[i + 1][0], points[i + 1][1]),
element.points[i][0] + element.x,
element.points[i][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom(
element.points[i + 1][0] + element.x,
element.points[i + 1][1] + element.y,
),
center,
element.angle,
),
), ),
); );
i++; i++;
} }
return segments; return segments;
} else if (shape.type === "polyline") {
return shape.data as LineSegment<GlobalPoint>[];
} else if (_isRectanguloidElement(element)) {
const [sides, corners] = deconstructRectanguloidElement(element);
const cornerSegments: LineSegment<GlobalPoint>[] = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (element.type === "diamond") {
const [sides, corners] = deconstructDiamondElement(element);
const cornerSegments = corners
.map((corner) => getSegmentsOnCurve(corner, center, element.angle))
.flat();
const rotatedSides = getRotatedSides(sides, center, element.angle);
return [...rotatedSides, ...cornerSegments];
} else if (shape.type === "polygon") {
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (container && isLinearElement(container)) {
const segments: LineSegment<GlobalPoint>[] = [
lineSegment(pointFrom(x1, y1), pointFrom(x2, y1)),
lineSegment(pointFrom(x2, y1), pointFrom(x2, y2)),
lineSegment(pointFrom(x2, y2), pointFrom(x1, y2)),
lineSegment(pointFrom(x1, y2), pointFrom(x1, y1)),
];
return segments;
}
}
const points = shape.data as GlobalPoint[];
const segments: LineSegment<GlobalPoint>[] = [];
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
return segments;
} else if (shape.type === "ellipse") {
return getSegmentsOnEllipse(element as ExcalidrawEllipseElement);
} }
const [nw, ne, sw, se, n, s, w, e] = ( const [nw, ne, sw, se, , , w, e] = (
[ [
[x1, y1], [x1, y1],
[x2, y1], [x2, y1],
@ -310,28 +360,6 @@ export const getElementLineSegments = (
] as GlobalPoint[] ] as GlobalPoint[]
).map((point) => pointRotateRads(point, center, element.angle)); ).map((point) => pointRotateRads(point, center, element.angle));
if (element.type === "diamond") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
if (element.type === "ellipse") {
return [
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
lineSegment(n, w),
lineSegment(n, e),
lineSegment(s, w),
lineSegment(s, e),
];
}
return [ return [
lineSegment(nw, ne), lineSegment(nw, ne),
lineSegment(sw, se), lineSegment(sw, se),
@ -344,6 +372,94 @@ export const getElementLineSegments = (
]; ];
}; };
const _isRectanguloidElement = (
element: ExcalidrawElement,
): element is ExcalidrawRectanguloidElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
const getRotatedSides = (
sides: LineSegment<GlobalPoint>[],
center: GlobalPoint,
angle: Radians,
) => {
return sides.map((side) => {
return lineSegment(
pointRotateRads<GlobalPoint>(side[0], center, angle),
pointRotateRads<GlobalPoint>(side[1], center, angle),
);
});
};
const getSegmentsOnCurve = (
curve: Curve<GlobalPoint>,
center: GlobalPoint,
angle: Radians,
): LineSegment<GlobalPoint>[] => {
const points = pointsOnBezierCurves(curve, 10);
let i = 0;
const segments: LineSegment<GlobalPoint>[] = [];
while (i < points.length - 1) {
segments.push(
lineSegment(
pointRotateRads<GlobalPoint>(
pointFrom(points[i][0], points[i][1]),
center,
angle,
),
pointRotateRads<GlobalPoint>(
pointFrom(points[i + 1][0], points[i + 1][1]),
center,
angle,
),
),
);
i++;
}
return segments;
};
const getSegmentsOnEllipse = (
ellipse: ExcalidrawEllipseElement,
): LineSegment<GlobalPoint>[] => {
const center = pointFrom<GlobalPoint>(
ellipse.x + ellipse.width / 2,
ellipse.y + ellipse.height / 2,
);
const a = ellipse.width / 2;
const b = ellipse.height / 2;
const segments: LineSegment<GlobalPoint>[] = [];
const points: GlobalPoint[] = [];
const n = 90;
const deltaT = (Math.PI * 2) / n;
for (let i = 0; i < n; i++) {
const t = i * deltaT;
const x = center[0] + a * Math.cos(t);
const y = center[1] + b * Math.sin(t);
points.push(pointRotateRads(pointFrom(x, y), center, ellipse.angle));
}
for (let i = 0; i < points.length - 1; i++) {
segments.push(lineSegment(points[i], points[i + 1]));
}
segments.push(lineSegment(points[points.length - 1], points[0]));
return segments;
};
/** /**
* Scene -> Scene coords, but in x1,x2,y1,y2 format. * Scene -> Scene coords, but in x1,x2,y1,y2 format.
* *
@ -828,10 +944,10 @@ export const getElementBounds = (
}; };
export const getCommonBounds = ( export const getCommonBounds = (
elements: readonly ExcalidrawElement[], elements: ElementsMapOrArray,
elementsMap?: ElementsMap, elementsMap?: ElementsMap,
): Bounds => { ): Bounds => {
if (!elements.length) { if (!sizeOf(elements)) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View file

@ -1,4 +1,4 @@
import { isTransparent } from "@excalidraw/common"; import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@ -16,7 +16,7 @@ import {
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
import { getPolygonShape } from "@excalidraw/utils/shape"; import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type { import type {
GlobalPoint, GlobalPoint,
@ -26,8 +26,6 @@ import type {
Radians, Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { GeometricShape } from "@excalidraw/utils/shape";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { getBoundTextShape, isPathALoop } from "./shapes";
@ -191,10 +189,7 @@ const intersectRectanguloidWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>( const rotatedA = pointRotateRads<GlobalPoint>(
@ -253,10 +248,7 @@ const intersectDiamondWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@ -304,10 +296,7 @@ const intersectEllipseWithLineSegment = (
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);

View file

@ -14,6 +14,8 @@ import {
} from "@excalidraw/math"; } from "@excalidraw/math";
import { type Point } from "points-on-curve"; import { type Point } from "points-on-curve";
import { elementCenterPoint } from "@excalidraw/common";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getResizedElementAbsoluteCoords, getResizedElementAbsoluteCoords,
@ -61,7 +63,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
pointFrom(element.x + element.width / 2, element.y + element.height / 2), elementCenterPoint(element),
-element.angle as Radians, -element.angle as Radians,
); );

View file

@ -1,12 +1,13 @@
import { import {
curvePointDistance, curvePointDistance,
distanceToLineSegment, distanceToLineSegment,
pointFrom,
pointRotateRads, pointRotateRads,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import { elementCenterPoint } from "@excalidraw/common";
import type { GlobalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
@ -53,10 +54,7 @@ const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@ -84,10 +82,7 @@ const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@ -115,10 +110,7 @@ const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = pointFrom( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),

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 },
); );
} }
}; };

View file

@ -36,7 +36,7 @@ import {
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixBindingsAfterDuplication } from "./binding"; import { fixDuplicatedBindingsAfterDuplication } from "./binding";
import type { import type {
ElementsMap, ElementsMap,
@ -57,16 +57,14 @@ import type {
* multiple elements at once, share this map * multiple elements at once, share this map
* amongst all of them * amongst all of them
* @param element Element to duplicate * @param element Element to duplicate
* @param overrides Any element properties to override
*/ */
export const duplicateElement = <TElement extends ExcalidrawElement>( export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"], editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>, groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement, element: TElement,
overrides?: Partial<TElement>,
randomizeSeed?: boolean, randomizeSeed?: boolean,
): Readonly<TElement> => { ): Readonly<TElement> => {
let copy = deepCopyElement(element); const copy = deepCopyElement(element);
if (isTestEnv()) { if (isTestEnv()) {
__test__defineOrigId(copy, element.id); __test__defineOrigId(copy, element.id);
@ -89,9 +87,6 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
return groupIdMapForOperation.get(groupId)!; return groupIdMapForOperation.get(groupId)!;
}, },
); );
if (overrides) {
copy = Object.assign(copy, overrides);
}
return copy; return copy;
}; };
@ -99,9 +94,14 @@ export const duplicateElements = (
opts: { opts: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean; randomizeSeed?: boolean;
overrides?: ( overrides?: (data: {
originalElement: ExcalidrawElement, duplicateElement: ExcalidrawElement;
) => Partial<ExcalidrawElement>; origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & ( } & (
| { | {
/** /**
@ -129,14 +129,6 @@ export const duplicateElements = (
editingGroupId: AppState["editingGroupId"]; editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
}; };
/**
* If true, duplicated elements are inserted _before_ specified
* elements. Case: alt-dragging elements to duplicate them.
*
* TODO: remove this once (if) we stop replacing the original element
* with the duplicated one in the scene array.
*/
reverseOrder: boolean;
} }
), ),
) => { ) => {
@ -150,8 +142,6 @@ export const duplicateElements = (
selectedGroupIds: {}, selectedGroupIds: {},
} as const); } as const);
const reverseOrder = opts.type === "in-place" ? opts.reverseOrder : false;
// Ids of elements that have already been processed so we don't push them // Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving // into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge // discontiguous group of elements (can happen due to a bug, or in edge
@ -164,10 +154,17 @@ export const duplicateElements = (
// loop over them. // loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>(); const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map(); const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = []; const duplicatedElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const origElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const origIdToDuplicateId = new Map<
const duplicatedElementsMap = new Map<string, ExcalidrawElement>(); ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap; const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate = const _idsOfElementsToDuplicate =
opts.type === "in-place" opts.type === "in-place"
@ -185,7 +182,7 @@ export const duplicateElements = (
elements = normalizeElementOrder(elements); elements = normalizeElementOrder(elements);
const elementsWithClones: ExcalidrawElement[] = elements.slice(); const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
// helper functions // helper functions
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -211,17 +208,17 @@ export const duplicateElements = (
appState.editingGroupId, appState.editingGroupId,
groupIdMap, groupIdMap,
element, element,
opts.overrides?.(element),
opts.randomizeSeed, opts.randomizeSeed,
); );
processedIds.set(newElement.id, true); processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement); duplicateElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
oldElements.push(element); origElements.push(element);
newElements.push(newElement); duplicatedElements.push(newElement);
acc.push(newElement); acc.push(newElement);
return acc; return acc;
@ -245,21 +242,12 @@ export const duplicateElements = (
return; return;
} }
if (reverseOrder && index < 1) { if (index > elementsWithDuplicates.length - 1) {
elementsWithClones.unshift(...castArray(elements)); elementsWithDuplicates.push(...castArray(elements));
return; return;
} }
if (!reverseOrder && index > elementsWithClones.length - 1) { elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
elementsWithClones.push(...castArray(elements));
return;
}
elementsWithClones.splice(
index + (reverseOrder ? 0 : 1),
0,
...castArray(elements),
);
}; };
const frameIdsToDuplicate = new Set( const frameIdsToDuplicate = new Set(
@ -291,13 +279,9 @@ export const duplicateElements = (
: [element], : [element],
); );
const targetIndex = reverseOrder const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
? elementsWithClones.findIndex((el) => { return el.groupIds?.includes(groupId);
return el.groupIds?.includes(groupId); });
})
: findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements)); insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue; continue;
@ -315,7 +299,7 @@ export const duplicateElements = (
const frameChildren = getFrameChildren(elements, frameId); const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.frameId === frameId || el.id === frameId; return el.frameId === frameId || el.id === frameId;
}); });
@ -332,7 +316,7 @@ export const duplicateElements = (
if (hasBoundTextElement(element)) { if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return ( return (
el.id === element.id || el.id === element.id ||
("containerId" in el && el.containerId === element.id) ("containerId" in el && el.containerId === element.id)
@ -341,7 +325,7 @@ export const duplicateElements = (
if (boundTextElement) { if (boundTextElement) {
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
targetIndex + (reverseOrder ? -1 : 0), targetIndex,
copyElements([element, boundTextElement]), copyElements([element, boundTextElement]),
); );
} else { } else {
@ -354,7 +338,7 @@ export const duplicateElements = (
if (isBoundToContainer(element)) { if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap); const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => { const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.id === element.id || el.id === container?.id; return el.id === element.id || el.id === container?.id;
}); });
@ -374,28 +358,46 @@ export const duplicateElements = (
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
insertBeforeOrAfterIndex( insertBeforeOrAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id), findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
copyElements(element), copyElements(element),
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fixBindingsAfterDuplication( fixDuplicatedBindingsAfterDuplication(
newElements, duplicatedElements,
oldIdToDuplicatedId, origIdToDuplicateId,
duplicatedElementsMap as NonDeletedSceneElementsMap, duplicateElementsMap as NonDeletedSceneElementsMap,
); );
bindElementsToFramesAfterDuplication( bindElementsToFramesAfterDuplication(
elementsWithClones, elementsWithDuplicates,
oldElements, origElements,
oldIdToDuplicatedId, origIdToDuplicateId,
); );
if (opts.overrides) {
for (const duplicateElement of duplicatedElements) {
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
if (origElement) {
Object.assign(
duplicateElement,
opts.overrides({
duplicateElement,
origElement,
origIdToDuplicateId,
}),
);
}
}
}
return { return {
newElements, duplicatedElements,
elementsWithClones, duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
}; };
}; };

View file

@ -50,7 +50,6 @@ 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, pointInsideBounds } from "./shapes";
@ -887,7 +886,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 +1272,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,
); );
@ -2250,7 +2247,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 +2264,6 @@ const getBindPointHeading = (
number, number,
], ],
), ),
elementsMap,
origPoint, origPoint,
); );

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,
),
); );
} }
} }

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[][] = [];

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,
@ -41,30 +41,24 @@ import type {
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
nextElements: readonly ExcalidrawElement[], nextElements: readonly ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[], origElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>, origIdToDuplicateId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => { ) => {
const nextElementMap = arrayToMap(nextElements) as Map< const nextElementMap = arrayToMap(nextElements) as Map<
ExcalidrawElement["id"], ExcalidrawElement["id"],
ExcalidrawElement ExcalidrawElement
>; >;
for (const element of oldElements) { for (const element of origElements) {
if (element.frameId) { if (element.frameId) {
// use its frameId to get the new frameId // use its frameId to get the new frameId
const nextElementId = oldIdToDuplicatedId.get(element.id); const nextElementId = origIdToDuplicateId.get(element.id);
const nextFrameId = oldIdToDuplicatedId.get(element.frameId); const nextFrameId = origIdToDuplicateId.get(element.frameId);
if (nextElementId) { const nextElement = nextElementId && nextElementMap.get(nextElementId);
const nextElement = nextElementMap.get(nextElementId); if (nextElement) {
if (nextElement) { mutateElement(nextElement, nextElementMap, {
mutateElement( frameId: nextFrameId ?? null,
nextElement, });
{
frameId: nextFrameId ?? element.frameId,
},
false,
);
}
} }
} }
} }
@ -567,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;
@ -611,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,
);
} }
}; };

View file

@ -1,11 +1,15 @@
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
import { import {
normalizeRadians,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
pointScaleFromOrigin, pointScaleFromOrigin,
radiansToDegrees, pointsEqual,
triangleIncludesPoint, triangleIncludesPoint,
vectorCross,
vectorFromPoint, vectorFromPoint,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
@ -13,7 +17,6 @@ import type {
GlobalPoint, GlobalPoint,
Triangle, Triangle,
Vector, Vector,
Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCenterForBounds, type Bounds } from "./bounds"; import { getCenterForBounds, type Bounds } from "./bounds";
@ -26,24 +29,6 @@ export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading; export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
a: Point,
b: Point,
) => {
const angle = radiansToDegrees(
normalizeRadians(Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians),
);
if (angle >= 315 || angle < 45) {
return HEADING_UP;
} else if (angle >= 45 && angle < 135) {
return HEADING_RIGHT;
} else if (angle >= 135 && angle < 225) {
return HEADING_DOWN;
}
return HEADING_LEFT;
};
export const vectorToHeading = (vec: Vector): Heading => { export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec; const [x, y] = vec;
const absX = Math.abs(x); const absX = Math.abs(x);
@ -76,6 +61,165 @@ export const headingIsHorizontal = (a: Heading) =>
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a); export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
const headingForPointFromDiamondElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<GlobalPoint>,
): Heading => {
const midPoint = getCenterForBounds(aabb);
if (isDevEnv() || isTestEnv()) {
invariant(
element.width > 0 && element.height > 0,
"Diamond element has no width or height",
);
invariant(
!pointsEqual(midPoint, point),
"The point is too close to the element mid point to determine heading",
);
}
const SHRINK = 0.95; // Rounded elements tolerance
const top = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const right = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const bottom = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const left = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
// Corners
if (
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
0 &&
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
) {
return headingForPoint(top, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, right),
vectorFromPoint(right, bottom),
) <= 0 &&
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
) {
return headingForPoint(right, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, left),
) <= 0 &&
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, right),
) > 0
) {
return headingForPoint(bottom, midPoint);
} else if (
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
0 &&
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
) {
return headingForPoint(left, midPoint);
}
// Sides
if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(top, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) > 0
) {
const p = element.width > element.height ? top : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(left, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : left;
return headingForPoint(p, midPoint);
}
const p = element.width > element.height ? top : left;
return headingForPoint(p, midPoint);
};
// Gets the heading for the point by creating a bounding box around the rotated // Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of // close fitting bounding box, then creating 4 search cones around the center of
// the external bbox. // the external bbox.
@ -89,74 +233,7 @@ export const headingForPointFromElement = <Point extends GlobalPoint>(
const midPoint = getCenterForBounds(aabb); const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") { if (element.type === "diamond") {
if (p[0] < element.x) { return headingForPointFromDiamondElement(element, aabb, p);
return HEADING_LEFT;
} else if (p[1] < element.y) {
return HEADING_UP;
} else if (p[0] > element.x + element.width) {
return HEADING_RIGHT;
} else if (p[1] > element.y + element.height) {
return HEADING_DOWN;
}
const top = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const right = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const bottom = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x + element.width / 2, element.y + element.height),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
const left = pointRotateRads(
pointScaleFromOrigin(
pointFrom(element.x, element.y + element.height / 2),
midPoint,
SEARCH_CONE_MULTIPLIER,
),
midPoint,
element.angle,
);
if (
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
) {
return headingForDiamond(top, right);
} else if (
triangleIncludesPoint<Point>(
[right, bottom, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(right, bottom);
} else if (
triangleIncludesPoint<Point>(
[bottom, left, midPoint] as Triangle<Point>,
p,
)
) {
return headingForDiamond(bottom, left);
}
return headingForDiamond(left, top);
} }
const topLeft = pointScaleFromOrigin( const topLeft = pointScaleFromOrigin(

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,14 +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, elementsMap);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
this.isDragging = false; this.isDragging = false;
@ -308,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(
@ -332,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
@ -357,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
@ -452,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:
@ -794,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(
@ -860,7 +859,6 @@ export class LinearElementEditor {
element, element,
startBindingElement, startBindingElement,
endBindingElement, endBindingElement,
elementsMap,
scene, scene,
); );
} }
@ -933,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;
@ -950,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,
@ -988,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,
@ -1159,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);
@ -1218,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),
@ -1243,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;
@ -1273,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;
@ -1328,6 +1345,7 @@ export class LinearElementEditor {
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene,
nextPoints, nextPoints,
offsetX, offsetX,
offsetY, offsetY,
@ -1338,7 +1356,6 @@ export class LinearElementEditor {
dragging || targetPoint.isDragging === true, dragging || targetPoint.isDragging === true,
false, false,
), ),
sceneElementsMap,
}, },
); );
} }
@ -1393,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,
@ -1424,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,
@ -1442,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,
@ -1478,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);
@ -1514,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],
@ -1573,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;
@ -1780,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,
@ -1824,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,
}); });
@ -1858,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);
} }
} }

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;
}; };

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);
} }
} }

View file

@ -1,4 +1,4 @@
import { isShallowEqual } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type { import type {
AppState, AppState,
@ -7,13 +7,20 @@ import type {
import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { isBoundToContainer, isFrameLikeElement } from "./typeChecks"; import {
isBoundToContainer,
isFrameLikeElement,
isLinearElement,
} from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
@ -254,3 +261,49 @@ export const makeNextSelectedElementIds = (
return nextSelectedElementIds; return nextSelectedElementIds;
}; };
const _getLinearElementEditor = (
targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
) => {
const linears = targetElements.filter(isLinearElement);
if (linears.length === 1) {
const linear = linears[0];
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = targetElements.every(
(el) => el.id === linear.id || boundElements.includes(el.id),
);
if (onlySingleLinearSelected) {
return new LinearElementEditor(linear, arrayToMap(allElements));
}
}
return null;
};
export const getSelectionStateForElements = (
targetElements: readonly ExcalidrawElement[],
allElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
return {
selectedLinearElement: _getLinearElementEditor(targetElements, allElements),
...selectGroupsForSelectedElements(
{
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
targetElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
allElements,
appState,
null,
),
};
};

View file

@ -4,6 +4,7 @@ import {
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
ROUNDNESS, ROUNDNESS,
invariant, invariant,
elementCenterPoint,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
isPoint, isPoint,
@ -297,7 +298,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = pointFrom(bbox.midX, bbox.midY); const center = elementCenterPoint(element);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,

View file

@ -14,6 +14,7 @@ export const showSelectedShapeActions = (
((appState.activeTool.type !== "custom" && ((appState.activeTool.type !== "custom" &&
(appState.editingTextElement || (appState.editingTextElement ||
(appState.activeTool.type !== "selection" && (appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "lasso" &&
appState.activeTool.type !== "eraser" && appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" && appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) || appState.activeTool.type !== "laser"))) ||

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">,
): { ): {

View file

@ -6,18 +6,22 @@ import {
TEXT_ALIGN, TEXT_ALIGN,
VERTICAL_ALIGN, VERTICAL_ALIGN,
getFontString, getFontString,
isProdEnv,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; 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 {
@ -26,6 +30,8 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import type Scene from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles"; import type { MaybeTransformHandleType } from "./transformHandles";
import type { import type {
ElementsMap, ElementsMap,
@ -40,17 +46,30 @@ 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()) {
invariant(
!container || !isArrowElement(container) || textElement.angle === 0,
"text element angle must be 0 if bound to arrow container",
);
}
const boundTextUpdates = { const boundTextUpdates = {
x: textElement.x, x: textElement.x,
y: textElement.y, y: textElement.y,
text: textElement.text, text: textElement.text,
width: textElement.width, width: textElement.width,
height: textElement.height, height: textElement.height,
angle: container?.angle ?? textElement.angle, angle: (container
? isArrowElement(container)
? 0
: container.angle
: textElement.angle) as Radians,
}; };
boundTextUpdates.text = textElement.text; boundTextUpdates.text = textElement.text;
@ -90,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;
@ -174,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),
); );
@ -335,7 +359,10 @@ export const getTextElementAngle = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null, container: ExcalidrawTextContainer | null,
) => { ) => {
if (!container || isArrowElement(container)) { if (isArrowElement(container)) {
return 0;
}
if (!container) {
return textElement.angle; return textElement.angle;
} }
return container.angle; return container.angle;

View file

@ -10,6 +10,8 @@ import {
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common";
import type { Curve, LineSegment } from "@excalidraw/math"; import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./shapes";
@ -68,10 +70,7 @@ export function deconstructRectanguloidElement(
return [sides, []]; return [sides, []];
} }
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
const r = rectangle( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
@ -254,10 +253,7 @@ export function deconstructDiamondElement(
return [[topRight, bottomRight, bottomLeft, topLeft], []]; return [[topRight, bottomRight, bottomLeft, topLeft], []];
} }
const center = pointFrom<GlobalPoint>( const center = elementCenterPoint(element);
element.x + element.width / 2,
element.y + element.height / 2,
);
const [top, right, bottom, left]: GlobalPoint[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),

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) => {

View file

@ -1,4 +1,3 @@
import React from "react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { import {
@ -8,13 +7,13 @@ 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";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import { import {
act, act,
@ -25,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";
@ -63,11 +61,11 @@ 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)],
}); });
const copy = duplicateElement(null, new Map(), element, undefined, true); const copy = duplicateElement(null, new Map(), element, true);
assertCloneObjects(element, copy); assertCloneObjects(element, copy);
@ -173,7 +171,7 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const; const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { newElements: clonedElements } = duplicateElements({ const { duplicatedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}); });
@ -181,10 +179,10 @@ describe("duplicating multiple elements", () => {
// generic id in-equality checks // generic id in-equality checks
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual( expect(origElements.map((e) => e.type)).toEqual(
clonedElements.map((e) => e.type), duplicatedElements.map((e) => e.type),
); );
origElements.forEach((origElement, idx) => { origElements.forEach((origElement, idx) => {
const clonedElement = clonedElements[idx]; const clonedElement = duplicatedElements[idx];
expect(origElement).toEqual( expect(origElement).toEqual(
expect.objectContaining({ expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id), id: expect.not.stringMatching(clonedElement.id),
@ -217,12 +215,12 @@ describe("duplicating multiple elements", () => {
}); });
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
const clonedArrows = clonedElements.filter( const clonedArrows = duplicatedElements.filter(
(e) => e.type === "arrow", (e) => e.type === "arrow",
) as ExcalidrawLinearElement[]; ) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] = const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
clonedElements as any as typeof origElements; duplicatedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id); expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect( expect(
@ -327,10 +325,10 @@ describe("duplicating multiple elements", () => {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const; const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const { newElements: clonedElements } = duplicateElements({ const duplicatedElements = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}) as any as { newElements: typeof origElements }; }).duplicatedElements as any as typeof origElements;
const [ const [
clonedRectangle, clonedRectangle,
@ -338,7 +336,7 @@ describe("duplicating multiple elements", () => {
clonedArrow1, clonedArrow1,
clonedArrow2, clonedArrow2,
clonedArrow3, clonedArrow3,
] = clonedElements; ] = duplicatedElements;
expect(clonedRectangle.boundElements).toEqual([ expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" }, { id: clonedArrow1.id, type: "arrow" },
@ -374,12 +372,12 @@ describe("duplicating multiple elements", () => {
}); });
const origElements = [rectangle1, rectangle2, rectangle3] as const; const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { newElements: clonedElements } = duplicateElements({ const { duplicatedElements } = duplicateElements({
type: "everything", type: "everything",
elements: origElements, elements: origElements,
}) as any as { newElements: typeof origElements }; });
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] = const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
clonedElements; duplicatedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]); expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]); expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
@ -399,7 +397,7 @@ describe("duplicating multiple elements", () => {
}); });
const { const {
newElements: [clonedRectangle1], duplicatedElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] }); } = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string"); expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
@ -408,6 +406,117 @@ describe("duplicating multiple elements", () => {
}); });
}); });
describe("group-related duplication", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("action-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it("alt-duplicating within group", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
]);
expect(h.state.editingGroupId).toBe("group1");
});
it("alt-duplicating within group away outside frame", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
width: 50,
height: 50,
groupIds: ["group1"],
frameId: frame.id,
});
API.setElements([frame, rectangle1, rectangle2]);
API.setSelectedElements([rectangle2], "group1");
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
});
// console.log(h.elements);
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle1.id, frameId: frame.id },
{ id: rectangle2.id, frameId: frame.id },
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
]);
expect(h.state.editingGroupId).toBe(null);
});
});
describe("duplication z-order", () => { describe("duplication z-order", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@ -503,8 +612,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -538,8 +647,8 @@ describe("duplication z-order", () => {
assertElements(h.elements, [ assertElements(h.elements, [
{ id: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle2.id }, { id: rectangle2.id },
{ [ORIG_ID]: rectangle3.id }, { id: rectangle3.id },
{ id: rectangle3.id, selected: true }, { [ORIG_ID]: rectangle3.id, selected: true },
]); ]);
}); });
@ -569,8 +678,8 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id }, { id: rectangle2.id },
{ id: rectangle3.id }, { id: rectangle3.id },
]); ]);
@ -605,19 +714,19 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle1.id }, { id: rectangle1.id },
{ [ORIG_ID]: rectangle2.id }, { id: rectangle2.id },
{ [ORIG_ID]: rectangle3.id }, { id: rectangle3.id },
{ id: rectangle1.id, selected: true }, { [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id, selected: true }, { [ORIG_ID]: rectangle2.id, selected: true },
{ id: rectangle3.id, selected: true }, { [ORIG_ID]: rectangle3.id, selected: true },
]); ]);
}); });
it("reverse-duplicating text container (in-order)", async () => { it("alt-duplicating text container (in-order)", async () => {
const [rectangle, text] = API.createTextContainer(); const [rectangle, text] = API.createTextContainer();
API.setElements([rectangle, text]); API.setElements([rectangle, text]);
API.setSelectedElements([rectangle, text]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -625,20 +734,20 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id }, { id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id, containerId: getCloneByOrigId(rectangle.id)?.id,
}, },
{ id: rectangle.id, selected: true },
{ id: text.id, containerId: rectangle.id, selected: true },
]); ]);
}); });
it("reverse-duplicating text container (out-of-order)", async () => { it("alt-duplicating text container (out-of-order)", async () => {
const [rectangle, text] = API.createTextContainer(); const [rectangle, text] = API.createTextContainer();
API.setElements([text, rectangle]); API.setElements([text, rectangle]);
API.setSelectedElements([rectangle, text]); API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5); mouse.down(rectangle.x + 5, rectangle.y + 5);
@ -646,21 +755,21 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: rectangle.id }, { id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id, containerId: getCloneByOrigId(rectangle.id)?.id,
}, },
{ id: rectangle.id, selected: true },
{ id: text.id, containerId: rectangle.id, selected: true },
]); ]);
}); });
it("reverse-duplicating labeled arrows (in-order)", async () => { it("alt-duplicating labeled arrows (in-order)", async () => {
const [arrow, text] = API.createLabeledArrow(); const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]); API.setElements([arrow, text]);
API.setSelectedElements([arrow, text]); API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -668,21 +777,24 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: arrow.id }, { id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id, containerId: getCloneByOrigId(arrow.id)?.id,
}, },
{ id: arrow.id, selected: true },
{ id: text.id, containerId: arrow.id, selected: true },
]); ]);
expect(h.state.selectedLinearElement).toEqual(
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
);
}); });
it("reverse-duplicating labeled arrows (out-of-order)", async () => { it("alt-duplicating labeled arrows (out-of-order)", async () => {
const [arrow, text] = API.createLabeledArrow(); const [arrow, text] = API.createLabeledArrow();
API.setElements([text, arrow]); API.setElements([text, arrow]);
API.setSelectedElements([arrow, text]); API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => { Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5); mouse.down(arrow.x + 5, arrow.y + 5);
@ -690,13 +802,50 @@ describe("duplication z-order", () => {
}); });
assertElements(h.elements, [ assertElements(h.elements, [
{ [ORIG_ID]: arrow.id }, { id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ {
[ORIG_ID]: text.id, [ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id, containerId: getCloneByOrigId(arrow.id)?.id,
}, },
{ id: arrow.id, selected: true }, ]);
{ id: text.id, containerId: arrow.id, selected: true }, });
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -100,
y: 50,
width: 95,
height: 0,
});
expect(arrow.endBinding?.elementId).toBe(rect.id);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(5, 5);
mouse.up(15, 15);
});
assertElements(h.elements, [
{
id: rect.id,
boundElements: expect.arrayContaining([
expect.objectContaining({ id: arrow.id }),
]),
},
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
{
id: arrow.id,
endBinding: expect.objectContaining({ elementId: rect.id }),
},
]); ]);
}); });
}); });

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, { h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });

View file

@ -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,
) { ) {

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,

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 }],
}); });

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);

View file

@ -21,12 +21,12 @@ import {
import { import {
hasBoundTextElement, hasBoundTextElement,
isArrowElement,
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
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";
@ -42,6 +42,8 @@ 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";
@ -77,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,
@ -85,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,
), ),
@ -150,24 +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,
}); });
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);
@ -226,8 +225,8 @@ export const actionWrapTextInContainer = register({
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
const areTextElements = selectedElements.every((el) => isTextElement(el)); const someTextElements = selectedElements.some((el) => isTextElement(el));
return selectedElements.length > 0 && areTextElements; return selectedElements.length > 0 && someTextElements;
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
@ -297,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],

View file

@ -29,6 +29,7 @@ import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
import { import {
handIcon, handIcon,
LassoIcon,
MoonIcon, MoonIcon,
SunIcon, SunIcon,
TrashIcon, TrashIcon,
@ -52,7 +53,6 @@ import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
label: "labels.canvasBackground", label: "labels.canvasBackground",
paletteName: "Change canvas background color",
trackEvent: false, trackEvent: false,
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
return ( return (
@ -90,7 +90,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({ export const actionClearCanvas = register({
name: "clearCanvas", name: "clearCanvas",
label: "labels.clearCanvas", label: "labels.clearCanvas",
paletteName: "Clear canvas",
icon: TrashIcon, icon: TrashIcon,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => { predicate: (elements, appState, props, app) => {
@ -525,10 +524,42 @@ export const actionToggleEraserTool = register({
keyTest: (event) => event.key === KEYS.E, keyTest: (event) => event.key === KEYS.E,
}); });
export const actionToggleLassoTool = register({
name: "toggleLassoTool",
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (appState.activeTool.type !== "lasso") {
activeTool = updateActiveTool(appState, {
type: "lasso",
fromSelection: false,
});
setCursor(app.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
});
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeEmbeddable: null,
activeTool,
},
captureUpdate: CaptureUpdateAction.NEVER,
};
},
});
export const actionToggleHandTool = register({ export const actionToggleHandTool = register({
name: "toggleHandTool", name: "toggleHandTool",
label: "toolBar.hand", label: "toolBar.hand",
paletteName: "Toggle hand tool",
trackEvent: { category: "toolbar" }, trackEvent: { category: "toolbar" },
icon: handIcon, icon: handIcon,
viewMode: false, viewMode: false,

View file

@ -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 }],
}); });

View file

@ -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,

View file

@ -7,26 +7,17 @@ import {
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import {
isBoundToContainer,
isLinearElement,
} from "@excalidraw/element/typeChecks";
import { LinearElementEditor } from "@excalidraw/element/linearElementEditor"; import { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import { selectGroupsForSelectedElements } from "@excalidraw/element/groups";
import { import {
excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
getSelectionStateForElements,
} from "@excalidraw/element/selection"; } from "@excalidraw/element/selection";
import { syncMovedIndices } from "@excalidraw/element/fractionalIndex"; import { syncMovedIndices } from "@excalidraw/element/fractionalIndex";
import { duplicateElements } from "@excalidraw/element/duplicate"; import { duplicateElements } from "@excalidraw/element/duplicate";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
@ -52,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 {
@ -65,52 +56,49 @@ export const actionDuplicateSelection = register({
} }
} }
let { newElements: duplicatedElements, elementsWithClones: nextElements } = let { duplicatedElements, elementsWithDuplicates } = duplicateElements({
duplicateElements({ type: "in-place",
type: "in-place", elements,
elements, idsOfElementsToDuplicate: arrayToMap(
idsOfElementsToDuplicate: arrayToMap( getSelectedElements(elements, appState, {
getSelectedElements(elements, appState, { includeBoundTextElement: true,
includeBoundTextElement: true, includeElementsInFrames: true,
includeElementsInFrames: true,
}),
),
appState,
randomizeSeed: true,
overrides: (element) => ({
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
}), }),
reverseOrder: false, ),
}); appState,
randomizeSeed: true,
overrides: ({ origElement, origIdToDuplicateId }) => {
const duplicateFrameId =
origElement.frameId && origIdToDuplicateId.get(origElement.frameId);
return {
x: origElement.x + DEFAULT_GRID_SIZE / 2,
y: origElement.y + DEFAULT_GRID_SIZE / 2,
frameId: duplicateFrameId ?? origElement.frameId,
};
},
});
if (app.props.onDuplicate && nextElements) { if (app.props.onDuplicate && elementsWithDuplicates) {
const mappedElements = app.props.onDuplicate(nextElements, elements); const mappedElements = app.props.onDuplicate(
elementsWithDuplicates,
elements,
);
if (mappedElements) { if (mappedElements) {
nextElements = mappedElements; elementsWithDuplicates = mappedElements;
} }
} }
return { return {
elements: syncMovedIndices(nextElements, arrayToMap(duplicatedElements)), elements: syncMovedIndices(
elementsWithDuplicates,
arrayToMap(duplicatedElements),
),
appState: { appState: {
...appState, ...appState,
...updateLinearElementEditors(duplicatedElements), ...getSelectionStateForElements(
...selectGroupsForSelectedElements( duplicatedElements,
{ getNonDeletedElements(elementsWithDuplicates),
editingGroupId: appState.editingGroupId,
selectedElementIds: excludeElementsInFramesFromSelection(
duplicatedElements,
).reduce((acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
acc[element.id] = true;
}
return acc;
}, {}),
},
getNonDeletedElements(nextElements),
appState, appState,
null,
), ),
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -130,24 +118,3 @@ export const actionDuplicateSelection = register({
/> />
), ),
}); });
const updateLinearElementEditors = (clonedElements: ExcalidrawElement[]) => {
const linears = clonedElements.filter(isLinearElement);
if (linears.length === 1) {
const linear = linears[0];
const boundElements = linear.boundElements?.map((def) => def.id) ?? [];
const onlySingleLinearSelected = clonedElements.every(
(el) => el.id === linear.id || boundElements.includes(el.id),
);
if (onlySingleLinearSelected) {
return {
selectedLinearElement: new LinearElementEditor(linear),
};
}
}
return {
selectedLinearElement: null,
};
};

View file

@ -90,7 +90,6 @@ export const actionToggleElementLock = register({
export const actionUnlockAllElements = register({ export const actionUnlockAllElements = register({
name: "unlockAllElements", name: "unlockAllElements",
paletteName: "Unlock all elements",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
viewMode: false, viewMode: false,
icon: UnlockedIcon, icon: UnlockedIcon,

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,
}, },

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,
}), }),

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,
);
} }
} }

View file

@ -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,

View file

@ -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),
); );
} }
} }

View file

@ -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,

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 (

View file

@ -9,7 +9,6 @@ export const actionToggleStats = register({
name: "stats", name: "stats",
label: "stats.fullTitle", label: "stats.fullTitle",
icon: abacusIcon, icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true, viewMode: true,
trackEvent: { category: "menu" }, trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"], keywords: ["edit", "attributes", "customize"],

View file

@ -8,7 +8,6 @@ import { register } from "./register";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
label: "labels.viewMode", label: "labels.viewMode",
paletteName: "Toggle view mode",
icon: eyeIcon, icon: eyeIcon,
viewMode: true, viewMode: true,
trackEvent: { trackEvent: {

View file

@ -9,7 +9,6 @@ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
label: "buttons.zenMode", label: "buttons.zenMode",
icon: coffeeIcon, icon: coffeeIcon,
paletteName: "Toggle zen mode",
viewMode: true, viewMode: true,
trackEvent: { trackEvent: {
category: "canvas", category: "canvas",

View file

@ -139,7 +139,8 @@ export type ActionName =
| "copyElementLink" | "copyElementLink"
| "linkToElement" | "linkToElement"
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame"; | "wrapSelectionInFrame"
| "toggleLassoTool";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View file

@ -23,6 +23,8 @@ export interface Trail {
export interface AnimatedTrailOptions { export interface AnimatedTrailOptions {
fill: (trail: AnimatedTrail) => string; fill: (trail: AnimatedTrail) => string;
stroke?: (trail: AnimatedTrail) => string;
animateTrail?: boolean;
} }
export class AnimatedTrail implements Trail { export class AnimatedTrail implements Trail {
@ -31,16 +33,28 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement; private container?: SVGSVGElement;
private trailElement: SVGPathElement; private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
constructor( constructor(
private animationFrameHandler: AnimationFrameHandler, private animationFrameHandler: AnimationFrameHandler,
private app: App, protected app: App,
private options: Partial<LaserPointerOptions> & private options: Partial<LaserPointerOptions> &
Partial<AnimatedTrailOptions>, Partial<AnimatedTrailOptions>,
) { ) {
this.animationFrameHandler.register(this, this.onFrame.bind(this)); this.animationFrameHandler.register(this, this.onFrame.bind(this));
this.trailElement = document.createElementNS(SVG_NS, "path"); this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
// TODO: make this configurable
this.trailAnimation.setAttribute("attributeName", "stroke-dashoffset");
this.trailElement.setAttribute("stroke-dasharray", "7 7");
this.trailElement.setAttribute("stroke-dashoffset", "10");
this.trailAnimation.setAttribute("from", "0");
this.trailAnimation.setAttribute("to", `-14`);
this.trailAnimation.setAttribute("dur", "0.3s");
this.trailElement.appendChild(this.trailAnimation);
}
} }
get hasCurrentTrail() { get hasCurrentTrail() {
@ -104,8 +118,23 @@ export class AnimatedTrail implements Trail {
} }
} }
getCurrentTrail() {
return this.currentTrail;
}
clearTrails() {
this.pastTrails = [];
this.currentTrail = undefined;
this.update();
}
private update() { private update() {
this.pastTrails = [];
this.start(); this.start();
if (this.trailAnimation) {
this.trailAnimation.setAttribute("begin", "indefinite");
this.trailAnimation.setAttribute("repeatCount", "indefinite");
}
} }
private onFrame() { private onFrame() {
@ -132,14 +161,25 @@ export class AnimatedTrail implements Trail {
const svgPaths = paths.join(" ").trim(); const svgPaths = paths.join(" ").trim();
this.trailElement.setAttribute("d", svgPaths); this.trailElement.setAttribute("d", svgPaths);
this.trailElement.setAttribute( if (this.trailAnimation) {
"fill", this.trailElement.setAttribute(
(this.options.fill ?? (() => "black"))(this), "fill",
); (this.options.fill ?? (() => "black"))(this),
);
this.trailElement.setAttribute(
"stroke",
(this.options.stroke ?? (() => "black"))(this),
);
} else {
this.trailElement.setAttribute(
"fill",
(this.options.fill ?? (() => "black"))(this),
);
}
} }
private drawTrail(trail: LaserPointer, state: AppState): string { private drawTrail(trail: LaserPointer, state: AppState): string {
const stroke = trail const _stroke = trail
.getStrokeOutline(trail.options.size / state.zoom.value) .getStrokeOutline(trail.options.size / state.zoom.value)
.map(([x, y]) => { .map(([x, y]) => {
const result = sceneCoordsToViewportCoords( const result = sceneCoordsToViewportCoords(
@ -150,6 +190,10 @@ export class AnimatedTrail implements Trail {
return [result.x, result.y]; return [result.x, result.y];
}); });
const stroke = this.trailAnimation
? _stroke.slice(0, _stroke.length / 2)
: _stroke;
return getSvgPathFromStroke(stroke, true); return getSvgPathFromStroke(stroke, true);
} }
} }

View file

@ -52,6 +52,7 @@ export const getDefaultAppState = (): Omit<
type: "selection", type: "selection",
customType: null, customType: null,
locked: DEFAULT_ELEMENT_PROPS.locked, locked: DEFAULT_ELEMENT_PROPS.locked,
fromSelection: false,
lastActiveTool: null, lastActiveTool: null,
}, },
penMode: false, penMode: false,

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;

View file

@ -62,6 +62,7 @@ import {
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
MagicIcon, MagicIcon,
LassoIcon,
} from "./icons"; } from "./icons";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
@ -83,7 +84,6 @@ export const canChangeStrokeColor = (
return ( return (
(hasStrokeColor(appState.activeTool.type) && (hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" && commonSelectedType !== "image" &&
commonSelectedType !== "frame" && commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") || commonSelectedType !== "magicframe") ||
@ -295,6 +295,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame"; const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser"; const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const embeddableToolSelected = activeTool.type === "embeddable"; const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels(); const { TTDDialogTriggerTunnel } = useTunnels();
@ -316,6 +318,7 @@ export const ShapesSwitcher = ({
const shortcut = letter const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}` ? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`; : `${numericKey}`;
return ( return (
<ToolButton <ToolButton
className={clsx("Shape", { fillable })} className={clsx("Shape", { fillable })}
@ -333,6 +336,14 @@ export const ShapesSwitcher = ({
if (!appState.penDetected && pointerType === "pen") { if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true); app.togglePenMode(true);
} }
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}} }}
onChange={({ pointerType }) => { onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) { if (appState.activeTool.type !== value) {
@ -358,6 +369,7 @@ export const ShapesSwitcher = ({
"App-toolbar__extra-tools-trigger--selected": "App-toolbar__extra-tools-trigger--selected":
frameToolSelected || frameToolSelected ||
embeddableToolSelected || embeddableToolSelected ||
lassoToolSelected ||
// in collab we're already highlighting the laser button // in collab we're already highlighting the laser button
// outside toolbar, so let's not highlight extra-tools button // outside toolbar, so let's not highlight extra-tools button
// on top of it // on top of it
@ -366,7 +378,15 @@ export const ShapesSwitcher = ({
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")} title={t("toolBar.extraTools")}
> >
{extraToolsIcon} {frameToolSelected
? frameToolIcon
: embeddableToolSelected
? EmbedIcon
: laserToolSelected && !app.props.isCollaborating
? laserPointerToolIcon
: lassoToolSelected
? LassoIcon
: extraToolsIcon}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)} onClickOutside={() => setIsExtraToolsMenuOpen(false)}
@ -399,6 +419,14 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate Generate
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -27,16 +27,22 @@
.color-picker__top-picks { .color-picker__top-picks {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
} }
.color-picker__button { .color-picker__button {
--radius: 0.25rem; --radius: 4px;
--size: 1.375rem;
&.has-outline {
box-shadow: inset 0 0 0 1px #d9d9d9;
}
padding: 0; padding: 0;
margin: 0; margin: 0;
width: 1.35rem; width: var(--size);
height: 1.35rem; height: var(--size);
border: 1px solid var(--color-gray-30); border: 0;
border-radius: var(--radius); border-radius: var(--radius);
filter: var(--theme-filter); filter: var(--theme-filter);
background-color: var(--swatch-color); background-color: var(--swatch-color);
@ -45,16 +51,20 @@
font-family: inherit; font-family: inherit;
box-sizing: border-box; box-sizing: border-box;
&:hover { &:hover:not(.active):not(.color-picker__button--large) {
transform: scale(1.075);
}
&:hover:not(.active).color-picker__button--large {
&::after { &::after {
content: ""; content: "";
position: absolute; position: absolute;
top: -2px; top: -1px;
left: -2px; left: -1px;
right: -2px; right: -1px;
bottom: -2px; bottom: -1px;
box-shadow: 0 0 0 1px var(--color-gray-30); box-shadow: 0 0 0 1px var(--color-gray-30);
border-radius: calc(var(--radius) + 1px); border-radius: var(--radius);
filter: var(--theme-filter); filter: var(--theme-filter);
} }
} }
@ -62,13 +72,14 @@
&.active { &.active {
.color-picker__button-outline { .color-picker__button-outline {
position: absolute; position: absolute;
top: -2px; --offset: -1px;
left: -2px; top: var(--offset);
right: -2px; left: var(--offset);
bottom: -2px; right: var(--offset);
bottom: var(--offset);
box-shadow: 0 0 0 1px var(--color-primary-darkest); box-shadow: 0 0 0 1px var(--color-primary-darkest);
z-index: 1; // due hover state so this has preference z-index: 1; // due hover state so this has preference
border-radius: calc(var(--radius) + 1px); border-radius: var(--radius);
filter: var(--theme-filter); filter: var(--theme-filter);
} }
} }
@ -123,10 +134,11 @@
.color-picker__button__hotkey-label { .color-picker__button__hotkey-label {
position: absolute; position: absolute;
right: 4px; right: 5px;
bottom: 4px; bottom: 3px;
filter: none; filter: none;
font-size: 11px; font-size: 11px;
font-weight: 500;
} }
.color-picker { .color-picker {

View file

@ -2,7 +2,11 @@ import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx"; import clsx from "clsx";
import { useRef } from "react"; import { useRef } from "react";
import { COLOR_PALETTE, isTransparent } from "@excalidraw/common"; import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
COLOR_PALETTE,
isTransparent,
} from "@excalidraw/common";
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@ -19,7 +23,7 @@ import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker"; import { Picker } from "./Picker";
import PickerHeading from "./PickerHeading"; import PickerHeading from "./PickerHeading";
import { TopPicks } from "./TopPicks"; import { TopPicks } from "./TopPicks";
import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import { activeColorPickerSectionAtom, isColorDark } from "./colorPickerUtils";
import "./ColorPicker.scss"; import "./ColorPicker.scss";
@ -190,6 +194,7 @@ const ColorPickerTrigger = ({
type="button" type="button"
className={clsx("color-picker__button active-color properties-trigger", { className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
})} })}
aria-label={label} aria-label={label}
style={color ? { "--swatch-color": color } : undefined} style={color ? { "--swatch-color": color } : undefined}

View file

@ -40,7 +40,7 @@ export const CustomColorList = ({
tabIndex={-1} tabIndex={-1}
type="button" type="button"
className={clsx( className={clsx(
"color-picker__button color-picker__button--large", "color-picker__button color-picker__button--large has-outline",
{ {
active: color === c, active: color === c,
"is-transparent": c === "transparent" || !c, "is-transparent": c === "transparent" || !c,
@ -56,7 +56,7 @@ export const CustomColorList = ({
key={i} key={i}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor /> <HotkeyLabel color={c} keyLabel={i + 1} />
</button> </button>
); );
})} })}

View file

@ -1,24 +1,22 @@
import React from "react"; import React from "react";
import { getContrastYIQ } from "./colorPickerUtils"; import { isColorDark } from "./colorPickerUtils";
interface HotkeyLabelProps { interface HotkeyLabelProps {
color: string; color: string;
keyLabel: string | number; keyLabel: string | number;
isCustomColor?: boolean;
isShade?: boolean; isShade?: boolean;
} }
const HotkeyLabel = ({ const HotkeyLabel = ({
color, color,
keyLabel, keyLabel,
isCustomColor = false,
isShade = false, isShade = false,
}: HotkeyLabelProps) => { }: HotkeyLabelProps) => {
return ( return (
<div <div
className="color-picker__button__hotkey-label" className="color-picker__button__hotkey-label"
style={{ style={{
color: getContrastYIQ(color, isCustomColor), color: isColorDark(color) ? "#fff" : "#000",
}} }}
> >
{isShade && "⇧"} {isShade && "⇧"}

View file

@ -65,7 +65,7 @@ const PickerColorList = ({
tabIndex={-1} tabIndex={-1}
type="button" type="button"
className={clsx( className={clsx(
"color-picker__button color-picker__button--large", "color-picker__button color-picker__button--large has-outline",
{ {
active: colorObj?.colorName === key, active: colorObj?.colorName === key,
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,

View file

@ -55,7 +55,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
key={i} key={i}
type="button" type="button"
className={clsx( className={clsx(
"color-picker__button color-picker__button--large", "color-picker__button color-picker__button--large has-outline",
{ active: i === shade }, { active: i === shade },
)} )}
aria-label="Shade" aria-label="Shade"

View file

@ -1,11 +1,14 @@
import clsx from "clsx"; import clsx from "clsx";
import { import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_PICKS, DEFAULT_ELEMENT_STROKE_PICKS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isColorDark } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils";
interface TopPicksProps { interface TopPicksProps {
@ -51,6 +54,10 @@ export const TopPicks = ({
className={clsx("color-picker__button", { className={clsx("color-picker__button", {
active: color === activeColor, active: color === activeColor,
"is-transparent": color === "transparent" || !color, "is-transparent": color === "transparent" || !color,
"has-outline": !isColorDark(
color,
COLOR_OUTLINE_CONTRAST_THRESHOLD,
),
})} })}
style={{ "--swatch-color": color }} style={{ "--swatch-color": color }}
key={color} key={color}

View file

@ -93,19 +93,42 @@ export type ActiveColorPickerSectionAtomType =
export const activeColorPickerSectionAtom = export const activeColorPickerSectionAtom =
atom<ActiveColorPickerSectionAtomType>(null); atom<ActiveColorPickerSectionAtomType>(null);
const calculateContrast = (r: number, g: number, b: number) => { const calculateContrast = (r: number, g: number, b: number): number => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000; const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 160 ? "black" : "white"; return yiq;
}; };
// inspiration from https://stackoverflow.com/a/11868398 // YIQ algo, inspiration from https://stackoverflow.com/a/11868398
export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => { export const isColorDark = (color: string, threshold = 160): boolean => {
if (isCustomColor) { // no color ("") -> assume it default to black
const style = new Option().style; if (!color) {
style.color = bgHex; return true;
}
if (style.color) { if (color === "transparent") {
const rgb = style.color return false;
}
// a string color (white etc) or any other format -> convert to rgb by way
// of creating a DOM node and retrieving the computeStyle
if (!color.startsWith("#")) {
const node = document.createElement("div");
node.style.color = color;
if (node.style.color) {
// making invisible so document doesn't reflow (hopefully).
// display=none works too, but supposedly not in all browsers
node.style.position = "absolute";
node.style.visibility = "hidden";
node.style.width = "0";
node.style.height = "0";
// needs to be in DOM else browser won't compute the style
document.body.appendChild(node);
const computedColor = getComputedStyle(node).color;
document.body.removeChild(node);
// computed style is in rgb() format
const rgb = computedColor
.replace(/^(rgb|rgba)\(/, "") .replace(/^(rgb|rgba)\(/, "")
.replace(/\)$/, "") .replace(/\)$/, "")
.replace(/\s/g, "") .replace(/\s/g, "")
@ -114,20 +137,17 @@ export const getContrastYIQ = (bgHex: string, isCustomColor: boolean) => {
const g = parseInt(rgb[1]); const g = parseInt(rgb[1]);
const b = parseInt(rgb[2]); const b = parseInt(rgb[2]);
return calculateContrast(r, g, b); return calculateContrast(r, g, b) < threshold;
} }
// invalid color -> assume it default to black
return true;
} }
// TODO: ? is this wanted? const r = parseInt(color.slice(1, 3), 16);
if (bgHex === "transparent") { const g = parseInt(color.slice(3, 5), 16);
return "black"; const b = parseInt(color.slice(5, 7), 16);
}
const r = parseInt(bgHex.substring(1, 3), 16); return calculateContrast(r, g, b) < threshold;
const g = parseInt(bgHex.substring(3, 5), 16);
const b = parseInt(bgHex.substring(5, 7), 16);
return calculateContrast(r, g, b);
}; };
export type ColorPickerType = export type ColorPickerType =

View file

@ -315,6 +315,7 @@ function CommandPaletteInner({
const toolCommands: CommandPaletteItem[] = [ const toolCommands: CommandPaletteItem[] = [
actionManager.actions.toggleHandTool, actionManager.actions.toggleHandTool,
actionManager.actions.setFrameAsActiveTool, actionManager.actions.setFrameAsActiveTool,
actionManager.actions.toggleLassoTool,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools)); ].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
const editorCommands: CommandPaletteItem[] = [ const editorCommands: CommandPaletteItem[] = [

View file

@ -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) => {

View file

@ -120,7 +120,7 @@ const getHints = ({
!appState.editingTextElement && !appState.editingTextElement &&
!appState.editingLinearElement !appState.editingLinearElement
) { ) {
return t("hints.deepBoxSelect"); return [t("hints.deepBoxSelect")];
} }
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
@ -128,7 +128,7 @@ const getHints = ({
} }
if (!selectedElements.length && !isMobile) { if (!selectedElements.length && !isMobile) {
return t("hints.canvasPanning"); return [t("hints.canvasPanning")];
} }
if (selectedElements.length === 1) { if (selectedElements.length === 1) {

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}
/> />

View file

@ -166,7 +166,7 @@ export default function LibraryMenuItems({
type: "everything", type: "everything",
elements: item.elements, elements: item.elements,
randomizeSeed: true, randomizeSeed: true,
}).newElements, }).duplicatedElements,
}; };
}); });
}, },

View file

@ -6,7 +6,7 @@
.range-wrapper { .range-wrapper {
position: relative; position: relative;
padding-top: 10px; padding-top: 10px;
padding-bottom: 30px; padding-bottom: 25px;
} }
.range-input { .range-input {

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,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, updateBindings } from "./utils"; import { getStepSizedValue, isPropertyEditable, updateBindings } 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 +34,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 +43,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 +69,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 });
} }
} }
}; };

View file

@ -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 {

View file

@ -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,

View file

@ -2,10 +2,12 @@
.drag-input-container { .drag-input-container {
display: flex; display: flex;
width: 100%; width: 100%;
border-radius: var(--border-radius-lg);
&:focus-within { &:focus-within {
box-shadow: 0 0 0 1px var(--color-primary-darkest); box-shadow: 0 0 0 1px var(--color-primary-darkest);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background: transparent;
} }
} }
@ -16,24 +18,14 @@
.drag-input-label { .drag-input-label {
flex-shrink: 0; flex-shrink: 0;
border: 1px solid var(--default-border-color); border: 0;
border-right: 0; padding: 0 0.5rem 0 0.25rem;
padding: 0 0.5rem 0 0.75rem;
min-width: 1rem; min-width: 1rem;
width: 1.5rem;
height: 2rem; height: 2rem;
box-sizing: border-box; box-sizing: content-box;
color: var(--popup-text-color); color: var(--popup-text-color);
:root[dir="ltr"] & {
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
}
:root[dir="rtl"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
border-right: 1px solid var(--default-border-color);
border-left: 0;
}
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -51,20 +43,8 @@
border: 0; border: 0;
outline: none; outline: none;
height: 2rem; height: 2rem;
border: 1px solid var(--default-border-color);
border-left: 0;
letter-spacing: 0.4px; letter-spacing: 0.4px;
:root[dir="ltr"] & {
border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
}
:root[dir="rtl"] & {
border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
border-left: 1px solid var(--default-border-color);
border-right: 0;
}
padding: 0.5rem; padding: 0.5rem;
padding-left: 0.25rem; padding-left: 0.25rem;
appearance: none; appearance: none;

View file

@ -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,

View file

@ -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,
); );
} }
} }

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 } 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();

View file

@ -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,

View file

@ -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,
); );
} }

View file

@ -5,12 +5,9 @@ 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 } from "./utils";
@ -18,7 +15,6 @@ 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 {
@ -36,13 +32,11 @@ 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 +59,6 @@ const moveElements = (
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@ -78,11 +70,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 +103,6 @@ const moveGroupTo = (
topLeftX + offsetX, topLeftX + offsetX,
topLeftY + offsetY, topLeftY + offsetY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@ -135,7 +124,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 +147,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 +174,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
false, false,
@ -214,8 +198,6 @@ const handlePositionChange: DragInputCallbackType<
changeInTopX, changeInTopX,
changeInTopY, changeInTopY,
originalElements, originalElements,
originalElements,
elementsMap,
originalElementsMap, originalElementsMap,
scene, scene,
); );

View file

@ -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 } 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 {
@ -38,7 +38,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 +100,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
}; };
} }
mutateElement(element, { scene.mutateElement(element, {
crop: nextCrop, crop: nextCrop,
}); });
@ -119,7 +118,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 +132,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
); );
@ -166,8 +163,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftX, newTopLeftX,
newTopLeftY, newTopLeftY,
origElement, origElement,
elementsMap,
elements,
scene, scene,
originalElementsMap, originalElementsMap,
); );

View file

@ -41,6 +41,10 @@
div + div { div + div {
text-align: right; text-align: right;
} }
&:empty {
display: none;
}
} }
&__row--heading { &__row--heading {

View file

@ -289,7 +289,11 @@ export const StatsInner = memo(
</StatsRow> </StatsRow>
)} )}
<StatsRow heading data-testid="stats-element-type"> <StatsRow
heading
data-testid="stats-element-type"
style={{ margin: "0.3125rem 0" }}
>
{appState.croppingElementId {appState.croppingElementId
? t("labels.imageCropping") ? t("labels.imageCropping")
: t(`element.${singleElement.type}`)} : t(`element.${singleElement.type}`)}

View file

@ -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]);

View file

@ -4,7 +4,6 @@ import {
bindOrUnbindLinearElements, bindOrUnbindLinearElements,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element/binding"; } 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,
@ -24,10 +23,10 @@ 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 type { AppState } from "../../types"; import type { AppState } from "../../types";
export type StatsInputProperty = export type StatsInputProperty =
@ -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,13 +163,13 @@ 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 },
); );
} }
}; };
@ -199,8 +197,6 @@ export const getAtomicUnits = (
export const updateBindings = ( export const updateBindings = (
latestElement: ExcalidrawElement, latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene, scene: Scene,
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
@ -209,16 +205,8 @@ export const updateBindings = (
}, },
) => { ) => {
if (isLinearElement(latestElement)) { if (isLinearElement(latestElement)) {
bindOrUnbindLinearElements( bindOrUnbindLinearElements([latestElement], true, [], scene, options?.zoom);
[latestElement],
elementsMap,
elements,
scene,
true,
[],
options?.zoom,
);
} else { } else {
updateBoundElements(latestElement, elementsMap, options); updateBoundElements(latestElement, scene, options);
} }
}; };

View file

@ -34,6 +34,7 @@ type InteractiveCanvasProps = {
selectionNonce: number | undefined; selectionNonce: number | undefined;
scale: number; scale: number;
appState: InteractiveCanvasAppState; appState: InteractiveCanvasAppState;
renderScrollbars: boolean;
device: Device; device: Device;
renderInteractiveSceneCallback: ( renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback, data: RenderInteractiveSceneCallback,
@ -143,7 +144,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
remotePointerUsernames, remotePointerUsernames,
remotePointerUserStates, remotePointerUserStates,
selectionColor, selectionColor,
renderScrollbars: false, renderScrollbars: props.renderScrollbars,
}, },
device: props.device, device: props.device,
callback: props.renderInteractiveSceneCallback, callback: props.renderInteractiveSceneCallback,
@ -230,7 +231,8 @@ const areEqual = (
// on appState) // on appState)
prevProps.elementsMap !== nextProps.elementsMap || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements || prevProps.visibleElements !== nextProps.visibleElements ||
prevProps.selectedElements !== nextProps.selectedElements prevProps.selectedElements !== nextProps.selectedElements ||
prevProps.renderScrollbars !== nextProps.renderScrollbars
) { ) {
return false; return false;
} }

View file

@ -87,34 +87,36 @@ const StaticCanvas = (props: StaticCanvasProps) => {
return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />; return <div className="excalidraw__canvas-wrapper" ref={wrapperRef} />;
}; };
const getRelevantAppStateProps = ( const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
appState: AppState, const relevantAppStateProps = {
): StaticCanvasAppState => ({ zoom: appState.zoom,
zoom: appState.zoom, scrollX: appState.scrollX,
scrollX: appState.scrollX, scrollY: appState.scrollY,
scrollY: appState.scrollY, width: appState.width,
width: appState.width, height: appState.height,
height: appState.height, viewModeEnabled: appState.viewModeEnabled,
viewModeEnabled: appState.viewModeEnabled, openDialog: appState.openDialog,
openDialog: appState.openDialog, hoveredElementIds: appState.hoveredElementIds,
hoveredElementIds: appState.hoveredElementIds, offsetLeft: appState.offsetLeft,
offsetLeft: appState.offsetLeft, offsetTop: appState.offsetTop,
offsetTop: appState.offsetTop, theme: appState.theme,
theme: appState.theme, pendingImageElementId: appState.pendingImageElementId,
pendingImageElementId: appState.pendingImageElementId, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, viewBackgroundColor: appState.viewBackgroundColor,
viewBackgroundColor: appState.viewBackgroundColor, exportScale: appState.exportScale,
exportScale: appState.exportScale, selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged,
selectedElementsAreBeingDragged: appState.selectedElementsAreBeingDragged, gridSize: appState.gridSize,
gridSize: appState.gridSize, gridStep: appState.gridStep,
gridStep: appState.gridStep, frameRendering: appState.frameRendering,
frameRendering: appState.frameRendering, selectedElementIds: appState.selectedElementIds,
selectedElementIds: appState.selectedElementIds, frameToHighlight: appState.frameToHighlight,
frameToHighlight: appState.frameToHighlight, editingGroupId: appState.editingGroupId,
editingGroupId: appState.editingGroupId, currentHoveredFontFamily: appState.currentHoveredFontFamily,
currentHoveredFontFamily: appState.currentHoveredFontFamily, croppingElementId: appState.croppingElementId,
croppingElementId: appState.croppingElementId, };
});
return relevantAppStateProps;
};
const areEqual = ( const areEqual = (
prevProps: StaticCanvasProps, prevProps: StaticCanvasProps,

View file

@ -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");

View file

@ -274,6 +274,21 @@ export const SelectionIcon = createIcon(
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 }, { fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
); );
export const LassoIcon = createIcon(
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
>
<path d="M4.028 13.252c-.657 -.972 -1.028 -2.078 -1.028 -3.252c0 -3.866 4.03 -7 9 -7s9 3.134 9 7s-4.03 7 -9 7c-1.913 0 -3.686 -.464 -5.144 -1.255" />
<path d="M5 15m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 17c0 1.42 .316 2.805 1 4" />
</g>,
{ fill: "none", width: 22, height: 22, strokeWidth: 1.25 },
);
// tabler-icons: square // tabler-icons: square
export const RectangleIcon = createIcon( export const RectangleIcon = createIcon(
<g strokeWidth="1.5"> <g strokeWidth="1.5">
@ -406,7 +421,7 @@ export const TrashIcon = createIcon(
); );
export const EmbedIcon = createIcon( export const EmbedIcon = createIcon(
<g strokeWidth="1.25"> <g strokeWidth="1.5">
<polyline points="12 16 18 10 12 4" /> <polyline points="12 16 18 10 12 4" />
<polyline points="8 4 2 10 8 16" /> <polyline points="8 4 2 10 8 16" />
</g>, </g>,

View file

@ -148,7 +148,7 @@
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
--color-surface-high: #f1f0ff; --color-surface-high: #f1f0ff;
--color-surface-mid: #f2f2f7; --color-surface-mid: #f6f6f9;
--color-surface-low: #ececf4; --color-surface-low: #ececf4;
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
@ -252,7 +252,7 @@
--color-logo-text: #e2dfff; --color-logo-text: #e2dfff;
--color-surface-high: hsl(245, 10%, 21%); --color-surface-high: #2e2d39;
--color-surface-low: hsl(240, 8%, 15%); --color-surface-low: hsl(240, 8%, 15%);
--color-surface-mid: hsl(240 6% 10%); --color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%); --color-surface-lowest: hsl(0, 0%, 7%);

View file

@ -104,12 +104,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0.5, 0,
], ],
[ [
394.5, 394,
34.5, 34,
], ],
], ],
"roughness": 1, "roughness": 1,
@ -129,8 +129,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 395, "width": 395,
"x": 247, "x": 247.5,
"y": 420, "y": 420.5,
} }
`; `;
@ -160,11 +160,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
399.5, 399,
0, 0,
], ],
], ],
@ -185,7 +185,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 400, "width": 400,
"x": 227, "x": 227.5,
"y": 450, "y": 450,
} }
`; `;
@ -350,11 +350,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -375,7 +375,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255.5,
"y": 239, "y": 239,
} }
`; `;
@ -452,11 +452,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -477,7 +477,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255.5,
"y": 239, "y": 239,
} }
`; `;
@ -628,11 +628,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -653,7 +653,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 255, "x": 255.5,
"y": 239, "y": 239,
} }
`; `;
@ -845,11 +845,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 100, "x": 100.5,
"y": 20, "y": 20,
} }
`; `;
@ -893,11 +893,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -914,7 +914,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 450, "x": 450.5,
"y": 20, "y": 20,
} }
`; `;
@ -1490,11 +1490,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
272.485, 271.985,
0, 0,
], ],
], ],
@ -1517,7 +1517,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"version": 4, "version": 4,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 272.985, "width": 272.985,
"x": 111.262, "x": 111.762,
"y": 57, "y": 57,
} }
`; `;
@ -1862,11 +1862,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -1883,7 +1883,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 100, "x": 100.5,
"y": 100, "y": 100,
} }
`; `;
@ -1915,11 +1915,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -1936,7 +1936,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 100, "x": 100.5,
"y": 200, "y": 200,
} }
`; `;
@ -1968,11 +1968,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -1989,7 +1989,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 100, "x": 100.5,
"y": 300, "y": 300,
} }
`; `;
@ -2021,11 +2021,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0.5, 0,
0, 0,
], ],
[ [
99.5, 99,
0, 0,
], ],
], ],
@ -2042,7 +2042,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 100, "x": 100.5,
"y": 400, "y": 400,
} }
`; `;

View file

@ -5,6 +5,7 @@ import {
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@ -134,7 +135,11 @@ export const exportCanvas = async (
if (type === "svg") { if (type === "svg") {
return fileSave( return fileSave(
svgPromise.then((svg) => { svgPromise.then((svg) => {
return new Blob([svg.outerHTML], { type: MIME_TYPES.svg }); // adding SVG preamble so that older software parse the SVG file
// properly
return new Blob([SVG_DOCUMENT_PREAMBLE + svg.outerHTML], {
type: MIME_TYPES.svg,
});
}), }),
{ {
description: "Export to SVG", description: "Export to SVG",

View file

@ -86,6 +86,7 @@ export const AllowedExcalidrawActiveTools: Record<
boolean boolean
> = { > = {
selection: true, selection: true,
lasso: true,
text: true, text: true,
rectangle: true, rectangle: true,
diamond: true, diamond: true,
@ -221,7 +222,7 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData; "customData" in extra ? extra.customData : element.customData;
} }
return { const ret = {
// spread the original element properties to not lose unknown ones // spread the original element properties to not lose unknown ones
// for forward-compatibility // for forward-compatibility
...element, ...element,
@ -230,6 +231,12 @@ const restoreElementWithProperties = <
...getNormalizedDimensions(base), ...getNormalizedDimensions(base),
...extra, ...extra,
} as unknown as T; } as unknown as T;
// strip legacy props (migrated in previous steps)
delete ret.strokeSharpness;
delete ret.boundElementIds;
return ret;
}; };
const restoreElement = ( const restoreElement = (
@ -432,7 +439,7 @@ const repairContainerElement = (
// if defined, lest boundElements is stale // if defined, lest boundElements is stale
!boundElement.containerId !boundElement.containerId
) { ) {
(boundElement as Mutable<ExcalidrawTextElement>).containerId = (boundElement as Mutable<typeof boundElement>).containerId =
container.id; container.id;
} }
} }
@ -457,6 +464,10 @@ const repairBoundElement = (
? elementsMap.get(boundElement.containerId) ? elementsMap.get(boundElement.containerId)
: null; : null;
(boundElement as Mutable<typeof boundElement>).angle = (
isArrowElement(container) ? 0 : container?.angle ?? 0
) as Radians;
if (!container) { if (!container) {
boundElement.containerId = null; boundElement.containerId = null;
return; return;

Some files were not shown because too many files have changed in this diff Show more