snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"extends": ["../eslintrc.base.json"]
}

View File

@@ -0,0 +1,19 @@
# @excalidraw/element
## Install
```bash
npm install @excalidraw/element
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/element
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/element
```

3
packages/element/global.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

View File

@@ -0,0 +1,70 @@
{
"name": "@excalidraw/element",
"version": "0.18.0",
"type": "module",
"types": "./dist/types/element/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/element/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./*": {
"types": "./dist/types/element/src/*.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
},
"./visualdebug": {
"types": "./dist/types/element/src/visualdebug.d.ts",
"development": "./dist/dev/visualdebug.js",
"production": "./dist/prod/visualdebug.js",
"default": "./dist/prod/visualdebug.js"
}
},
"files": [
"dist/*"
],
"description": "Excalidraw elements-related logic",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"keywords": [
"excalidraw",
"excalidraw-utils"
],
"browserslist": {
"production": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all",
"not safari < 12",
"not kaios <= 2.5",
"not edge < 79",
"not chrome < 70",
"not and_uc < 13",
"not samsung < 10"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"dependencies": {
"@excalidraw/common": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/fractional-indexing": "3.3.0"
}
}

View File

@@ -0,0 +1,446 @@
import throttle from "lodash.throttle";
import {
randomInteger,
arrayToMap,
toBrandedType,
isDevEnv,
isTestEnv,
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element";
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawFrameLikeElement,
ElementsMapOrArray,
SceneElementsMap,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
Ordered,
} from "@excalidraw/element/types";
import type {
Assert,
Mutable,
SameType,
} from "@excalidraw/common/utility-types";
import type { AppState } from "../../excalidraw/types";
type SceneStateCallback = () => void;
type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" };
const getNonDeletedElements = <T extends ExcalidrawElement>(
allElements: readonly T[],
) => {
const elementsMap = new Map() as NonDeletedSceneElementsMap;
const elements: NonDeleted<T>[] = [];
for (const element of allElements) {
if (!element.isDeleted) {
elements.push(element as NonDeleted<T>);
elementsMap.set(
element.id,
element as Ordered<NonDeletedExcalidrawElement>,
);
}
}
return { elementsMap, elements };
};
const validateIndicesThrottled = throttle(
(elements: readonly ExcalidrawElement[]) => {
if (isDevEnv() || isTestEnv() || window?.DEBUG_FRACTIONAL_INDICES) {
validateFractionalIndices(elements, {
// throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
shouldThrow: isDevEnv() || isTestEnv(),
includeBoundTextValidation: true,
});
}
},
1000 * 60,
{ leading: true, trailing: false },
);
const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => {
const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
// just to ensure we're hashing all expected keys
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _ = Assert<
SameType<
Required<HashableKeys>,
Pick<Required<HashableKeys>, typeof keys[number]>
>
>;
let hash = "";
for (const key of keys) {
hash += `${key}:${opts[key] ? "1" : "0"}`;
}
return hash as SelectionHash;
};
// ideally this would be a branded type but it'd be insanely hard to work with
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
export class Scene {
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
private callbacks: Set<SceneStateCallback> = new Set();
private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
[];
private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
new Map(),
);
// ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
private elements: readonly OrderedExcalidrawElement[] = [];
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
[];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = toBrandedType<SceneElementsMap>(new Map());
private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null;
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
} = {
selectedElementIds: null,
elements: null,
cache: new Map(),
};
/**
* Random integer regenerated each scene update.
*
* Does not relate to elements versions, it's only a renderer
* cache-invalidation nonce at the moment.
*/
private sceneNonce: number | undefined;
getSceneNonce() {
return this.sceneNonce;
}
getNonDeletedElementsMap() {
return this.nonDeletedElementsMap;
}
getElementsIncludingDeleted() {
return this.elements;
}
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements() {
return this.nonDeletedElements;
}
getFramesIncludingDeleted() {
return this.frames;
}
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) {
this.replaceAllElements(elements, options);
}
}
getSelectedElements(opts: {
// NOTE can be ommitted by making Scene constructor require App instance
selectedElementIds: AppState["selectedElementIds"];
/**
* for specific cases where you need to use elements not from current
* scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case.
*/
elements?: ElementsMapOrArray;
// selection-related options
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
}): NonDeletedExcalidrawElement[] {
const hash = hashSelectionOpts(opts);
const elements = opts?.elements || this.nonDeletedElements;
if (
this.selectedElementsCache.elements === elements &&
this.selectedElementsCache.selectedElementIds === opts.selectedElementIds
) {
const cached = this.selectedElementsCache.cache.get(hash);
if (cached) {
return cached;
}
} else if (opts?.elements == null) {
// if we're operating on latest scene elements and the cache is not
// storing the latest elements, clear the cache
this.selectedElementsCache.cache.clear();
}
const selectedElements = getSelectedElements(
elements,
{ selectedElementIds: opts.selectedElementIds },
opts,
);
// cache only if we're not using custom elements
if (opts?.elements == null) {
this.selectedElementsCache.selectedElementIds = opts.selectedElementIds;
this.selectedElementsCache.elements = this.nonDeletedElements;
this.selectedElementsCache.cache.set(hash, selectedElements);
}
return selectedElements;
}
getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFramesLikes;
}
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
return (this.elementsMap.get(id) as T | undefined) || null;
}
getNonDeletedElement(
id: ExcalidrawElement["id"],
): NonDeleted<ExcalidrawElement> | null {
const element = this.getElement(id);
if (element && isNonDeletedElement(element)) {
return element;
}
return null;
}
/**
* A utility method to help with updating all scene elements, with the added
* performance optimization of not renewing the array if no change is made.
*
* Maps all current excalidraw elements, invoking the callback for each
* element. The callback should either return a new mapped element, or the
* original element if no changes are made. If no changes are made to any
* element, this results in a no-op. Otherwise, the newly mapped elements
* are set as the next scene's elements.
*
* @returns whether a change was made
*/
mapElements(
iteratee: (element: ExcalidrawElement) => ExcalidrawElement,
): boolean {
let didChange = false;
const newElements = this.elements.map((element) => {
const nextElement = iteratee(element);
if (nextElement !== element) {
didChange = true;
}
return nextElement;
});
if (didChange) {
this.replaceAllElements(newElements);
}
return didChange;
}
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();
this.elements.forEach((element) => {
if (isFrameLikeElement(element)) {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
this.nonDeletedElementsMap = nonDeletedElements.elementsMap;
this.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
this.triggerUpdate();
}
triggerUpdate() {
this.sceneNonce = randomInteger();
for (const callback of Array.from(this.callbacks)) {
callback();
}
}
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
if (this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.add(cb);
return () => {
if (!this.callbacks.has(cb)) {
throw new Error();
}
this.callbacks.delete(cb);
};
}
destroy() {
this.elements = [];
this.nonDeletedElements = [];
this.nonDeletedFramesLikes = [];
this.frames = [];
this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null;
this.selectedElementsCache.elements = null;
this.selectedElementsCache.cache.clear();
// done not for memory leaks, but to guard against possible late fires
// (I guess?)
this.callbacks.clear();
}
/** low-level - generally use app.insertNewElements() */
insertElementsAtIndex(
elements: ExcalidrawElement[],
/** null indicates end of the array */
index: number | null,
) {
if (!elements.length) {
return;
}
if (index === null) {
index = this.elements.length;
}
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
);
}
const nextElements = [
...this.elements.slice(0, index),
...elements,
...this.elements.slice(index),
];
syncMovedIndices(nextElements, arrayToMap(elements));
this.replaceAllElements(nextElements);
}
/** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId);
}
getContainerElement = (
element:
| (ExcalidrawElement & {
containerId: ExcalidrawElement["id"] | null;
})
| null,
) => {
if (!element) {
return null;
}
if (element.containerId) {
return this.getElement(element.containerId) || null;
}
return null;
};
getElementsFromId = (id: string): ExcalidrawElement[] => {
const elementsMap = this.getNonDeletedElementsMap();
// first check if the id is an element
const el = elementsMap.get(id);
if (el) {
return [el];
}
// then, check if the id is a group
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;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,60 @@
import { ROUNDNESS } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types";
import { Scene } from "../Scene";
import { addNewNodes } from "../flowchart";
import { newStickyNoteElement } from "../newElement";
import { isFlowchartNodeElement, isStickyNoteElement } from "../typeChecks";
describe("flowchart", () => {
it("creates connected sticky notes", () => {
const sticky = newStickyNoteElement({
type: "stickynote",
x: 100,
y: 100,
width: 240,
height: 260,
baseHeight: 220,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 2,
backgroundColor: "#ffec99",
strokeColor: "#1e1e1e",
strokeWidth: 2,
});
const scene = new Scene([sticky], { skipValidation: true });
const {
nodes: [nextNode, bindingArrow],
} = addNewNodes(
sticky,
{
currentItemEndArrowhead: "arrow",
} as AppState,
"right",
scene,
1,
);
expect(isFlowchartNodeElement(sticky)).toBe(true);
expect(isFlowchartNodeElement(nextNode)).toBe(true);
expect(isStickyNoteElement(nextNode)).toBe(true);
expect(nextNode).toMatchObject({
type: "stickynote",
x: sticky.x + sticky.width + 100,
y: sticky.y,
width: sticky.width,
height: sticky.height,
baseHeight: sticky.baseHeight,
roundness: sticky.roundness,
roughness: sticky.roughness,
backgroundColor: sticky.backgroundColor,
strokeColor: sticky.strokeColor,
strokeWidth: sticky.strokeWidth,
});
expect(bindingArrow).toMatchObject({
type: "arrow",
startBinding: { elementId: sticky.id },
endBinding: { elementId: nextNode.id },
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups";
import { getNonDeletedElements } from ".";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement, NonDeletedExcalidrawElement } from "./types";
export interface Alignment {
position: "start" | "center" | "end";
axis: "x" | "y";
}
export const alignElements = (
selectedElements: NonDeletedExcalidrawElement[],
alignment: Alignment,
scene: Scene,
appState: Readonly<AppState>,
): NonDeletedExcalidrawElement[] => {
const groups = getSelectedElementsByGroup(
selectedElements,
scene.getNonDeletedElementsMap(),
appState,
).map(getNonDeletedElements); // Nothing to align on deleted elements
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
return groups.flatMap((group) => {
const translation = calculateTranslation(
group,
selectionBoundingBox,
alignment,
);
return group.map((element) => {
// update element
const updatedEle = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
// update bound elements
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedEle;
});
});
};
const calculateTranslation = (
group: readonly ExcalidrawElement[],
selectionBoundingBox: BoundingBox,
{ axis, position }: Alignment,
): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group);
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
const noTranslation = { x: 0, y: 0 };
if (position === "start") {
return {
...noTranslation,
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
};
} else if (position === "end") {
return {
...noTranslation,
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
};
} // else if (position === "center") {
return {
...noTranslation,
[axis]:
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
};
};

View File

@@ -0,0 +1,288 @@
/**
* Text bound to an arrow endpoint — creating a text element that reads as a
* label for the tip of an arrow, placed and anchored so the arrow itself never
* moves.
*
* The App-side interaction (hover affordance, hit gating, creation/drag entry
* points) lives in `App.arrowText.ts`; the generic binding bookkeeping these
* helpers rely on stays in `binding.ts`.
*/
import { TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import {
pointDistance,
pointFromVector,
pointsEqual,
vector,
vectorDot,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import type { GlobalPoint } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import { getBindingGap, normalizeFixedPoint } from "./binding";
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
compareHeading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { getTextAnchorRatios } from "./newElement";
import { isArrowElement, isElbowArrow } from "./typeChecks";
import type { Heading } from "./heading";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawTextElement,
FixedPoint,
NonDeleted,
NonDeletedExcalidrawElement,
TextAlign,
VerticalAlign,
} from "./types";
export type ArrowEndpoint = {
arrow: NonDeleted<ExcalidrawArrowElement>;
startOrEnd: "start" | "end";
};
/**
* Finds a free (unbound) arrow endpoint under the cursor, front-most first.
* Used by the text tool to offer creating a text label anchored to the tip of
* an arrow.
*/
export const getUnboundArrowEndpointAtPoint = (
scenePointer: GlobalPoint,
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
zoom: AppState["zoom"],
): ArrowEndpoint | null => {
// front to back, so the top-most arrow wins when endpoints overlap
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (
!isArrowElement(element) ||
element.locked ||
element.points.length < 2
) {
continue;
}
// prefer the end (arrowhead) over the start when both are within range
for (const startOrEnd of ["end", "start"] as const) {
if (element[startOrEnd === "start" ? "startBinding" : "endBinding"]) {
continue;
}
// a zero-length tail has no direction to place a text along, so
// `getTextBindingForArrowEndpoint` would refuse the endpoint — don't
// offer it at all rather than highlight something a click won't honor
if (
pointsEqual(
element.points[
startOrEnd === "start" ? 0 : element.points.length - 1
],
element.points[
startOrEnd === "start" ? 1 : element.points.length - 2
],
)
) {
continue;
}
const point = LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
// same reach as grabbing a point handle in the linear editor
// (`LinearElementEditor.getPointIndexUnderCursor`), which can't be
// reused here: it reports whichever point is nearest, with no way to
// skip an endpoint that is already bound and fall through to the other
if (
pointDistance(scenePointer, point) * zoom.value <
LinearElementEditor.POINT_HANDLE_SIZE + 1
) {
return { arrow: element, startOrEnd };
}
}
}
return null;
};
/**
* Describes how a text element should be created so that it reads as a label
* for the given arrow endpoint.
*
* The arrow is treated as fixed: rather than routing the arrow to the text, we
* pick the side of the text the arrow should attach to (the one it already
* points at), and place the text so that side's midpoint lands on the existing
* endpoint. The alignment returned pins that same midpoint while the text is
* typed, so the arrow doesn't swing around during editing.
*/
export const getTextBindingForArrowEndpoint = (
arrow: NonDeleted<ExcalidrawArrowElement>,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
/**
* stroke width the text will be created with — the binding gap is derived
* from the *bind target*, so using the arrow's would offset the anchor by
* half the difference between the two
*/
targetStrokeWidth: number,
): {
/** the text-local ratio the arrow binds to (a side midpoint) */
fixedPoint: FixedPoint;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
/** scene position the text's bound side midpoint should sit at */
anchor: GlobalPoint;
} | null => {
const endpoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 0 : -1,
elementsMap,
);
const neighbor = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : -2,
elementsMap,
);
if (pointsEqual(endpoint, neighbor)) {
return null;
}
// The direction the arrow travels as it reaches this endpoint, snapped to
// the dominant axis — i.e. the direction the text should extend away from
// the tip.
//
// Deliberately not `vectorToHeading`: its tie-break resolves an exact 45°
// vector to a direction the arrow isn't travelling in at all (a perfectly
// down-right vector yields UP), which would put the text on the wrong side.
// Here a tie picks the horizontal axis, always with the correct sign.
const direction = vectorFromPoint(endpoint, neighbor);
const heading: Heading =
Math.abs(direction[0]) >= Math.abs(direction[1])
? direction[0] >= 0
? HEADING_RIGHT
: HEADING_LEFT
: direction[1] >= 0
? HEADING_DOWN
: HEADING_UP;
// Keep the tip off the text's bounding box by the usual binding gap so the
// arrowhead doesn't collide with the glyphs. Must match the gap
// `updateBoundPoint` will resolve the binding with, or the first text update
// shifts the arrow. Elbow arrows terminate on the fixed point itself rather
// than gap-outside the outline, so offsetting there would just shift them.
const gap = isElbowArrow(arrow)
? 0
: getBindingGap({ strokeWidth: targetStrokeWidth }, arrow);
// How far back along the arrow the bound side has to sit for the tip to
// stay exactly where it is.
//
// `updateBoundPoint` resolves an orbit binding by intersecting the arrow's
// own line with the target's outline grown by `gap`; it does not step along
// the bound side's normal. Offsetting the anchor perpendicular to the side
// would therefore leave the tip `gap * tan(angle)` off to one side on a
// diagonal arrow. Walking back along the arrow instead keeps the anchor both
// `gap` clear of the tip and collinear with the arrow, so the intersection
// lands back on the tip itself.
//
// `heading` is the dominant axis of the arrow direction, so it is never more
// than 45° away from it and the divisor stays >= cos(45°).
const awayFromTip = vectorNormalize(vectorFromPoint(neighbor, endpoint));
const alongHeading = Math.abs(
vectorDot(awayFromTip, vector(heading[0], heading[1])),
);
const anchor = pointFromVector(
vectorScale(awayFromTip, -gap / alongHeading),
endpoint,
);
if (compareHeading(heading, HEADING_RIGHT)) {
// text sits to the right of the tip -> bind its left side
return {
fixedPoint: normalizeFixedPoint([0, 0.5]),
anchor,
textAlign: TEXT_ALIGN.LEFT as TextAlign,
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
};
}
if (compareHeading(heading, HEADING_LEFT)) {
// text sits to the left of the tip -> bind its right side
return {
fixedPoint: normalizeFixedPoint([1, 0.5]),
anchor,
textAlign: TEXT_ALIGN.RIGHT as TextAlign,
verticalAlign: VERTICAL_ALIGN.MIDDLE as VerticalAlign,
};
}
if (compareHeading(heading, HEADING_DOWN)) {
// text sits below the tip -> bind its top side
return {
fixedPoint: normalizeFixedPoint([0.5, 0]),
anchor,
textAlign: TEXT_ALIGN.CENTER as TextAlign,
verticalAlign: VERTICAL_ALIGN.TOP as VerticalAlign,
};
}
// text sits above the tip -> bind its bottom side
return {
fixedPoint: normalizeFixedPoint([0.5, 1]),
anchor,
textAlign: TEXT_ALIGN.CENTER as TextAlign,
verticalAlign: VERTICAL_ALIGN.BOTTOM as VerticalAlign,
};
};
/**
* Whether an arrow endpoint is bound to this text — i.e. the text serves as an
* arrow-endpoint label. Checked on the arrows' own bindings: the text's
* `boundElements` only says an arrow relates to it, while the binding on the
* arrow is the authoritative side of the relationship.
*/
export const isEndpointBoundText = (
text: ExcalidrawTextElement,
elementsMap: ElementsMap,
): boolean =>
!!text.boundElements?.some(({ id, type }) => {
if (type !== "arrow") {
return false;
}
const arrow = elementsMap.get(id);
return (
isArrowElement(arrow) &&
(arrow.startBinding?.elementId === text.id ||
arrow.endBinding?.elementId === text.id)
);
});
/** the anchor a text bound to an arrow endpoint grows away from */
export const getEndpointBoundTextDragAnchor = (
newElement: ExcalidrawTextElement,
) => {
const anchorRatio = getTextAnchorRatios(newElement).x;
return {
anchorRatio,
anchorX: newElement.x + newElement.width * anchorRatio,
};
};

View File

@@ -0,0 +1,32 @@
import type { Arrowhead, AnyArrowhead } from "./types";
export const normalizeArrowhead = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
switch (arrowhead) {
case undefined:
case null:
return null;
case "dot":
return "circle";
case "crowfoot_one":
return "cardinality_one";
case "crowfoot_many":
return "cardinality_many";
case "crowfoot_one_or_many":
return "cardinality_one_or_many";
default:
return arrowhead;
}
};
export const getArrowheadForPicker = (
arrowhead: AnyArrowhead | null | undefined,
): Arrowhead | null => {
const normalizedArrowhead = normalizeArrowhead(arrowhead);
if (normalizedArrowhead === null) {
return null;
}
return normalizedArrowhead;
};

View File

@@ -0,0 +1,575 @@
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { invariant } from "@excalidraw/common";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import {
bindBindingElement,
calculateFixedPointForNonElbowArrowBinding,
FOCUS_POINT_SIZE,
getBindingGap,
getGlobalFixedPointForBindableElement,
isBindingEnabled,
maxBindingDistance_simple,
unbindBindingElement,
updateBoundPoint,
} from "../binding";
import {
isBindableElement,
isBindingElement,
isElbowArrow,
} from "../typeChecks";
import { LinearElementEditor } from "../linearElementEditor";
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
import { moveArrowAboveBindable } from "../zindex";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
NonDeleted,
FixedPointBinding,
NonDeletedSceneElementsMap,
PointsPositionUpdates,
} from "../types";
import type { Scene } from "../Scene";
export const isFocusPointVisible = (
focusPoint: GlobalPoint,
arrow: ExcalidrawArrowElement,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
appState: {
isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"];
},
startOrEnd: "start" | "end",
ignoreOverlap = false,
): boolean => {
// No focus point management for elbow arrows, because elbow arrows
// always have their focus point at the arrow point itself
if (
isElbowArrow(arrow) ||
!isBindingEnabled(appState) ||
arrow.points.length !== 2
) {
return false;
}
// Avoid showing the focus point indicator if the focus point is essentially
// on top of the arrow point it belongs to itself, if not ignoring specifically
if (!ignoreOverlap) {
const associatedPointIdx =
arrow.startBinding?.elementId === bindableElement.id
? 0
: arrow.points.length - 1;
const associatedArrowPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
associatedPointIdx,
elementsMap,
);
if (
pointDistance(focusPoint, associatedArrowPoint) <
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
) {
return false;
}
}
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap,
);
// Check if the focus point is within the element's shape bounds
// Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
};
// Updates the arrow endpoints in "orbit" configuration
const focusPointUpdate = (
arrow: NonDeleted<ExcalidrawArrowElement>,
bindableElement: ExcalidrawBindableElement | null,
isStartBinding: boolean,
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
appState: AppState,
switchToInsideBinding: boolean,
) => {
const pointUpdates = new Map();
const originalAdjacentBinding =
appState.selectedLinearElement?.initialState
.arrowOtherEndpointInitialBinding;
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
let currentBinding = arrow[bindingField];
let adjacentBinding =
originalAdjacentBinding?.mode === "orbit" &&
arrow[adjacentBindingField]?.mode === "inside"
? originalAdjacentBinding
: arrow[adjacentBindingField];
// Update the dragged focus point related end
if (currentBinding && bindableElement) {
// Update the targeted bindings
const boundToSameElement =
bindableElement &&
adjacentBinding &&
currentBinding.elementId === adjacentBinding.elementId;
if (switchToInsideBinding || boundToSameElement) {
currentBinding = {
...currentBinding,
mode: "inside",
};
} else {
currentBinding = {
...currentBinding,
mode: "orbit",
};
}
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
const newPoint = updateBoundPoint(
arrow,
bindingField as "startBinding" | "endBinding",
currentBinding,
bindableElement,
elementsMap,
true,
);
if (newPoint) {
pointUpdates.set(pointIndex, { point: newPoint });
}
}
// Also update the adjacent end if it has a binding
if (adjacentBinding && adjacentBinding.mode === "orbit") {
const adjacentBindableElement = elementsMap.get(
adjacentBinding.elementId,
) as ExcalidrawBindableElement;
if (
adjacentBindableElement &&
isBindableElement(adjacentBindableElement) &&
isBindingEnabled(appState)
) {
// Same shape bound on both ends
const boundToSameElementAfterUpdate =
bindableElement && adjacentBinding.elementId === bindableElement.id;
if (boundToSameElementAfterUpdate) {
adjacentBinding = {
...adjacentBinding,
mode: "inside",
};
} else {
adjacentBinding = {
...adjacentBinding,
mode: "orbit",
};
}
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
const adjacentNewPoint = updateBoundPoint(
arrow,
adjacentBindingField,
adjacentBinding,
adjacentBindableElement,
elementsMap,
);
if (adjacentNewPoint) {
pointUpdates.set(adjacentPointIndex, {
point: adjacentNewPoint,
});
}
}
}
if (pointUpdates.size > 0) {
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
[bindingField]: currentBinding,
[adjacentBindingField]: adjacentBinding,
});
}
};
export const handleFocusPointDrag = (
linearElementEditor: LinearElementEditor,
elementsMap: NonDeletedSceneElementsMap,
pointerCoords: { x: number; y: number },
scene: Scene,
appState: AppState,
gridSize: NullableGridSize,
switchToInsideBinding: boolean,
) => {
const arrow = LinearElementEditor.getElement(
linearElementEditor.elementId,
elementsMap,
);
// Sanity checks
if (
!arrow ||
!isBindingElement(arrow) ||
isElbowArrow(arrow) ||
!linearElementEditor.hoveredFocusPointBinding ||
!linearElementEditor.draggedFocusPointBinding
) {
return;
}
const isStartBinding =
linearElementEditor.draggedFocusPointBinding === "start";
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
const point = pointFrom<GlobalPoint>(
pointerCoords.x - offsetX,
pointerCoords.y - offsetY,
);
const bindingField = isStartBinding ? "startBinding" : "endBinding";
const hit = getHoveredElementForFocusPoint(
point,
arrow,
scene.getNonDeletedElements(),
elementsMap,
maxBindingDistance_simple(appState.zoom),
);
// Hovering a bindable element
if (hit && isBindingEnabled(appState)) {
// Break existing binding if bound to another shape or if binding is disabled
if (arrow[bindingField] && hit.id !== binding?.elementId) {
unbindBindingElement(
arrow,
linearElementEditor.draggedFocusPointBinding,
scene,
);
}
// Handle binding mode switch
const newMode =
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
? "inside"
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
? "orbit"
: null;
// If no existing binding, create it
if (!arrow[bindingField] || newMode) {
// Create a new binding if none exists
bindBindingElement(
arrow,
hit,
newMode || "orbit",
linearElementEditor.draggedFocusPointBinding,
scene,
point,
);
}
// Update the binding's fixed point
scene.mutateElement(arrow, {
[bindingField]: {
...arrow[bindingField],
elementId: hit.id,
mode: newMode || arrow[bindingField]?.mode || "orbit",
...calculateFixedPointForNonElbowArrowBinding(
arrow,
hit,
linearElementEditor.draggedFocusPointBinding,
elementsMap,
point,
),
},
});
} else {
// Not hovering any bindable element, move the arrow endpoint
const pointUpdates: PointsPositionUpdates = new Map();
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
pointUpdates.set(pointIndex, {
point: LinearElementEditor.createPointAt(
arrow,
elementsMap,
point[0],
point[1],
gridSize,
),
});
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
if (arrow[bindingField]) {
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
}
}
// Update the arrow endpoints
focusPointUpdate(
arrow,
hit,
isStartBinding,
elementsMap,
scene,
appState,
switchToInsideBinding,
);
if (hit && isBindingEnabled(appState)) {
moveArrowAboveBindable(
point,
arrow,
scene.getElementsIncludingDeleted(),
elementsMap,
scene,
hit,
);
}
};
export const handleFocusPointPointerDown = (
arrow: ExcalidrawArrowElement,
pointerDownState: { origin: { x: number; y: number } },
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
): {
hitFocusPoint: "start" | "end" | null;
pointerOffset: { x: number; y: number };
arrowOtherEndpointInitialBinding: FixedPointBinding | null;
} => {
const pointerPos = pointFrom(
pointerDownState.origin.x,
pointerDownState.origin.y,
);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "start",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.endBinding,
};
}
}
}
// Check end binding focus point (only if start not already hit)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return {
hitFocusPoint: "end",
pointerOffset: {
x: pointerPos[0] - focusPoint[0],
y: pointerPos[1] - focusPoint[1],
},
arrowOtherEndpointInitialBinding: arrow.startBinding,
};
}
}
}
return {
hitFocusPoint: null,
pointerOffset: { x: 0, y: 0 },
arrowOtherEndpointInitialBinding: null,
};
};
export const handleFocusPointPointerUp = (
linearElementEditor: LinearElementEditor,
scene: Scene,
) => {
invariant(
linearElementEditor.draggedFocusPointBinding,
"Must have a dragged focus point at pointer release",
);
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
linearElementEditor.elementId,
scene.getNonDeletedElementsMap(),
);
invariant(arrow, "Arrow must be in the scene");
// Clean up
const bindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "startBinding"
: "endBinding";
const otherBindingKey =
linearElementEditor.draggedFocusPointBinding === "start"
? "endBinding"
: "startBinding";
const boundElementId = arrow[bindingKey]?.elementId;
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
const oldBoundElement =
boundElementId &&
scene
.getNonDeletedElements()
.find(
(element) =>
element.id !== boundElementId &&
element.id !== otherBoundElementId &&
isBindableElement(element) &&
element.boundElements?.find(({ id }) => id === arrow.id),
);
if (oldBoundElement) {
scene.mutateElement(oldBoundElement, {
boundElements: oldBoundElement.boundElements?.filter(
({ id }) => id !== arrow.id,
),
});
}
// Record the new bound element
const boundElement =
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
if (boundElement) {
scene.mutateElement(boundElement, {
boundElements: [
...(boundElement.boundElements || [])?.filter(
({ id }) => id !== arrow.id,
),
{
id: arrow.id,
type: "arrow",
},
],
});
}
return {
arrowOtherEndpointInitialBinding: null,
};
};
export const handleFocusPointHover = (
arrow: ExcalidrawArrowElement,
scenePointerX: number,
scenePointerY: number,
scene: Scene,
appState: AppState,
): "start" | "end" | null => {
const elementsMap = scene.getNonDeletedElementsMap();
const pointerPos = pointFrom(scenePointerX, scenePointerY);
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
// Check start binding focus point
if (arrow.startBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"start",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "start";
}
}
}
// Check end binding focus point (only if start not already hovered)
if (arrow.endBinding?.elementId) {
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
if (
bindableElement &&
isBindableElement(bindableElement) &&
!bindableElement.isDeleted
) {
const focusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
bindableElement,
elementsMap,
);
if (
isFocusPointVisible(
focusPoint,
arrow,
bindableElement,
elementsMap,
appState,
"end",
) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold
) {
return "end";
}
}
}
return null;
};

View File

@@ -0,0 +1,45 @@
import type { App } from "@excalidraw/excalidraw/types";
import { LinearElementEditor } from "../linearElementEditor";
import { handleFocusPointDrag } from "./focus";
export const maybeHandleArrowPointlikeDrag = ({
app,
event,
}: {
app: App;
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
}): boolean => {
const appState = app.state;
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
// Update focus point status if the binding mode is changing
if (appState.selectedLinearElement.draggedFocusPointBinding) {
handleFocusPointDrag(
appState.selectedLinearElement,
app.scene.getNonDeletedElementsMap(),
app.lastPointerMoveCoords,
app.scene,
appState,
app.getEffectiveGridSize(),
event.altKey,
);
return true;
} else if (
appState.selectedLinearElement.hoverPointIndex !== null &&
app.lastPointerMoveEvent &&
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
appState.selectedLinearElement.isDragging
) {
LinearElementEditor.handlePointDragging(
app.lastPointerMoveEvent,
app,
app.lastPointerMoveCoords.x,
app.lastPointerMoveCoords.y,
appState.selectedLinearElement,
);
return true;
}
}
return false;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,868 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
lineSegment,
lineSegmentIntersectionPoints,
pointFrom,
pointFromVector,
pointRotateRads,
pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math";
import {
ellipse,
ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse";
import type {
Curve,
GlobalPoint,
LineSegment,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
doBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import {
hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
import { hasBackground } from "./comparisons";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") {
return false;
}
const isDraggableFromInside =
(hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points);
}
if (element.type === "freedraw") {
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside || isImageElement(element);
};
export type HitTestArgs = {
point: GlobalPoint;
element: ExcalidrawElement;
threshold: number;
elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null;
overrideShouldTestInside?: boolean;
};
let cachedPoint: GlobalPoint | null = null;
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
let cachedThreshold: number = Infinity;
let cachedHit: boolean = false;
let cachedOverrideShouldTestInside = false;
let cachedFrameNameBound: FrameNameBounds | null = null;
const frameNameBoundsEqual = (
a: FrameNameBounds | null,
b: FrameNameBounds | null,
) =>
a === b ||
(!!a &&
!!b &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height);
export const hitElementItself = ({
point,
element,
threshold,
elementsMap,
frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => {
// Return cached result if the same point and element version is tested again.
// A cached hit stays valid for any larger threshold, while a cached miss
// stays valid only for a threshold no larger than the cached one (a larger
// threshold could turn a miss into a hit).
if (
cachedPoint &&
pointsEqual(point, cachedPoint) &&
(cachedHit ? cachedThreshold <= threshold : cachedThreshold >= threshold) &&
overrideShouldTestInside === cachedOverrideShouldTestInside &&
frameNameBoundsEqual(frameNameBound, cachedFrameNameBound)
) {
const derefElement = cachedElement?.deref();
if (
derefElement &&
derefElement.id === element.id &&
derefElement.version === element.version &&
derefElement.versionNonce === element.versionNonce
) {
return cachedHit;
}
}
// Hit test against a frame's name
const hitFrameName = frameNameBound
? isPointWithinBounds(
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
point,
pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds(
point,
bounds,
element.angle,
threshold,
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
}
// Do the precise (and relatively costly) hit test
const hitElement = (
overrideShouldTestInside ? true : shouldTestInside(element)
)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
const result = hitElement || hitFrameName;
// Cache end result
cachedPoint = point;
cachedElement = new WeakRef(element);
cachedThreshold = threshold;
cachedOverrideShouldTestInside = overrideShouldTestInside;
cachedFrameNameBound = frameNameBound;
cachedHit = result;
return result;
};
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
const bounds = getElementBounds(element, elementsMap, true);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
) =>
!hitElementItself(hitArgs) &&
// bound text is considered part of the element (even if it's outside the bounding box)
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
export const hitElementBoundText = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
): boolean => {
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
};
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// If the element is inside a frame, we should clip the element
if (element.frameId) {
const enclosingFrame = elementsMap.get(element.frameId);
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
const enclosingFrameBounds = getElementBounds(
enclosingFrame,
elementsMap,
);
if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false;
}
}
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
};
export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break;
}
}
}
return candidateElements;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
tolerance,
);
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForFocusPoint = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
tolerance?: number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, tolerance)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
const distanceFilteredCandidateElements = candidateElements
// Resolve by distance
.filter(
(el) =>
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
isPointInElement(point, el, elementsMap),
);
if (distanceFilteredCandidateElements.length === 0) {
return null;
}
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
};
/**
* Intersect a line with an element for binding test
*
* @param element
* @param line
* @param offset
* @returns
*/
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) {
case "rectangle":
case "stickynote":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "selection":
case "magicframe":
return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond":
return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse":
return intersectEllipseWithLineSegment(
element,
elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
}
};
const curveIntersections = (
curves: Curve<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
for (const j of hits) {
intersections.push(pointRotateRads(j, center, angle));
}
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const lineIntersections = (
lines: LineSegment<GlobalPoint>[],
segment: LineSegment<GlobalPoint>,
intersections: GlobalPoint[],
center: GlobalPoint,
angle: Radians,
onlyFirst = false,
) => {
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(pointRotateRads(intersection, center, angle));
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
if (!doBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>(
segment[0],
center,
-element.angle as Radians,
);
const rotatedB = pointRotateRads<GlobalPoint>(
segment[1],
center,
-element.angle as Radians,
);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset);
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
const [sides, corners] = deconstructDiamondElement(element, offset);
const intersections: GlobalPoint[] = [];
lineIntersections(
sides,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
if (onlyFirst && intersections.length > 0) {
return intersections;
}
curveIntersections(
corners,
rotatedIntersector,
intersections,
center,
element.angle,
onlyFirst,
);
return intersections;
};
/**
*
* @param element
* @param a
* @param b
* @returns
*/
const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle));
};
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};
export const isBindableElementInsideOtherBindable = (
innerElement: ExcalidrawBindableElement,
outerElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): boolean => {
// Get corner points of the inner element based on its type
const getCornerPoints = (
element: ExcalidrawElement,
offset: number,
): GlobalPoint[] => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
// Diamond has 4 corner points at the middle of each side
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const corners: GlobalPoint[] = [
pointFrom(x + topX, y + topY - offset), // top
pointFrom(x + rightX + offset, y + rightY), // right
pointFrom(x + bottomX, y + bottomY + offset), // bottom
pointFrom(x + leftX - offset, y + leftY), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
if (element.type === "ellipse") {
// For ellipse, test points at the extremes (top, right, bottom, left)
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
const corners: GlobalPoint[] = [
pointFrom(cx, cy - ry - offset), // top
pointFrom(cx + rx + offset, cy), // right
pointFrom(cx, cy + ry + offset), // bottom
pointFrom(cx - rx - offset, cy), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
// Rectangle and other rectangular shapes (image, text, etc.)
const corners: GlobalPoint[] = [
pointFrom(x - offset, y - offset), // top-left
pointFrom(x + width + offset, y - offset), // top-right
pointFrom(x + width + offset, y + height + offset), // bottom-right
pointFrom(x - offset, y + height + offset), // bottom-left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
};
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
const innerCorners = getCornerPoints(innerElement, offset);
// Check if all corner points of the inner element are inside the outer element
return innerCorners.every((corner) =>
isPointInElement(corner, outerElement, elementsMap),
);
};

View File

@@ -0,0 +1,68 @@
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "stickynote" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "line" ||
type === "freedraw" ||
type === "autoshape" ||
// tool-only type; makes the `G` background shortcut work for bucket fill
type === "bucketfill";
export const hasFillStyle = (type: ElementOrToolType) =>
hasBackground(type) && type !== "stickynote";
export const hasStrokeColor = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "stickynote" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "text" ||
type === "embeddable" ||
type === "autoshape";
export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "freedraw" ||
type === "arrow" ||
type === "line" ||
type === "autoshape";
export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "ellipse" ||
type === "diamond" ||
type === "arrow" ||
type === "line" ||
type === "autoshape";
export const hasRoughness = (type: ElementOrToolType) =>
hasStrokeStyle(type) || type === "stickynote";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
type === "embeddable" ||
type === "line" ||
type === "diamond" ||
type === "stickynote" ||
type === "image";
export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";

View File

@@ -0,0 +1,33 @@
import type { ExcalidrawTextContainer } from "./types";
export const originalContainerCache: {
[id: ExcalidrawTextContainer["id"]]:
| {
height: ExcalidrawTextContainer["height"];
}
| undefined;
} = {};
export const updateOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
height: ExcalidrawTextContainer["height"],
) => {
const data =
originalContainerCache[id] || (originalContainerCache[id] = { height });
data.height = height;
return data;
};
export const resetOriginalContainerCache = (
id: ExcalidrawTextContainer["id"],
) => {
if (originalContainerCache[id]) {
delete originalContainerCache[id];
}
};
export const getOriginalContainerHeightFromCache = (
id: ExcalidrawTextContainer["id"],
) => {
return originalContainerCache[id]?.height ?? null;
};

View File

@@ -0,0 +1,821 @@
import {
getBoundsFromPoints,
getElementAbsoluteCoords,
} from "@excalidraw/element/bounds";
import {
newArrowElement,
newElement,
newLinearElement,
} from "@excalidraw/element/newElement";
import {
ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
getStrokeWidthByKey,
ROUNDNESS,
} from "@excalidraw/common";
import {
convexHull,
distanceToLineSegment,
elongation,
kurtosis,
lineSegment,
orientPrincipalAxes,
pointFrom,
polygonArea,
principalAxes,
principalCoords,
skewness,
} from "@excalidraw/math";
import type { App, AppState } from "@excalidraw/excalidraw/types";
import type { LocalPoint, GlobalPoint, Radians } from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common";
import type {
ExcalidrawArrowElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawFrameLikeElement,
ExcalidrawRectangleElement,
ElementsMap,
ExcalidrawNonSelectionElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { getFrameLikeElements } from "./frame";
import {
isLinearElement,
isLineElement,
isUsingAdaptiveRadius,
} from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
declare global {
interface Window {
LAST_POINTS?: readonly GlobalPoint[];
}
}
// A moment-based shape recognizer.
//
// NOTE: Rectangle vs. diamond is deliberately *not* rotation invariant. A 45
// rectangle = diamond.
type NonDeletedRecognizedShapeElement = NonDeleted<
| ExcalidrawRectangleElement
| ExcalidrawEllipseElement
| ExcalidrawDiamondElement
| ExcalidrawArrowElement
| ExcalidrawLineElement
| ExcalidrawFreeDrawElement
>;
type Shape = NonDeletedRecognizedShapeElement["type"];
interface ShapeRecognitionResult<P extends LocalPoint | GlobalPoint> {
// The type of the recognized shape, or "freedraw" if no good match was found
type: Shape;
// The original input points (not simplified)
points: readonly P[];
// Bounding box of the original input points, used for positioning the
// converted shape
boundingBox: Bounds;
}
// Number of points every stroke is resampled to before feature extraction.
const RESAMPLE_N = 64;
// Minimum apparent (on-screen) size of a stroke's larger bounding-box
// dimension, in pixels, for recognition to run — below this the stroke reads
// as an accidental scribble. Compared against `sceneSize * zoom` so the same
// physical gesture behaves the same at every zoom level.
//
// Maximum not required. Runtime is O(point count), points come in at roughly
// one per frame.
const RECOGNITION_MIN_SCREEN_SIZE = 25;
// A stroke whose endpoints are farther apart than this fraction of its own
// path length is treated as open (a line or an arrow) rather than closed (a
// rectangle, diamond or ellipse).
const CLOSED_GAP_MAX_RATIO = 0.15;
// Maximum elongation for an open stroke to count as straight.
const LINEAR_MAX_ELONGATION = 0.25;
// Fraction of the start→tip distance around the tip inside which an arrowhead
// may live: points there are exempt from the shaft straightness check.
const ARROWHEAD_ZONE_RATIO = 0.5;
// Maximum distance any point outside the arrowhead zone may stray from the
// start→tip chord, as a fraction of the chord length. Loose enough for a lazy
// bow or a small squiggle, tight enough to reject elbows, arcs and sawtooths
// that only *statistically* look straight (their point-cloud elongation
// passes, their geometry doesn't).
const LINEAR_MAX_SHAFT_DEVIATION = 0.15;
// Minimum |skew| along the major axis for an open, straight stroke to be an
// arrow rather than a plain line. Arrowhead add "mass" -> skews that way.
const ARROW_MIN_SKEW = 0.3;
// Maximum feature-space distance to a shape prototype, in units of the
// per-feature tolerances in CLOSED_SHAPE_PROTOTYPES. Beyond this the stroke is
// not enough like any known shape and stays freedraw.
const CLOSED_SHAPE_MAX_DISTANCE = 1.5;
// Half-width, in resampled points, of the chord window used to measure local
// turning along the stroke. Wide enough to smooth pen wobble, narrow enough
// that adjacent corners of a small quadrilateral stay separate peaks.
const TURN_WINDOW = 3;
// =============================================================================
// Feature extraction
// =============================================================================
// Resample `pts` to exactly `n` evenly-spaced points along the stroke path.
function resample<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
n: number,
): P[] {
let totalLen = 0;
for (let i = 1; i < pts.length; i++) {
totalLen += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const interval = totalLen / (n - 1);
let accumulated = 0;
const result: P[] = [pts[0]];
let prev = pts[0];
for (let i = 1; i < pts.length; i++) {
const curr = pts[i];
const segLen = Math.hypot(curr[0] - prev[0], curr[1] - prev[1]);
if (accumulated + segLen >= interval) {
// Insert interpolated points within this segment
let remaining = interval - accumulated;
while (remaining <= segLen + 1e-10) {
const t = remaining / segLen;
const newPt: P = [
prev[0] + t * (curr[0] - prev[0]),
prev[1] + t * (curr[1] - prev[1]),
] as P;
result.push(newPt);
if (result.length === n) {
return result;
}
prev = newPt;
accumulated = 0;
remaining += interval;
}
accumulated = segLen - (remaining - interval);
} else {
accumulated += segLen;
}
prev = curr;
}
// Fill any remaining slots with the last point (rounding errors)
while (result.length < n) {
result.push(pts[pts.length - 1]);
}
return result;
}
// Statistical description of a stroke, in terms that don't depend on where,
// how big, or (except where noted) how rotated it was drawn.
interface StrokeFeatures {
// Straight-line distance between the endpoints over the stroke's own path
// length. ~0 when the stroke returns to its start.
gapRatio: number;
// Point cloud elongation along the major axis.
elongation: number;
// Skew of the points projected onto the major axis. Signed so that it is
// <= 0 by construction.
majorSkew: number;
// Convex hull area over axis-aligned bounding box area, in the screen
// frame: ~1 for a rectangle, ~PI/4 for an ellipse, ~1/2 for a diamond.
hullFillRatio: number;
// Share of the stroke's total turning concentrated in its 4 strongest
// corner loci: ~1 for any quadrilateral — however tapered or slanted, all
// turning happens at the corners — vs. ~0.5 for an ellipse, whose turning
// is spread along the whole outline. This is what tells a sloppy,
// trapezoid-ish rectangle (hull fill ≈ PI/4, same as an ellipse's) from an
// actual ellipse: sharp corners.
cornerTurnShare: number;
// kurtosis along x times kurtosis along y, in the screen frame. The two
// factors each vary with the aspect ratio, but their product barely does:
// ~1.83 for any rectangle, 2.25 for any ellipse, 3.24 for any diamond.
kurtosisProduct: number;
// How far the stroke strays from the straight chord between its start and
// its farthest point ("tip"), as a fraction of that chord's length — points
// within ARROWHEAD_ZONE_RATIO of the tip are exempt, so an arrowhead
// doesn't count as straying. ~0 for a line or arrow however wobbly, large
// for elbows, arcs and sawtooths, whose point-cloud statistics alone can
// still look line-like.
shaftDeviationRatio: number;
}
// See StrokeFeatures.shaftDeviationRatio.
function shaftDeviationRatio<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const start = pts[0];
let tip = start;
let tipDistance = 0;
for (const point of pts) {
const distance = Math.hypot(point[0] - start[0], point[1] - start[1]);
if (distance > tipDistance) {
tipDistance = distance;
tip = point;
}
}
if (tipDistance === 0) {
return 0;
}
const chord = lineSegment(start, tip);
let maxDeviation = 0;
for (const point of pts) {
const tipDist = Math.hypot(point[0] - tip[0], point[1] - tip[1]);
if (tipDist <= ARROWHEAD_ZONE_RATIO * tipDistance) {
continue;
}
maxDeviation = Math.max(maxDeviation, distanceToLineSegment(point, chord));
}
return maxDeviation / tipDistance;
}
// The turn angle at every resampled point, measured between the incoming and
// outgoing chords `TURN_WINDOW` points away. The stroke is deliberately NOT
// treated as a closed loop: wrapping around would let the closure artifacts of
// a hand-drawn outline (overshooting or undershooting the starting point)
// fabricate a sharp corner that was never drawn.
function windowedTurns<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number[] {
const turns: number[] = [];
for (let i = TURN_WINDOW; i < pts.length - TURN_WINDOW; i++) {
const [ax, ay] = pts[i - TURN_WINDOW];
const [bx, by] = pts[i];
const [cx, cy] = pts[i + TURN_WINDOW];
const v1x = bx - ax;
const v1y = by - ay;
const v2x = cx - bx;
const v2y = cy - by;
turns.push(
Math.abs(Math.atan2(v1x * v2y - v1y * v2x, v1x * v2x + v1y * v2y)),
);
}
return turns;
}
// See StrokeFeatures.cornerTurnShare. Greedily picks the 4 strongest turn
// peaks, each absorbing its ±TURN_WINDOW neighborhood so one physical corner
// (whose turn smears across the window) counts once.
function cornerTurnShare<P extends LocalPoint | GlobalPoint>(
pts: readonly P[],
): number {
const turns = windowedTurns(pts);
const total = turns.reduce((sum, turn) => sum + turn, 0);
if (total === 0) {
return 0;
}
const taken = new Array<boolean>(turns.length).fill(false);
let top4 = 0;
for (let corner = 0; corner < 4; corner++) {
let peak = -1;
let peakTurn = 0;
for (let i = 0; i < turns.length; i++) {
if (!taken[i] && turns[i] > peakTurn) {
peakTurn = turns[i];
peak = i;
}
}
if (peak < 0) {
break;
}
for (let i = peak - TURN_WINDOW; i <= peak + TURN_WINDOW; i++) {
if (i >= 0 && i < turns.length && !taken[i]) {
top4 += turns[i];
taken[i] = true;
}
}
}
return top4 / total;
}
function extractFeatures<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
): StrokeFeatures {
// Resample first: features are moments of the point set, so they would
// otherwise be biased by pointer speed, which crowds samples wherever the
// user drew slowly.
const pts = resample(points, RESAMPLE_N);
let pathLength = 0;
for (let i = 1; i < pts.length; i++) {
pathLength += Math.hypot(
pts[i][0] - pts[i - 1][0],
pts[i][1] - pts[i - 1][1],
);
}
const gap = Math.hypot(
pts[pts.length - 1][0] - pts[0][0],
pts[pts.length - 1][1] - pts[0][1],
);
const axes = orientPrincipalAxes(pts, principalAxes(pts));
const majorProjection = principalCoords(pts, axes).map(([u]) => u);
const hull = convexHull(pts);
const [minX, minY, maxX, maxY] = getBoundsFromPoints(pts);
const boxArea = (maxX - minX) * (maxY - minY);
return {
gapRatio: pathLength > 0 ? gap / pathLength : 0,
elongation: elongation(axes),
majorSkew: skewness(majorProjection),
hullFillRatio: boxArea > 0 ? polygonArea(hull) / boxArea : 0,
cornerTurnShare: cornerTurnShare(pts),
kurtosisProduct:
kurtosis(pts.map(([x]) => x)) * kurtosis(pts.map(([, y]) => y)),
shaftDeviationRatio: shaftDeviationRatio(pts),
};
}
// =============================================================================
// Classification
// =============================================================================
const CLOSED_SHAPE_PROTOTYPES: readonly {
type: Shape;
hullFillRatio: number;
cornerTurnShare: number;
kurtosisProduct: number;
}[] = [
{
type: "rectangle",
hullFillRatio: 1,
cornerTurnShare: 0.95,
kurtosisProduct: 1.83,
},
{
type: "diamond",
hullFillRatio: 0.5,
cornerTurnShare: 0.95,
kurtosisProduct: 3.24,
},
{
type: "ellipse",
hullFillRatio: Math.PI / 4,
cornerTurnShare: 0.55,
kurtosisProduct: 2.25,
},
];
// Hull fill no longer has to separate rectangles from ellipses on its own
// (cornerTurnShare carries that), so it can afford the slack that lets a
// tapered, hand-drawn quadrilateral (fill ≈ 0.8) still read as a rectangle.
const HULL_FILL_RATIO_TOLERANCE = 0.2;
const CORNER_TURN_SHARE_TOLERANCE = 0.2;
const KURTOSIS_PRODUCT_TOLERANCE = 0.7;
// Pick the closed shape whose prototype the stroke's features sit closest to,
// or freedraw when even the nearest one is too far away.
function classifyClosedStroke(features: StrokeFeatures): Shape {
let best: Shape = "freedraw";
let bestDistance = CLOSED_SHAPE_MAX_DISTANCE;
for (const prototype of CLOSED_SHAPE_PROTOTYPES) {
const distance = Math.hypot(
(features.hullFillRatio - prototype.hullFillRatio) /
HULL_FILL_RATIO_TOLERANCE,
(features.cornerTurnShare - prototype.cornerTurnShare) /
CORNER_TURN_SHARE_TOLERANCE,
(features.kurtosisProduct - prototype.kurtosisProduct) /
KURTOSIS_PRODUCT_TOLERANCE,
);
if (distance < bestDistance) {
bestDistance = distance;
best = prototype.type;
}
}
return best;
}
// An open stroke is a line or an arrow, provided it is straight enough; the
// arrowhead is what makes the point distribution lopsided.
function classifyOpenStroke(features: StrokeFeatures): Shape {
if (
features.elongation > LINEAR_MAX_ELONGATION ||
features.shaftDeviationRatio > LINEAR_MAX_SHAFT_DEVIATION
) {
return "freedraw";
}
return Math.abs(features.majorSkew) >= ARROW_MIN_SKEW ? "arrow" : "line";
}
function classify(features: StrokeFeatures): Shape {
return features.gapRatio > CLOSED_GAP_MAX_RATIO
? classifyOpenStroke(features)
: classifyClosedStroke(features);
}
// Arrow endpoint selection
// When an arrow is recognized, the original input's last point may not
// correspond to the actual arrow tip. The tip must lie on the bounding-box
// perimeter and is the perimeter point farthest from the start (first input
// point). We find the closest original input point to that ideal tip.
function getArrowEndpoint<P extends LocalPoint | GlobalPoint>(
points: readonly P[],
boundingBox: Bounds,
startPoint: P,
): P {
const [minX, minY, maxX, maxY] = boundingBox;
const w = maxX - minX;
const h = maxY - minY;
// Degenerate bounding box – fall back to last point
if (w === 0 && h === 0) {
return points[points.length - 1];
}
// Perimeter corners / edge points to check (4 corners + 4 edge midpoints
// give good coverage of the perimeter).
const perimeterPoints: LocalPoint[] = [
pointFrom(minX, minY),
pointFrom((minX + maxX) / 2, minY),
pointFrom(maxX, minY),
pointFrom(maxX, (minY + maxY) / 2),
pointFrom(maxX, maxY),
pointFrom((minX + maxX) / 2, maxY),
pointFrom(minX, maxY),
pointFrom(minX, (minY + maxY) / 2),
];
// Ideal endpoint = perimeter point farthest from start.
let idealDist = -1;
let idealEndpoint: LocalPoint = pointFrom(maxX, maxY);
for (const pp of perimeterPoints) {
const d = Math.hypot(pp[0] - startPoint[0], pp[1] - startPoint[1]);
if (d > idealDist) {
idealDist = d;
idealEndpoint = pp;
}
}
// Find the original input point closest to the ideal endpoint.
let bestDist = Infinity;
let bestPoint: P = points[points.length - 1];
for (const pt of points) {
const d = Math.hypot(pt[0] - idealEndpoint[0], pt[1] - idealEndpoint[1]);
if (d < bestDist) {
bestDist = d;
bestPoint = pt;
}
}
return bestPoint;
}
// =============================================================================
// Public API
// =============================================================================
// Recognizes common shapes from free-draw input points
export const recognizeShape = <P extends LocalPoint | GlobalPoint>(
points: P[],
previousElement: ExcalidrawElement | null,
zoom: number = 1,
): ShapeRecognitionResult<P> => {
// DEBUG (will be removed lated)
if (typeof window !== "undefined") {
window.LAST_POINTS = points.map(([x, y]) => pointFrom<GlobalPoint>(x, y));
}
const boundingBox = getBoundsFromPoints(points);
const [minX, minY, maxX, maxY] = boundingBox;
const maxDim = Math.max(maxX - minX, maxY - minY);
if (points.length < 3 || maxDim * zoom < RECOGNITION_MIN_SCREEN_SIZE) {
return { type: "freedraw", points, boundingBox };
}
const recognized = classify(extractFeatures(points));
const type =
previousElement?.type === "arrow" && recognized !== "arrow"
? "freedraw"
: recognized;
return { type, points, boundingBox };
};
// Converts a freedraw element to the detected shape.
//
// `frames` lets callers pass the scene's cached frame-like elements (e.g.
// scene.getNonDeletedFramesLikes()) to avoid spreading and re-filtering the
// whole element map on every invocation; when omitted it is derived from
// `elementsMap`.
export const convertToShape = (
points: GlobalPoint[],
appState: AppState,
elementsMap: ElementsMap,
previousElement: ExcalidrawElement | null,
frames?: readonly ExcalidrawFrameLikeElement[],
): NonDeletedRecognizedShapeElement | undefined => {
const recognizedShape = recognizeShape(
points,
previousElement,
appState.zoom.value,
);
const boundingBox = recognizedShape.boundingBox;
const [minX, minY, maxX, maxY] = boundingBox;
const frameLikeElements =
frames ?? getFrameLikeElements([...elementsMap.values()]);
const frameId =
frameLikeElements.find((frame) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
return fx1 <= minX && fy1 <= minY && fx2 >= maxX && fy2 >= maxY;
})?.id ?? null;
const roundness =
appState.currentItemRoundness === "round"
? {
type: isUsingAdaptiveRadius(recognizedShape.type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: null;
switch (recognizedShape.type) {
case "rectangle":
case "diamond":
case "ellipse": {
return newElement({
type: recognizedShape.type,
x: recognizedShape.boundingBox[0],
y: recognizedShape.boundingBox[1],
width: recognizedShape.boundingBox[2] - recognizedShape.boundingBox[0],
height: recognizedShape.boundingBox[3] - recognizedShape.boundingBox[1],
groupIds: [],
angle: 0 as Radians,
frameId,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
}) as NonDeletedRecognizedShapeElement;
}
case "arrow": {
const [arrowX, arrowY] = recognizedShape.points[0];
const [globalEndX, globalEndY] = getArrowEndpoint(
recognizedShape.points,
recognizedShape.boundingBox,
recognizedShape.points[0],
);
const endPoint: LocalPoint = pointFrom<LocalPoint>(
globalEndX - arrowX,
globalEndY - arrowY,
);
const arrowLen = Math.hypot(
globalEndX - recognizedShape.points[0][0],
globalEndY - recognizedShape.points[0][1],
);
if (arrowLen < 60) {
const tempElement = newLinearElement({
type: "line",
x: arrowX,
y: arrowY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
const tempElement = newArrowElement({
type: "arrow",
x: arrowX,
y: arrowY,
startArrowhead: appState.currentItemStartArrowhead,
endArrowhead: appState.currentItemEndArrowhead,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - arrowX,
recognizedShape.points[0][1] - arrowY,
),
endPoint,
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newArrowElement({
...tempElement,
...normalized,
});
}
case "line": {
const [lineX, lineY] = recognizedShape.points[0];
const endPoint =
recognizedShape.points[recognizedShape.points.length - 1];
const tempElement = newLinearElement({
type: recognizedShape.type,
x: lineX,
y: lineY,
points: [
pointFrom<LocalPoint>(
recognizedShape.points[0][0] - lineX,
recognizedShape.points[0][1] - lineY,
),
pointFrom<LocalPoint>(endPoint[0] - lineX, endPoint[1] - lineY),
],
groupIds: [],
frameId,
locked: false,
angle: 0 as Radians,
roundness,
roughness: appState.currentItemRoughness,
backgroundColor: appState.currentItemBackgroundColor,
strokeColor: appState.currentItemStrokeColor,
fillStyle: appState.currentItemFillStyle,
opacity: appState.currentItemOpacity,
strokeStyle: appState.currentItemStrokeStyle,
strokeWidth: getStrokeWidthByKey(
recognizedShape.type,
appState.currentItemStrokeWidthKey,
),
});
const normalized =
LinearElementEditor.getNormalizeElementPointsAndCoords(tempElement);
return newLinearElement({
...tempElement,
...normalized,
}) as NonDeletedRecognizedShapeElement;
}
}
return undefined;
};
// Whether two drawShape preview elements are visually identical, so a redundant
// preview recompute on pointermove can skip setState (avoiding a no-op React
// re-render and roughjs shape regeneration).
const isDrawShapePreviewEqual = (
a: NonDeletedExcalidrawElement | null,
b: NonDeletedExcalidrawElement,
): boolean => {
if (
!a ||
a.type !== b.type ||
a.x !== b.x ||
a.y !== b.y ||
a.width !== b.width ||
a.height !== b.height ||
a.angle !== b.angle
) {
return false;
}
if (isLinearElement(a) && isLinearElement(b)) {
if (a.points.length !== b.points.length) {
return false;
}
for (let i = 0; i < a.points.length; i++) {
if (
a.points[i][0] !== b.points[i][0] ||
a.points[i][1] !== b.points[i][1]
) {
return false;
}
}
}
return true;
};
// Append to the trail and recompute the shape preview. This runs inside
// withBatchedUpdatesThrottled (throttleRAF), so it fires at most once per
// animation frame regardless of raw pointermove rate.
export const convertToShapeHandlePointerMoveFromPointerDown = (
app: App,
pointerCoords: { x: number; y: number },
) => {
if (app.state.activeTool.type === "autoshape") {
app.drawShape.trail.addPointToPath(pointerCoords.x, pointerCoords.y);
const drawShapeTrailPoints = app.drawShape.trail.getCurrentPoints();
// note: size gate inside recognizeShape
if (drawShapeTrailPoints.length >= 3) {
const shapePreview = convertToShape(
drawShapeTrailPoints,
app.state,
app.scene.getNonDeletedElementsMap(),
app.state.newElement,
app.scene.getNonDeletedFramesLikes(),
) as ExcalidrawNonSelectionElement | undefined;
// Lines get no live preview — nearly every stroke reads as a line at
// some early point, so previewing them is mostly flicker. Releasing
// still commits the line: finalize() re-recognizes from the trail when
// no preview exists.
if (shapePreview && !isLineElement(shapePreview)) {
const prevPreview = app.state.newElement;
const nextPreview = {
...shapePreview,
// Keep a stable id across the gesture while the recognized type
// is unchanged, so the preview element's identity doesn't churn
// frame to frame.
id:
prevPreview?.type === shapePreview.type
? prevPreview.id
: shapePreview.id,
created:
prevPreview?.type === shapePreview.type
? prevPreview.created
: shapePreview.created,
seed: 1,
opacity: ELEMENT_PENDING_DRAW_SHAPE_OPACITY,
isDeleted: false as const,
} as NonDeletedRecognizedShapeElement;
if (!isDrawShapePreviewEqual(prevPreview, nextPreview)) {
app.setState({ newElement: nextPreview });
}
} else if (app.state.newElement !== null) {
app.setState({ newElement: null });
}
}
return true;
}
return false;
};

View File

@@ -0,0 +1,628 @@
import {
type Radians,
pointFrom,
pointCenter,
pointRotateRads,
vectorFromPoint,
vectorNormalize,
vectorSubtract,
vectorAdd,
vectorScale,
pointFromVector,
clamp,
isCloseTo,
} from "@excalidraw/math";
import { type Point } from "points-on-curve";
import {
elementCenterPoint,
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
import type { TransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawImageElement,
ImageCrop,
} from "./types";
export const MINIMAL_CROP_SIZE = 10;
export const cropElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType,
naturalWidth: number,
naturalHeight: number,
pointerX: number,
pointerY: number,
widthAspectRatio?: number,
) => {
const { width: uncroppedWidth, height: uncroppedHeight } =
getUncroppedWidthAndHeight(element);
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
/**
* uncropped width
* *––––––––––––––––––––––––*
* | (x,y) (natural) |
* | *–––––––* |
* | |///////| height | uncropped height
* | *–––––––* |
* | width (natural) |
* *––––––––––––––––––––––––*
*/
const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY),
elementCenterPoint(element, elementsMap),
-element.angle as Radians,
);
pointerX = rotatedPointer[0];
pointerY = rotatedPointer[1];
let nextWidth = element.width;
let nextHeight = element.height;
let crop: ImageCrop | null = element.crop ?? {
x: 0,
y: 0,
width: naturalWidth,
height: naturalHeight,
naturalWidth,
naturalHeight,
};
const previousCropHeight = crop.height;
const previousCropWidth = crop.width;
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let changeInHeight = pointerY - element.y;
let changeInWidth = pointerX - element.x;
if (transformHandle.includes("n")) {
nextHeight = clamp(
element.height - changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
);
}
if (transformHandle.includes("s")) {
changeInHeight = pointerY - element.y - element.height;
nextHeight = clamp(
element.height + changeInHeight,
MINIMAL_CROP_SIZE,
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
);
}
if (transformHandle.includes("e")) {
changeInWidth = pointerX - element.x - element.width;
nextWidth = clamp(
element.width + changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
);
}
if (transformHandle.includes("w")) {
nextWidth = clamp(
element.width - changeInWidth,
MINIMAL_CROP_SIZE,
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
);
}
const updateCropWidthAndHeight = (crop: ImageCrop) => {
crop.height = nextHeight * naturalHeightToUncropped;
crop.width = nextWidth * naturalWidthToUncropped;
};
updateCropWidthAndHeight(crop);
const adjustFlipForHandle = (
handle: TransformHandleType,
crop: ImageCrop,
) => {
updateCropWidthAndHeight(crop);
if (handle.includes("n")) {
if (!isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("s")) {
if (isFlippedByY) {
crop.y += previousCropHeight - crop.height;
}
}
if (handle.includes("e")) {
if (isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
if (handle.includes("w")) {
if (!isFlippedByX) {
crop.x += previousCropWidth - crop.width;
}
}
};
switch (transformHandle) {
case "n": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "s": {
if (widthAspectRatio) {
const distanceToLeft = croppedLeft + element.width / 2;
const distanceToRight =
uncroppedWidth - croppedLeft - element.width / 2;
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.x += (previousCropWidth - crop.width) / 2;
}
break;
}
case "w": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "e": {
if (widthAspectRatio) {
const distanceToTop = croppedTop + element.height / 2;
const distanceToBottom =
uncroppedHeight - croppedTop - element.height / 2;
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
}
adjustFlipForHandle(transformHandle, crop);
if (widthAspectRatio) {
crop.y += (previousCropHeight - crop.height) / 2;
}
break;
}
case "ne": {
if (widthAspectRatio) {
if (changeInWidth > -changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "nw": {
if (widthAspectRatio) {
if (changeInWidth < changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? uncroppedHeight - croppedTop
: croppedTop + element.height;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "se": {
if (widthAspectRatio) {
if (changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? croppedLeft + element.width
: uncroppedWidth - croppedLeft;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
case "sw": {
if (widthAspectRatio) {
if (-changeInWidth > changeInHeight) {
const MAX_HEIGHT = isFlippedByY
? croppedTop + element.height
: uncroppedHeight - croppedTop;
nextHeight = clamp(
nextWidth / widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_HEIGHT,
);
nextWidth = nextHeight * widthAspectRatio;
} else {
const MAX_WIDTH = isFlippedByX
? uncroppedWidth - croppedLeft
: croppedLeft + element.width;
nextWidth = clamp(
nextHeight * widthAspectRatio,
MINIMAL_CROP_SIZE,
MAX_WIDTH,
);
nextHeight = nextWidth / widthAspectRatio;
}
}
adjustFlipForHandle(transformHandle, crop);
break;
}
default:
break;
}
const newOrigin = recomputeOrigin(
element,
transformHandle,
nextWidth,
nextHeight,
!!widthAspectRatio,
);
// reset crop to null if we're back to orig size
if (
isCloseTo(crop.width, crop.naturalWidth) &&
isCloseTo(crop.height, crop.naturalHeight)
) {
crop = null;
}
return {
x: newOrigin[0],
y: newOrigin[1],
width: nextWidth,
height: nextHeight,
crop,
};
};
const recomputeOrigin = (
stateAtCropStart: ExcalidrawElement,
transformHandle: TransformHandleType,
width: number,
height: number,
shouldMaintainAspectRatio?: boolean,
) => {
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
stateAtCropStart,
stateAtCropStart.width,
stateAtCropStart.height,
true,
);
const startTopLeft = pointFrom(x1, y1);
const startBottomRight = pointFrom(x2, y2);
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
const newBoundsWidth = newBoundsX2 - newBoundsX1;
const newBoundsHeight = newBoundsY2 - newBoundsY1;
// Calculate new topLeft based on fixed corner during resize
let newTopLeft = [...startTopLeft] as [number, number];
if (["n", "w", "nw"].includes(transformHandle)) {
newTopLeft = [
startBottomRight[0] - Math.abs(newBoundsWidth),
startBottomRight[1] - Math.abs(newBoundsHeight),
];
}
if (transformHandle === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
}
if (transformHandle === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
}
if (shouldMaintainAspectRatio) {
if (["s", "n"].includes(transformHandle)) {
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
}
if (["e", "w"].includes(transformHandle)) {
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
}
}
// adjust topLeft to new rotation point
const angle = stateAtCropStart.angle;
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
];
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
newTopLeft = pointRotateRads(
rotatedTopLeft,
rotatedNewCenter,
-angle as Radians,
);
const newOrigin = [...newTopLeft];
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
return newOrigin;
};
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
export const getUncroppedImageElement = (
element: ExcalidrawImageElement,
elementsMap: ElementsMap,
) => {
if (element.crop) {
const { width, height } = getUncroppedWidthAndHeight(element);
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const topLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
);
const topRightVector = vectorFromPoint(
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
);
const topEdgeNormalized = vectorNormalize(
vectorSubtract(topRightVector, topLeftVector),
);
const bottomLeftVector = vectorFromPoint(
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
);
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
const rotatedTopLeft = vectorAdd(
vectorAdd(
topLeftVector,
vectorScale(
topEdgeNormalized,
(-cropX * width) / element.crop.naturalWidth,
),
),
vectorScale(
leftEdgeNormalized,
(-cropY * height) / element.crop.naturalHeight,
),
);
const center = pointFromVector(
vectorAdd(
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
vectorScale(leftEdgeNormalized, height / 2),
),
);
const unrotatedTopLeft = pointRotateRads(
pointFromVector(rotatedTopLeft),
center,
-element.angle as Radians,
);
const uncroppedElement: ExcalidrawImageElement = {
...element,
x: unrotatedTopLeft[0],
y: unrotatedTopLeft[1],
width,
height,
crop: null,
};
return uncroppedElement;
}
return element;
};
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
if (element.crop) {
const width =
element.width / (element.crop.width / element.crop.naturalWidth);
const height =
element.height / (element.crop.height / element.crop.naturalHeight);
return {
width,
height,
};
}
return {
width: element.width,
height: element.height,
};
};
const adjustCropPosition = (
crop: ImageCrop,
scale: ExcalidrawImageElement["scale"],
) => {
let cropX = crop.x;
let cropY = crop.y;
const flipX = scale[0] === -1;
const flipY = scale[1] === -1;
if (flipX) {
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
}
if (flipY) {
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
}
return {
cropX,
cropY,
};
};
export const getFlipAdjustedCropPosition = (
element: ExcalidrawImageElement,
natural = false,
) => {
const crop = element.crop;
if (!crop) {
return null;
}
const isFlippedByX = element.scale[0] === -1;
const isFlippedByY = element.scale[1] === -1;
let cropX = crop.x;
let cropY = crop.y;
if (isFlippedByX) {
cropX = crop.naturalWidth - crop.width - crop.x;
}
if (isFlippedByY) {
cropY = crop.naturalHeight - crop.height - crop.y;
}
if (natural) {
return {
x: cropX,
y: cropY,
};
}
const { width, height } = getUncroppedWidthAndHeight(element);
return {
x: cropX / (crop.naturalWidth / width),
y: cropY / (crop.naturalHeight / height),
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
import {
curvePointDistance,
distanceToLineSegment,
pointRotateRads,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
import type { GlobalPoint, Radians } from "@excalidraw/math";
import {
deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement,
} from "./utils";
import { elementCenterPoint } from "./bounds";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
export const distanceToElement = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
switch (element.type) {
case "selection":
case "rectangle":
case "stickynote":
case "image":
case "text":
case "iframe":
case "embeddable":
case "frame":
case "magicframe":
return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond":
return distanceToDiamondElement(element, elementsMap, p);
case "ellipse":
return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
/**
* Returns the distance of a point and the provided rectangular-shaped element,
* accounting for roundness and rotation
*
* @param element The rectanguloid element
* @param p The point to consider
* @returns The eucledian distance to the outline of the rectanguloid element
*/
const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
// Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element);
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...corners.map((a) => curvePointDistance(a, rotatedPoint)),
);
};
/**
* Returns the distance of a point and the provided diamond element, accounting
* for roundness and rotation
*
* @param element The diamond element
* @param p The point to consider
* @returns The eucledian distance to the outline of the diamond
*/
const distanceToDiamondElement = (
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element);
return Math.min(
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
...curves.map((a) => curvePointDistance(a, rotatedPoint)),
);
};
/**
* Returns the distance of a point and the provided ellipse element, accounting
* for roundness and rotation
*
* @param element The ellipse element
* @param p The point to consider
* @returns The eucledian distance to the outline of the ellipse
*/
const distanceToEllipseElement = (
element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint,
): number => {
const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2),
);
};
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};

View File

@@ -0,0 +1,114 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups";
import { getNonDeletedElements } from ".";
import type { Scene } from "./Scene";
import type { ElementsMap, NonDeletedExcalidrawElement } from "./types";
export interface Distribution {
space: "between";
axis: "x" | "y";
}
export const distributeElements = (
selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
scene: Scene,
): NonDeletedExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
? (["minX", "midX", "maxX", "width"] as const)
: (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements);
const groups = getSelectedElementsByGroup(
selectedElements,
elementsMap,
appState,
)
.map(getNonDeletedElements) // Nothing to distribute on deleted elements
.map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]);
let span = 0;
for (const group of groups) {
span += group[1][extent];
}
const step = (bounds[extent] - span) / (groups.length - 1);
if (step < 0) {
// If we have a negative step, we'll need to distribute from centers
// rather than from gaps. Buckle up, this is a weird one.
// Get indices of boxes that define start and end of our bounding box
const index0 = groups.findIndex((g) => g[1][start] === bounds[start]);
const index1 = groups.findIndex((g) => g[1][end] === bounds[end]);
// Get our step, based on the distance between the center points of our
// start and end boxes
const step =
(groups[index1][1][mid] - groups[index0][1][mid]) / (groups.length - 1);
let pos = groups[index0][1][mid];
return groups.flatMap(([group, box], index) => {
const translation = {
x: 0,
y: 0,
};
// Don't move our start and end boxes
if (index !== index0 && index !== index1) {
pos += step;
translation[distribution.axis] = pos - box[mid];
}
return group.map((element) => {
const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
});
}
// Distribute from gaps
let pos = bounds[start];
return groups.flatMap(([group, box]) => {
const translation = {
x: 0,
y: 0,
};
translation[distribution.axis] = pos - box[start];
pos += step;
pos += box[extent];
return group.map((element) => {
const updatedElement = scene.mutateElement(element, {
x: element.x + translation.x,
y: element.y + translation.y,
});
updateBoundElements(element, scene, {
simultaneouslyUpdated: group,
});
return updatedElement;
});
});
};

View File

@@ -0,0 +1,409 @@
import {
type Bounds,
TEXT_AUTOWRAP_THRESHOLD,
getGridPoint,
getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common";
import type {
AppState,
NormalizedZoomValue,
NullableGridSize,
PointerDownState,
} from "@excalidraw/excalidraw/types";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { unbindBindingElement, updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
import { getMinTextElementWidth } from "./textMeasurements";
import {
isArrowElement,
isElbowArrow,
isFrameLikeElement,
isImageElement,
isTextElement,
} from "./typeChecks";
import type { Scene } from "./Scene";
import type { ExcalidrawElement, ExcalidrawTextElement } from "./types";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
_selectedElements: NonDeletedExcalidrawElement[],
offset: { x: number; y: number },
scene: Scene,
snapOffset: {
x: number;
y: number;
},
gridSize: NullableGridSize,
) => {
if (
_selectedElements.length === 1 &&
isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) {
return;
}
const selectedElements = _selectedElements.filter((element) => {
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
const startElement = _selectedElements.find(
(el) => el.id === element.startBinding?.elementId,
);
const endElement = _selectedElements.find(
(el) => el.id === element.endBinding?.elementId,
);
return startElement && endElement;
}
return true;
});
// we do not want a frame and its elements to be selected at the same time
// but when it happens (due to some bug), we want to avoid updating element
// in the frame twice, hence the use of set
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
selectedElements,
);
const frames = selectedElements
.filter((e) => isFrameLikeElement(e))
.map((f) => f.id);
if (frames.length > 0) {
for (const element of scene.getNonDeletedElements()) {
if (element.frameId !== null && frames.includes(element.frameId)) {
elementsToUpdate.add(element);
}
}
}
const origElements: ExcalidrawElement[] = [];
for (const element of elementsToUpdate) {
const origElement = pointerDownState.originalElements.get(element.id);
// if original element is not set (e.g. when you duplicate during a drag
// operation), exit to avoid undefined behavior
if (!origElement) {
return;
}
origElements.push(origElement);
}
const adjustedOffset = calculateOffset(
getCommonBounds(origElements),
offset,
snapOffset,
gridSize,
);
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => {
const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
element,
scene.getNonDeletedElementsMap(),
);
if (textElement) {
updateElementCoords(
pointerDownState,
textElement,
scene,
adjustedOffset,
);
}
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} else if (
// NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it.
elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD ||
(!element.startBinding && !element.endBinding)
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
if (shouldUnbindStart) {
unbindBindingElement(element, "start", scene);
}
if (shouldUnbindEnd) {
unbindBindingElement(element, "end", scene);
}
}
}
});
};
const calculateOffset = (
commonBounds: Bounds,
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: NullableGridSize,
): { x: number; y: number } => {
const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x;
let nextY = y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint(
x + dragOffset.x,
y + dragOffset.y,
gridSize,
);
if (snapOffset.x === 0) {
nextX = nextGridX;
}
if (snapOffset.y === 0) {
nextY = nextGridY;
}
}
return {
x: nextX - x,
y: nextY - y,
};
};
const updateElementCoords = (
pointerDownState: PointerDownState,
element: ExcalidrawElement,
scene: Scene,
dragOffset: { x: number; y: number },
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
scene.mutateElement(element, {
x: nextX,
y: nextY,
});
};
export const getDragOffsetXY = (
selectedElements: NonDeletedExcalidrawElement[],
x: number,
y: number,
): [number, number] => {
const [x1, y1] = getCommonBounds(selectedElements);
return [x - x1, y - y1];
};
/**
* Sizes a text element as it is dragged out.
*
* A dragged text pins one point and grows away from it; `anchorRatio` says
* where along the box that point sits — 0 for its left edge, 1 for its right,
* 0.5 for its centre.
*
* A free text pins the point the drag started from and takes the ratio from
* the drag direction, so it can be pulled either way. A text bound to an arrow
* endpoint instead pins whatever the binding placed it against and takes the
* ratio from its alignment — which is also what keeps it from growing back
* over the arrow, since dragging that way makes no progress rather than
* flipping the box around.
*/
export const dragNewTextElement = ({
newElement,
anchorX,
anchorRatio,
pointerX,
nextY,
zoom,
scene,
informMutation = true,
}: {
newElement: ExcalidrawTextElement;
anchorX: number;
/** 0 = anchored by its left edge, 1 = by its right, 0.5 = by its centre */
anchorRatio: number;
pointerX: number;
/** free text re-tops itself to the drag origin; a bound one must not move */
nextY?: number;
zoom: NormalizedZoomValue;
scene: Scene;
informMutation?: boolean;
}) => {
const offset = pointerX - anchorX;
// how far the pointer has travelled away from the anchor along the direction
// the box may grow — negative once it heads back the other way
const reach =
anchorRatio === 0 ? offset : anchorRatio === 1 ? -offset : Math.abs(offset);
const width = Math.max(
// a centred box grows on both sides, so it widens at twice the reach
anchorRatio === 0.5 ? reach * 2 : reach,
getMinTextElementWidth(
getFontString({
fontSize: newElement.fontSize,
fontFamily: newElement.fontFamily,
}),
newElement.lineHeight,
),
);
scene.mutateElement(
newElement,
{
x: anchorX - width * anchorRatio,
...(nextY === undefined ? {} : { y: nextY }),
width,
...(reach > TEXT_AUTOWRAP_THRESHOLD / zoom ? { autoResize: false } : {}),
},
{ informMutation, isDragging: false },
);
};
export const dragNewElement = ({
newElement,
elementType,
originX,
originY,
x,
y,
width,
height,
shouldMaintainAspectRatio,
shouldResizeFromCenter,
zoom,
scene,
widthAspectRatio = null,
originOffset = null,
informMutation = true,
}: {
newElement: NonDeletedExcalidrawElement;
elementType: AppState["activeTool"]["type"];
originX: number;
originY: number;
x: number;
y: number;
width: number;
height: number;
shouldMaintainAspectRatio: boolean;
shouldResizeFromCenter: boolean;
zoom: NormalizedZoomValue;
scene: Scene;
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
true */
widthAspectRatio?: number | null;
originOffset?: {
x: number;
y: number;
} | null;
informMutation?: boolean;
}) => {
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
if (widthAspectRatio) {
height = width / widthAspectRatio;
} else {
// Depending on where the cursor is at (x, y) relative to where the starting point is
// (originX, originY), we use ONLY width or height to control size increase.
// This allows the cursor to always "stick" to one of the sides of the bounding box.
if (Math.abs(y - originY) > Math.abs(x - originX)) {
({ width, height } = getPerfectElementSize(
elementType,
height,
x < originX ? -width : width,
));
} else {
({ width, height } = getPerfectElementSize(
elementType,
width,
y < originY ? -height : height,
));
}
if (height < 0) {
height = -height;
}
}
}
if (isTextElement(newElement)) {
// a text is only ever sized horizontally — its height follows the wrapped
// content — so it grows away from the point the drag started at
dragNewTextElement({
newElement,
anchorX: originX + (originOffset?.x ?? 0),
anchorRatio: shouldResizeFromCenter ? 0.5 : x < originX ? 1 : 0,
pointerX: x,
nextY: originY + (originOffset?.y ?? 0),
zoom,
scene,
informMutation,
});
return;
}
let newX = x < originX ? originX - width : originX;
let newY = y < originY ? originY - height : originY;
if (shouldResizeFromCenter) {
width += width;
height += height;
newX = originX - width / 2;
newY = originY - height / 2;
}
if (width !== 0 && height !== 0) {
let imageInitialDimension = null;
if (isImageElement(newElement)) {
imageInitialDimension = {
initialWidth: width,
initialHeight: height,
};
}
scene.mutateElement(
newElement,
{
x: newX + (originOffset?.x ?? 0),
y: newY + (originOffset?.y ?? 0),
width,
height,
...imageInitialDimension,
},
{ informMutation, isDragging: false },
);
}
};

View File

@@ -0,0 +1,519 @@
import {
ORIG_ID,
randomId,
randomInteger,
arrayToMap,
castArray,
findLastIndex,
getUpdatedTimestamp,
isTestEnv,
} from "@excalidraw/common";
import type { Mutable } from "@excalidraw/common/utility-types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
getElementsInGroup,
getNewGroupIdsForDuplication,
getSelectedGroupForElement,
} from "./groups";
import {
bindElementsToFramesAfterDuplication,
getFrameChildren,
} from "./frame";
import { normalizeElementOrder } from "./sortElements";
import { bumpVersion } from "./mutateElement";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "./typeChecks";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
import { isNonDeletedElement } from ".";
import type {
ElementsMap,
ExcalidrawElement,
GroupId,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
/**
* Duplicate an element, often used in the alt-drag operation.
* Note that this method has gotten a bit complicated since the
* introduction of gruoping/ungrouping elements.
* @param editingGroupId The current group being edited. The new
* element will inherit this group and its
* parents.
* @param groupIdMapForOperation A Map that maps old group IDs to
* duplicated ones. If you are duplicating
* multiple elements at once, share this map
* amongst all of them
* @param element Element to duplicate
*/
export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId: AppState["editingGroupId"],
groupIdMapForOperation: Map<GroupId, GroupId>,
element: TElement,
randomizeSeed?: boolean,
): Readonly<TElement> => {
const copy = deepCopyElement(element);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = randomId();
copy.updated = getUpdatedTimestamp();
copy.created = copy.updated;
if (randomizeSeed) {
copy.seed = randomInteger();
bumpVersion(copy);
}
copy.groupIds = getNewGroupIdsForDuplication(
copy.groupIds,
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, randomId());
}
return groupIdMapForOperation.get(groupId)!;
},
);
return copy;
};
export const duplicateElements = (
opts: {
elements: readonly ExcalidrawElement[];
randomizeSeed?: boolean;
overrides?: (data: {
duplicateElement: ExcalidrawElement;
origElement: ExcalidrawElement;
origIdToDuplicateId: Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>;
}) => Partial<ExcalidrawElement>;
} & (
| {
/**
* Duplicates all elements in array.
*
* Use this when programmaticaly duplicating elements, without direct
* user interaction.
*/
type: "everything";
// TODO remove/review this once we add frame children order migration
// and invariant checks
preserveFrameChildrenOrder?: boolean;
}
| {
/**
* Duplicates specified elements and inserts them back into the array
* in specified order.
*
* Use this when duplicating Scene elements, during user interaction
* such as alt-drag or on duplicate action.
*/
type: "in-place";
idsOfElementsToDuplicate: Map<
ExcalidrawElement["id"],
ExcalidrawElement
>;
appState: {
editingGroupId: AppState["editingGroupId"];
selectedGroupIds: AppState["selectedGroupIds"];
};
}
),
) => {
let { elements } = opts;
const appState =
"appState" in opts
? opts.appState
: ({
editingGroupId: null,
selectedGroupIds: {},
} as const);
// Ids of elements that have already been processed so we don't push them
// into the array twice if we end up backtracking when retrieving
// discontiguous group of elements (can happen due to a bug, or in edge
// cases such as a group containing deleted elements which were not selected).
//
// This is not enough to prevent duplicates, so we do a second loop afterwards
// to remove them.
//
// For convenience we mark even the newly created ones even though we don't
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const groupIdMap = new Map();
const duplicatedElements: NonDeletedExcalidrawElement[] = [];
const origElements: ExcalidrawElement[] = [];
const origIdToDuplicateId = new Map<
ExcalidrawElement["id"],
ExcalidrawElement["id"]
>();
const duplicateIdToOrigElement = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const duplicateElementsMap = new Map<string, NonDeletedExcalidrawElement>();
const elementsMap = arrayToMap(elements) as ElementsMap;
const _idsOfElementsToDuplicate =
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
const preserveFrameChildrenOrder =
opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
elements
.filter((el) => el.groupIds?.includes(groupId))
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
}
}
elements = normalizeElementOrder(elements);
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
// helper functions
// -------------------------------------------------------------------------
// Used for the heavy lifing of copying a single element, a group of elements
// an element with bound text etc.
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
// SAFETY: this should never happen, but we
// want to make sure we log it if it does
if (!isNonDeletedElement(element)) {
console.error(
"[NONDELETED][INVARIANT] Element to duplicate should be non-deleted",
);
}
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
opts.randomizeSeed,
) as NonDeletedExcalidrawElement;
processedIds.set(newElement.id, true);
duplicateElementsMap.set(newElement.id, newElement);
origIdToDuplicateId.set(element.id, newElement.id);
duplicateIdToOrigElement.set(newElement.id, element);
origElements.push(element);
duplicatedElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
);
return (
Array.isArray(element) ? _newElements : _newElements[0] || null
) as T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null;
};
// Helper to position cloned elements in the Z-order the product needs it
const insertBeforeOrAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
if (!elements) {
return;
}
if (index > elementsWithDuplicates.length - 1) {
elementsWithDuplicates.push(...castArray(elements));
return;
}
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
if (!_idsOfElementsToDuplicate.has(element.id)) {
continue;
}
// groups
// -------------------------------------------------------------------------
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.groupIds?.includes(groupId);
});
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (
!preserveFrameChildrenOrder &&
element.frameId &&
frameIdsToDuplicate.has(element.frameId)
) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
if (preserveFrameChildrenOrder) {
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
copyElements(element),
);
continue;
}
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertBeforeOrAfterIndex(
targetIndex,
copyElements([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([element, boundTextElement]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
return el.id === element.id || el.id === container?.id;
});
if (container) {
insertBeforeOrAfterIndex(
targetIndex,
copyElements([container, element]),
);
} else {
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
}
continue;
}
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertBeforeOrAfterIndex(
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
copyElements(element),
);
}
// ---------------------------------------------------------------------------
fixDuplicatedBindingsAfterDuplication(
duplicatedElements,
origIdToDuplicateId,
duplicateElementsMap as NonDeletedSceneElementsMap,
);
bindElementsToFramesAfterDuplication(
elementsWithDuplicates,
origElements,
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 {
duplicatedElements,
duplicateElementsMap,
elementsWithDuplicates,
origIdToDuplicateId,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
//
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
// Typed arrays and other non-null objects.
//
// Adapted from https://github.com/lukeed/klona
//
// The reason for `deepCopyElement()` wrapper is type safety (only allow
// passing ExcalidrawElement as the top-level argument).
const _deepCopyElement = (val: any, depth: number = 0) => {
// only clone non-primitives
if (val == null || typeof val !== "object") {
return val;
}
const objectType = Object.prototype.toString.call(val);
if (objectType === "[object Object]") {
const tmp =
typeof val.constructor === "function"
? Object.create(Object.getPrototypeOf(val))
: {};
for (const key in val) {
if (val.hasOwnProperty(key)) {
// don't copy non-serializable objects like these caches. They'll be
// populated when the element is rendered.
if (depth === 0 && (key === "shape" || key === "canvas")) {
continue;
}
tmp[key] = _deepCopyElement(val[key], depth + 1);
}
}
return tmp;
}
if (Array.isArray(val)) {
let k = val.length;
const arr = new Array(k);
while (k--) {
arr[k] = _deepCopyElement(val[k], depth + 1);
}
return arr;
}
// we're not cloning non-array & non-plain-object objects because we
// don't support them on excalidraw elements yet. If we do, we need to make
// sure we start cloning them, so let's warn about it.
if (import.meta.env.DEV) {
if (
objectType !== "[object Object]" &&
objectType !== "[object Array]" &&
objectType.startsWith("[object ")
) {
console.warn(
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
);
}
}
return val;
};
/**
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
* any value. The purpose is to to break object references for immutability
* reasons, whenever we want to keep the original element, but ensure it's not
* mutated.
*
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
* Typed arrays and other non-null objects.
*/
export const deepCopyElement = <T extends ExcalidrawElement>(
val: T,
): Mutable<T> => {
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
/**
* Create and link between shapes.
*/
import { ELEMENT_LINK_KEY, normalizeLink } from "@excalidraw/common";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { elementsAreInSameGroup } from "./groups";
import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<
AppProps["generateLinkForSelection"],
undefined
> = (id, type) => {
const url = window.location.href;
try {
const link = new URL(url);
link.searchParams.set(ELEMENT_LINK_KEY, id);
return normalizeLink(link.toString());
} catch (error) {
console.error(error);
}
return normalizeLink(url);
};
export const getLinkIdAndTypeFromSelection = (
selectedElements: ExcalidrawElement[],
appState: AppState,
): {
id: string;
type: "element" | "group";
} | null => {
if (
selectedElements.length > 0 &&
canCreateLinkFromElements(selectedElements)
) {
if (selectedElements.length === 1) {
return {
id: selectedElements[0].id,
type: "element",
};
}
if (selectedElements.length > 1) {
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
if (selectedGroupId) {
return {
id: selectedGroupId,
type: "group",
};
}
return {
id: selectedElements[0].groupIds[0],
type: "group",
};
}
}
return null;
};
export const canCreateLinkFromElements = (
selectedElements: ExcalidrawElement[],
) => {
if (selectedElements.length === 1) {
return true;
}
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
return true;
}
return false;
};
export const isElementLink = (url: string) => {
try {
const _url = new URL(url);
return (
_url.searchParams.has(ELEMENT_LINK_KEY) &&
_url.host === window.location.host
);
} catch (error) {
return false;
}
};
export const parseElementLinkFromURL = (url: string) => {
try {
const { searchParams } = new URL(url);
if (searchParams.has(ELEMENT_LINK_KEY)) {
const id = searchParams.get(ELEMENT_LINK_KEY);
return id;
}
} catch {}
return null;
};

View File

@@ -0,0 +1,535 @@
import {
FONT_FAMILY,
VERTICAL_ALIGN,
escapeDoubleQuotes,
getFontString,
} from "@excalidraw/common";
import type { ExcalidrawProps } from "@excalidraw/excalidraw/types";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping";
import { isIframeElement } from "./typeChecks";
import type {
ExcalidrawIframeLikeElement,
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
const RE_MSFORMS = /^(?:https?:\/\/)?forms\.microsoft\.com\//;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const RE_REDDIT =
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeLikeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const parseGoogleDriveVideoLink = (
url: string,
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
const hostname = urlObj.hostname.replace(/^www\./, "");
if (hostname !== "drive.google.com") {
return null;
}
let fileId: string | null = null;
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
if (pathMatch?.[1]) {
fileId = pathMatch[1];
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
// Shared Drive links can be emitted as:
// - /open?id=<fileId> (common "open in Drive" format)
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
fileId = urlObj.searchParams.get("id");
}
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
return null;
}
// Some Drive share links include `resourcekey` for access to link-shared
// files; preserve it in the preview URL so embeds keep working.
const resourceKey = urlObj.searchParams.get("resourcekey");
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
return {
fileId,
resourceKey:
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
? resourceKey
: undefined,
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
// normalize to seconds for a stable preview URL.
timestamp: timestamp > 0 ? timestamp : undefined,
};
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"drive.google.com",
"figma.com",
"link.excalidraw.com",
"gist.github.com",
"twitter.com",
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
"val.town",
"giphy.com",
"reddit.com",
"forms.microsoft.com",
]);
const ALLOW_SAME_ORIGIN = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"drive.google.com",
"figma.com",
"twitter.com",
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
"reddit.com",
"forms.microsoft.com",
]);
export const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`;
};
export const getEmbedLink = (
link: string | null | undefined,
): IframeDataWithSandbox | null => {
if (!link) {
return null;
}
if (embeddedLinkCache.has(link)) {
return embeddedLinkCache.get(link)!;
}
const originalLink = link;
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
);
let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const startTime = parseYouTubeLikeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {
case "embed/":
case "watch?v=":
case "shorts/":
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
break;
case "playlist?list=":
case "embed/videoseries?list=":
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
break;
default:
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
break;
}
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const vimeoLink = link.match(RE_VIMEO);
if (vimeoLink?.[1]) {
const target = vimeoLink?.[1];
const error = !/^\d+$/.test(target)
? new URIError("Invalid embed link format")
: undefined;
type = "video";
link = `https://player.vimeo.com/video/${target}?api=1`;
aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
error,
sandbox: { allowSameOrigin },
};
}
const googleDriveVideo = parseGoogleDriveVideoLink(link);
if (googleDriveVideo) {
type = "video";
const searchParams = new URLSearchParams();
if (googleDriveVideo.resourceKey) {
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
}
if (googleDriveVideo.timestamp) {
searchParams.set("t", `${googleDriveVideo.timestamp}`);
}
const search = searchParams.toString();
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
search ? `?${search}` : ""
}`;
aspectRatio = { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const figmaLink = link.match(RE_FIGMA);
if (figmaLink) {
type = "generic";
link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
link,
)}`;
aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const valLink = link.match(RE_VALTOWN);
if (valLink) {
link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
if (RE_MSFORMS.test(link) && !link.includes("embed=true")) {
link += link.includes("?") ? "&embed=true" : "?embed=true";
}
if (RE_TWITTER.test(link)) {
const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only.
// Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed.
const safeURL = escapeDoubleQuotes(
`https://twitter.com/x/status/${postId}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = escapeDoubleQuotes(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = escapeDoubleQuotes(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${safeURL}.js"></script>
<style type="text/css">
* { margin: 0px; }
table, .gist { height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
intrinsicSize: { w: 550, h: 720 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(link, ret);
return ret;
}
embeddedLinkCache.set(link, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
};
export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawIframeLikeElement,
): NonDeletedExcalidrawElement => {
let text: string;
if (isIframeElement(element)) {
text = "IFrame element";
} else {
text =
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
}
const fontSize = Math.max(
Math.min(element.width / 2, element.width / text.length),
element.width / 30,
);
const fontFamily = FONT_FAMILY.Helvetica;
const fontString = getFontString({
fontSize,
fontFamily,
});
return newTextElement({
x: element.x + element.width / 2,
y: element.y + element.height / 2,
strokeColor:
element.strokeColor !== "transparent" ? element.strokeColor : "black",
backgroundColor: "transparent",
fontFamily,
fontSize,
text: wrapText(text, fontString, element.width - 20),
textAlign: "center",
verticalAlign: VERTICAL_ALIGN.MIDDLE,
angle: element.angle ?? 0,
});
};
const matchHostname = (
url: string,
/** using a Set assumes it already contains normalized bare domains */
allowedHostnames: Set<string> | string,
): string | null => {
try {
const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, "");
if (allowedHostnames instanceof Set) {
if (ALLOWED_DOMAINS.has(bareDomain)) {
return bareDomain;
}
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
return bareDomainWithFirstSubdomainWildcarded;
}
return null;
}
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
if (bareDomain === bareAllowedHostname) {
return bareAllowedHostname;
}
} catch (error) {
// ignore
}
return null;
};
export const maybeParseEmbedSrc = (str: string): string => {
const twitterMatch = str.match(RE_TWITTER_EMBED);
if (twitterMatch && twitterMatch.length === 2) {
return twitterMatch[1];
}
const redditMatch = str.match(RE_REDDIT_EMBED);
if (redditMatch && redditMatch.length === 2) {
return redditMatch[1];
}
const gistMatch = str.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 2) {
return gistMatch[1];
}
if (RE_GIPHY.test(str)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
}
const match = str.match(RE_GENERIC_EMBED);
if (match && match.length === 2) {
return match[1];
}
return str;
};
export const embeddableURLValidator = (
url: string | null | undefined,
validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
): boolean => {
if (!url) {
return false;
}
if (validateEmbeddable != null) {
if (typeof validateEmbeddable === "function") {
const ret = validateEmbeddable(url);
// if return value is undefined, leave validation to default
if (typeof ret === "boolean") {
return ret;
}
} else if (typeof validateEmbeddable === "boolean") {
return validateEmbeddable;
} else if (validateEmbeddable instanceof RegExp) {
return validateEmbeddable.test(url);
} else if (Array.isArray(validateEmbeddable)) {
for (const domain of validateEmbeddable) {
if (domain instanceof RegExp) {
if (url.match(domain)) {
return true;
}
} else if (matchHostname(url, domain)) {
return true;
}
}
return false;
}
}
return !!matchHostname(url, ALLOWED_DOMAINS);
};

View File

@@ -0,0 +1,763 @@
import { invariant, toBrandedType, type Bounds } from "@excalidraw/common";
import {
clamp,
type GlobalPoint,
pointFrom,
type LocalPoint,
} from "@excalidraw/math";
import type {
AppState,
PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow";
import {
HEADING_DOWN,
HEADING_LEFT,
HEADING_RIGHT,
HEADING_UP,
compareHeading,
headingForPointFromElement,
type Heading,
} from "./heading";
import { LinearElementEditor } from "./linearElementEditor";
import { mutateElement } from "./mutateElement";
import {
newArrowElement,
newElement,
newStickyNoteElement,
} from "./newElement";
import { aabbForElement } from "./bounds";
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
import {
isBindableElement,
isElbowArrow,
isFrameElement,
isFlowchartNodeElement,
} from "./typeChecks";
import {
type NonDeleted,
type ElementsMap,
type ExcalidrawBindableElement,
type ExcalidrawElement,
type ExcalidrawFlowchartNodeElement,
type NonDeletedExcalidrawElement,
type NonDeletedSceneElementsMap,
type Ordered,
type OrderedExcalidrawElement,
} from "./types";
import type { Scene } from "./Scene";
export type LinkDirection = "up" | "right" | "down" | "left";
const VERTICAL_OFFSET = 100;
const HORIZONTAL_OFFSET = 100;
type Interval = { start: number; end: number };
const mergeIntervals = (intervals: Interval[]): Interval[] => {
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Interval[] = [];
for (const interval of sorted) {
const last = merged[merged.length - 1];
if (last && interval.start <= last.end) {
last.end = Math.max(last.end, interval.end);
} else {
merged.push({ ...interval });
}
}
return merged;
};
const intervalIsFree = (start: number, size: number, occupied: Interval[]) =>
occupied.every((o) => start + size <= o.start || start >= o.end);
// Nearest `start` for a segment of `size` avoiding every occupied interval,
// searching both sides of `ideal`; a tie resolves toward the positive side.
const findNearestFreeSlot = (
ideal: number,
size: number,
occupied: Interval[],
): number => {
if (intervalIsFree(ideal, size, occupied)) {
return ideal;
}
const gapStarts = [-Infinity, ...occupied.map((o) => o.end)];
const gapEnds = [...occupied.map((o) => o.start), Infinity];
let best = ideal;
let bestDistance = Infinity;
for (let i = 0; i < gapStarts.length; i++) {
if (gapEnds[i] - gapStarts[i] < size) {
continue;
}
const start = clamp(ideal, gapStarts[i], gapEnds[i] - size);
const distance = Math.abs(start - ideal);
if (distance <= bestDistance) {
best = start;
bestDistance = distance;
}
}
return best;
};
// Walk the arrow bindings to collect every node that belongs to the same
// flowchart as `node` — the whole connected component acts as the obstacle
// set during placement (#8518).
const getConnectedFlowchartNodes = (
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): ExcalidrawBindableElement[] => {
const arrows = [...elementsMap.values()].filter(isElbowArrow);
const visited = new Set<string>([node.id]);
const queue: string[] = [node.id];
const connected: ExcalidrawBindableElement[] = [];
while (queue.length > 0) {
const currentId = queue.shift()!;
for (const arrow of arrows) {
const startId = arrow.startBinding?.elementId;
const endId = arrow.endBinding?.elementId;
let neighborId: string | undefined;
if (startId === currentId) {
neighborId = endId;
} else if (endId === currentId) {
neighborId = startId;
}
if (!neighborId || visited.has(neighborId)) {
continue;
}
visited.add(neighborId);
const neighbor = elementsMap.get(neighborId);
if (neighbor && isBindableElement(neighbor)) {
connected.push(neighbor);
queue.push(neighborId);
}
}
}
return connected;
};
// Place a cluster of `count` equally-sized nodes next to `parent`:
// - the primary axis (the creation direction) is fixed at exactly one gap
// away from the parent, forming a search band the cluster will occupy
// - along the cross axis the cluster slides into the free slot nearest the
// parent-centered ideal, treating band obstacles as immovable
// - `stickyCrossStart` anchors an already-visible pending cluster: growing it
// extends it at either end so the existing pending nodes keep their
// positions, unless the grown cluster no longer fits there
const placeCluster = (
parent: ExcalidrawFlowchartNodeElement,
direction: LinkDirection,
count: number,
obstacles: readonly Bounds[],
stickyCrossStart: number | null,
): { positions: { x: number; y: number }[]; crossStart: number } => {
const horizontal = direction === "left" || direction === "right";
// INSIGHT: new nodes copy the parent's dimensions
const nodePrimarySize = horizontal ? parent.width : parent.height;
const nodeCrossSize = horizontal ? parent.height : parent.width;
const primaryGap = horizontal ? HORIZONTAL_OFFSET : VERTICAL_OFFSET;
const crossGap = horizontal ? VERTICAL_OFFSET : HORIZONTAL_OFFSET;
const parentPrimaryStart = horizontal ? parent.x : parent.y;
const parentCrossCenter = horizontal
? parent.y + parent.height / 2
: parent.x + parent.width / 2;
const primaryStart =
direction === "right" || direction === "down"
? parentPrimaryStart + nodePrimarySize + primaryGap
: parentPrimaryStart - primaryGap - nodePrimarySize;
// cross-axis intervals of the obstacles sharing the band, inflated so the
// cluster keeps at least one gap of clearance
const occupied = mergeIntervals(
obstacles
.filter((bounds) => {
const start = horizontal ? bounds[0] : bounds[1];
const end = horizontal ? bounds[2] : bounds[3];
return start < primaryStart + nodePrimarySize && end > primaryStart;
})
.map((bounds) => ({
start: (horizontal ? bounds[1] : bounds[0]) - crossGap,
end: (horizontal ? bounds[3] : bounds[2]) + crossGap,
})),
);
const step = nodeCrossSize + crossGap;
const clusterCrossSize = count * nodeCrossSize + (count - 1) * crossGap;
const anchoredStart =
stickyCrossStart === null
? null
: [stickyCrossStart, stickyCrossStart - step]
.filter((start) => intervalIsFree(start, clusterCrossSize, occupied))
.sort(
(a, b) =>
Math.abs(a + clusterCrossSize / 2 - parentCrossCenter) -
Math.abs(b + clusterCrossSize / 2 - parentCrossCenter),
)[0] ?? null;
const crossStart =
anchoredStart ??
findNearestFreeSlot(
parentCrossCenter - clusterCrossSize / 2,
clusterCrossSize,
occupied,
);
const positions = Array.from({ length: count }, (_, index) => {
const cross = crossStart + index * step;
return horizontal
? { x: primaryStart, y: cross }
: { x: cross, y: primaryStart };
});
return { positions, crossStart };
};
const cloneFlowchartNode = (
template: ExcalidrawFlowchartNodeElement,
x: number,
y: number,
) => {
const commonNodeProps = {
x,
y,
width: template.width,
height: template.height,
roundness: template.roundness,
roughness: template.roughness,
backgroundColor: template.backgroundColor,
strokeColor: template.strokeColor,
strokeWidth: template.strokeWidth,
opacity: template.opacity,
fillStyle: template.fillStyle,
strokeStyle: template.strokeStyle,
};
const node =
template.type === "stickynote"
? newStickyNoteElement({
type: "stickynote",
...commonNodeProps,
baseHeight: template.baseHeight,
})
: newElement({
type: template.type,
...commonNodeProps,
});
invariant(
isFlowchartNodeElement(node),
"not an ExcalidrawFlowchartNodeElement",
);
return node;
};
export const addNewNodes = (
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
appState: AppState,
direction: LinkDirection,
scene: Scene,
numberOfNodes: number,
stickyCrossStart: number | null = null,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
const obstacles = getConnectedFlowchartNodes(startNode, elementsMap).map(
(node) => aabbForElement(node, elementsMap),
);
const { positions, crossStart } = placeCluster(
startNode,
direction,
numberOfNodes,
obstacles,
stickyCrossStart,
);
const nodes: NonDeletedExcalidrawElement[] = [];
for (const position of positions) {
const nextNode = cloneFlowchartNode(startNode, position.x, position.y);
const bindingArrow = createBindingArrow(
startNode,
nextNode,
direction,
appState,
scene,
);
nodes.push(nextNode, bindingArrow);
}
return { nodes, crossStart };
};
const createBindingArrow = (
startBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
endBindingElement: NonDeleted<ExcalidrawFlowchartNodeElement>,
direction: LinkDirection,
appState: AppState,
scene: Scene,
) => {
let startX: number;
let startY: number;
const PADDING = 6;
switch (direction) {
case "up": {
startX = startBindingElement.x + startBindingElement.width / 2;
startY = startBindingElement.y - PADDING;
break;
}
case "down": {
startX = startBindingElement.x + startBindingElement.width / 2;
startY = startBindingElement.y + startBindingElement.height + PADDING;
break;
}
case "right": {
startX = startBindingElement.x + startBindingElement.width + PADDING;
startY = startBindingElement.y + startBindingElement.height / 2;
break;
}
case "left": {
startX = startBindingElement.x - PADDING;
startY = startBindingElement.y + startBindingElement.height / 2;
break;
}
}
let endX: number;
let endY: number;
switch (direction) {
case "up": {
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
break;
}
case "down": {
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
endY = endBindingElement.y - startY - PADDING;
break;
}
case "right": {
endX = endBindingElement.x - startX - PADDING;
endY = endBindingElement.y - startY + endBindingElement.height / 2;
break;
}
case "left": {
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
endY = endBindingElement.y - startY + endBindingElement.height / 2;
break;
}
}
const bindingArrow = newArrowElement({
type: "arrow",
x: startX,
y: startY,
startArrowhead: null,
endArrowhead: appState.currentItemEndArrowhead,
strokeColor: startBindingElement.strokeColor,
strokeStyle: startBindingElement.strokeStyle,
strokeWidth: startBindingElement.strokeWidth,
opacity: startBindingElement.opacity,
roughness: startBindingElement.roughness,
points: [pointFrom(0, 0), pointFrom(endX, endY)],
elbowed: true,
});
const elementsMap = scene.getNonDeletedElementsMap();
bindBindingElement(
bindingArrow,
startBindingElement,
"orbit",
"start",
scene,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set(
startBindingElement.id,
startBindingElement as OrderedExcalidrawElement,
);
changedElements.set(
endBindingElement.id,
endBindingElement as OrderedExcalidrawElement,
);
changedElements.set(
bindingArrow.id,
bindingArrow as OrderedExcalidrawElement,
);
LinearElementEditor.movePoints(
bindingArrow,
scene,
new Map([
[
1,
{
point: bindingArrow.points[1],
},
],
]),
);
const update = updateElbowArrowPoints(
bindingArrow,
toBrandedType<NonDeletedSceneElementsMap>(
new Map([
...elementsMap.entries(),
[startBindingElement.id, startBindingElement],
[endBindingElement.id, endBindingElement],
[bindingArrow.id, bindingArrow],
] as [string, Ordered<NonDeletedExcalidrawElement>][]),
),
{ points: bindingArrow.points },
);
return {
...bindingArrow,
...update,
isDeleted: bindingArrow.isDeleted,
};
};
export class FlowChartNavigator {
isExploring: boolean = false;
// nodes that are ONE link away (successor and predecessor both included)
private sameLevelNodes: ExcalidrawElement[] = [];
private sameLevelIndex: number = 0;
// set it to the opposite of the defalut creation direction
private direction: LinkDirection | null = null;
// for speedier navigation
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
clear() {
this.isExploring = false;
this.sameLevelNodes = [];
this.sameLevelIndex = 0;
this.direction = null;
this.visitedNodes.clear();
}
exploreByDirection(
element: ExcalidrawElement,
elementsMap: ElementsMap,
direction: LinkDirection,
): ExcalidrawElement["id"] | null {
if (!isBindableElement(element)) {
return null;
}
// clear if going at a different direction
if (direction !== this.direction) {
this.clear();
}
// add the current node to the visited
if (!this.visitedNodes.has(element.id)) {
this.visitedNodes.add(element.id);
}
/**
* CASE:
* - already started exploring, AND
* - there are multiple nodes at the same level, AND
* - still going at the same direction, AND
*
* RESULT:
* - loop through nodes at the same level
*
* WHY:
* - provides user the capability to loop through nodes at the same level
*/
if (
this.isExploring &&
direction === this.direction &&
this.sameLevelNodes.length > 1
) {
this.sameLevelIndex =
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
return this.sameLevelNodes[this.sameLevelIndex].id;
}
const nodes = [
...FlowChartNavigator.getSuccessors(element, elementsMap, direction),
...FlowChartNavigator.getPredecessors(element, elementsMap, direction),
];
/**
* CASE:
* - just started exploring at the given direction
*
* RESULT:
* - go to the first node in the given direction
*/
if (nodes.length > 0) {
this.sameLevelIndex = 0;
this.isExploring = true;
this.sameLevelNodes = nodes;
this.direction = direction;
this.visitedNodes.add(nodes[0].id);
return nodes[0].id;
}
/**
* CASE:
* - (just started exploring or still going at the same direction) OR
* - there're no nodes at the given direction
*
* RESULT:
* - go to some other unvisited linked node
*
* WHY:
* - provide a speedier navigation from a given node to some predecessor
* without the user having to change arrow key
*/
if (direction === this.direction || !this.isExploring) {
if (!this.isExploring) {
// just started and no other nodes at the given direction
// so the current node is technically the first visited node
// (this is needed so that we don't get stuck between looping through )
this.visitedNodes.add(element.id);
}
const otherDirections: LinkDirection[] = [
"up",
"right",
"down",
"left",
].filter((dir): dir is LinkDirection => dir !== direction);
const otherLinkedNodes = otherDirections
.map((dir) => [
...FlowChartNavigator.getSuccessors(element, elementsMap, dir),
...FlowChartNavigator.getPredecessors(element, elementsMap, dir),
])
.flat()
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
for (const linkedNode of otherLinkedNodes) {
if (!this.visitedNodes.has(linkedNode.id)) {
this.visitedNodes.add(linkedNode.id);
this.isExploring = true;
this.direction = direction;
return linkedNode.id;
}
}
}
return null;
}
private static getNodeRelatives(
type: "predecessors" | "successors",
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
const items = [...elementsMap.values()].reduce(
(
acc: { relative: ExcalidrawBindableElement; heading: Heading }[],
el,
) => {
let oppositeBinding;
if (
isElbowArrow(el) &&
// we want check existence of the opposite binding, in the direction
// we're interested in
(oppositeBinding =
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
// similarly, we need to filter only arrows bound to target node
el[type === "predecessors" ? "endBinding" : "startBinding"]
?.elementId === node.id
) {
const relative = elementsMap.get(oppositeBinding.elementId);
if (!relative) {
return acc;
}
invariant(
isBindableElement(relative),
"not an ExcalidrawBindableElement",
);
const edgePoint = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(
node,
aabbForElement(node, elementsMap),
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({
relative,
heading,
});
}
return acc;
},
[],
);
switch (direction) {
case "up":
return items
.filter((item) => compareHeading(item.heading, HEADING_UP))
.map((item) => item.relative);
case "down":
return items
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
.map((item) => item.relative);
case "right":
return items
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
.map((item) => item.relative);
case "left":
return items
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
.map((item) => item.relative);
}
}
private static getSuccessors(
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
return FlowChartNavigator.getNodeRelatives(
"successors",
node,
elementsMap,
direction,
);
}
private static getPredecessors(
node: ExcalidrawBindableElement,
elementsMap: ElementsMap,
direction: LinkDirection,
) {
return FlowChartNavigator.getNodeRelatives(
"predecessors",
node,
elementsMap,
direction,
);
}
}
export class FlowChartCreator {
isCreatingChart: boolean = false;
private numberOfNodes: number = 0;
private direction: LinkDirection | null = null;
// cross-axis anchor of the pending cluster, so growing it keeps the
// already-visible pending nodes in place
private clusterCrossStart: number | null = null;
pendingNodes: PendingExcalidrawElements | null = null;
createNodes(
startNode: NonDeleted<ExcalidrawFlowchartNodeElement>,
appState: AppState,
direction: LinkDirection,
scene: Scene,
) {
const elementsMap = scene.getNonDeletedElementsMap();
if (direction !== this.direction) {
this.numberOfNodes = 1;
this.clusterCrossStart = null;
} else {
this.numberOfNodes += 1;
}
const { nodes, crossStart } = addNewNodes(
startNode,
appState,
direction,
scene,
this.numberOfNodes,
this.clusterCrossStart,
);
this.isCreatingChart = true;
this.direction = direction;
this.clusterCrossStart = crossStart;
this.pendingNodes = nodes;
// add pending nodes to the same frame as the start node
// if every pending node is at least intersecting with the frame
if (startNode.frameId) {
const frame = elementsMap.get(startNode.frameId);
invariant(
frame && isFrameElement(frame),
"not an ExcalidrawFrameElement",
);
if (
frame &&
this.pendingNodes.every(
(node) =>
elementsAreInFrameBounds([node], frame, elementsMap) ||
elementOverlapsWithFrame(node, frame, elementsMap),
)
) {
this.pendingNodes = this.pendingNodes.map((node) =>
mutateElement(node, elementsMap, {
frameId: startNode.frameId,
}),
);
}
}
}
clear() {
this.isCreatingChart = false;
this.pendingNodes = null;
this.direction = null;
this.numberOfNodes = 0;
this.clusterCrossStart = null;
}
}
export const isNodeInFlowchart = (
element: ExcalidrawFlowchartNodeElement,
elementsMap: ElementsMap,
) => {
for (const [, el] of elementsMap) {
if (
el.type === "arrow" &&
(el.startBinding?.elementId === element.id ||
el.endBinding?.elementId === element.id)
) {
return true;
}
}
return false;
};

View File

@@ -0,0 +1,473 @@
import { arrayToMap } from "@excalidraw/common";
import {
validateOrderKey,
generateNKeysBetween,
} from "@excalidraw/fractional-indexing";
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElementId } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
import { isNonDeletedElement } from ".";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
Ordered,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
}
/**
* Envisioned relation between array order and fractional indices:
*
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
* - it's undesirable to perform reorder for each related operation, therefore it's necessary to cache the order defined by fractional indices into an ordered data structure
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
* - it's necessary to always keep the fractional indices in sync with the array order
* - elements with invalid indices should be detected and synced, without altering the already valid indices
*
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
* - as the fractional indices are encoded as part of the elements, it opens up possibilities for incremental-like APIs
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconciliation & undo/redo
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
*/
/**
* Ensure that all elements have valid fractional indices.
*
* @throws `InvalidFractionalIndexError` if invalid index is detected.
*/
export const validateFractionalIndices = (
elements: readonly ExcalidrawElement[],
{
shouldThrow = false,
includeBoundTextValidation = false,
ignoreLogs,
reconciliationContext,
}: {
shouldThrow: boolean;
includeBoundTextValidation: boolean;
ignoreLogs?: true;
reconciliationContext?: {
localElements: ReadonlyArray<ExcalidrawElement>;
remoteElements: ReadonlyArray<ExcalidrawElement>;
};
},
) => {
const errorMessages = [];
const stringifyElement = (element: ExcalidrawElement | void) =>
`${element?.index}:${element?.id}:${element?.type}:${element?.isDeleted}:${element?.version}:${element?.versionNonce}`;
const elementsMap = includeBoundTextValidation ? arrayToMap(elements) : null;
const indices = elements.map((x) => x.index);
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
const successorIndex = indices[i + 1];
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
errorMessages.push(
`Fractional indices invariant has been compromised: "${stringifyElement(
elements[i - 1],
)}", "${stringifyElement(elements[i])}", "${stringifyElement(
elements[i + 1],
)}"`,
);
}
// disabled by default, as we don't fix it
if (
includeBoundTextValidation &&
elementsMap &&
hasBoundTextElement(elements[i]) &&
isNonDeletedElement(elements[i])
) {
const container = elements[i];
const boundTextElementId = getBoundTextElementId(container);
const text = boundTextElementId
? elementsMap.get(boundTextElementId)
: null;
if (
text &&
isNonDeletedElement(text) &&
text.index! <= container.index!
) {
errorMessages.push(
`Fractional indices invariant for bound elements has been compromised: "${stringifyElement(
text,
)}", "${stringifyElement(container)}"`,
);
}
}
}
if (errorMessages.length) {
const error = new InvalidFractionalIndexError();
const additionalContext = [];
if (reconciliationContext) {
additionalContext.push("Additional reconciliation context:");
additionalContext.push(
reconciliationContext.localElements.map((x) => stringifyElement(x)),
);
additionalContext.push(
reconciliationContext.remoteElements.map((x) => stringifyElement(x)),
);
}
if (!ignoreLogs) {
// report just once and with the stacktrace
console.error(
errorMessages.join("\n\n"),
error.stack,
elements.map((x) => stringifyElement(x)),
...additionalContext,
);
}
if (shouldThrow) {
// if enabled, gather all the errors first, throw once
throw error;
}
}
};
/**
* Order the elements based on the fractional indices.
* - when fractional indices are identical, break the tie based on the element id
* - when there is no fractional index in one of the elements, respect the order of the array
*/
export const orderByFractionalIndex = (
elements: OrderedExcalidrawElement[],
) => {
return elements.sort((a, b) => {
// in case the indices are not the defined at runtime
if (isOrderedElement(a) && isOrderedElement(b)) {
if (a.index < b.index) {
return -1;
} else if (a.index > b.index) {
return 1;
}
// break ties based on the element id
return a.id < b.id ? -1 : 1;
}
// defensively keep the array order
return 1;
});
};
/**
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
*/
export const syncMovedIndices = (
elements: readonly ExcalidrawElement[],
movedElements: ElementsMap,
): OrderedExcalidrawElement[] => {
try {
const elementsMap = arrayToMap(elements);
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) => {
const elementUpdates = elementsUpdates.get(x);
if (elementUpdates) {
return { ...x, index: elementUpdates.index };
}
return x;
});
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
elementsCandidates,
// we don't autofix invalid bound text indices, hence don't include it in the validation
{
includeBoundTextValidation: false,
shouldThrow: true,
ignoreLogs: true,
},
);
// split mutation so we don't end up in an incosistent state
for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, { index });
}
} catch (e) {
// fallback to default sync
syncInvalidIndices(elements);
}
return elements as OrderedExcalidrawElement[];
};
/**
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndices = <T extends ExcalidrawElement>(
elements: readonly T[],
): Ordered<T>[] => {
const elementsMap = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, { index });
}
return elements as Ordered<T>[];
};
/**
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndicesImmutable = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap | undefined => {
const syncedElements = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
syncedElements.set(element.id, newElementWith(element, { index }));
}
return syncedElements as SceneElementsMap;
};
/**
* Get contiguous groups of indices of passed moved elements.
*
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
*/
const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[],
movedElements: ElementsMap,
) => {
const indicesGroups: number[][] = [];
let i = 0;
while (i < elements.length) {
if (movedElements.has(elements[i].id)) {
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
while (++i < elements.length) {
if (!movedElements.has(elements[i].id)) {
break;
}
indicesGroup.push(i);
}
indicesGroup.push(i); // push the upper bound index as the last item
indicesGroups.push(indicesGroup);
} else {
i++;
}
}
return indicesGroups;
};
/**
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
*
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
*/
const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
const indicesGroups: number[][] = [];
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
let lowerBoundIndex: number = -1;
let upperBoundIndex: number = 0;
/** @returns maybe valid lowerBound */
const getLowerBound = (
index: number,
): [ExcalidrawElement["index"] | undefined, number] => {
const lowerBound = elements[lowerBoundIndex]
? elements[lowerBoundIndex].index
: undefined;
// we are already iterating left to right, therefore there is no need for additional looping
const candidate = elements[index - 1]?.index;
if (
(!lowerBound && candidate) || // first lowerBound
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
) {
// WARN: candidate's index could be higher or same as the current element's index
return [candidate, index - 1];
}
// cache hit! take the last lower bound
return [lowerBound, lowerBoundIndex];
};
/** @returns always valid upperBound */
const getUpperBound = (
index: number,
): [ExcalidrawElement["index"] | undefined, number] => {
const upperBound = elements[upperBoundIndex]
? elements[upperBoundIndex].index
: undefined;
// cache hit! don't let it find the upper bound again
if (upperBound && index < upperBoundIndex) {
return [upperBound, upperBoundIndex];
}
// set the current upperBoundIndex as the starting point
let i = upperBoundIndex;
while (++i < elements.length) {
const candidate = elements[i]?.index;
if (
(!upperBound && candidate) || // first upperBound
(upperBound && candidate && candidate > upperBound) // next upperBound
) {
return [candidate, i];
}
}
// we reached the end, sky is the limit
return [undefined, i];
};
let i = 0;
while (i < elements.length) {
const current = elements[i].index;
[lowerBound, lowerBoundIndex] = getLowerBound(i);
[upperBound, upperBoundIndex] = getUpperBound(i);
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
// push the lower bound index as the first item
const indicesGroup = [lowerBoundIndex, i];
while (++i < elements.length) {
const current = elements[i].index;
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
break;
}
// assign bounds only for the moved elements
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
indicesGroup.push(i);
}
// push the upper bound index as the last item
indicesGroup.push(upperBoundIndex);
indicesGroups.push(indicesGroup);
} else {
i++;
}
}
return indicesGroups;
};
const isValidFractionalIndex = (
index: ExcalidrawElement["index"] | undefined,
predecessor: ExcalidrawElement["index"] | undefined,
successor: ExcalidrawElement["index"] | undefined,
) => {
if (!index) {
return false;
}
try {
// Format validation
validateOrderKey(index);
} catch {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
if (!predecessor && successor) {
// first element
return index < successor;
}
if (predecessor && !successor) {
// last element
return predecessor < index;
}
// only element in the array
return !!index;
};
const generateIndices = (
elements: readonly ExcalidrawElement[],
indicesGroups: number[][],
) => {
const elementsUpdates = new Map<
ExcalidrawElement,
{ index: FractionalIndex }
>();
for (const indices of indicesGroups) {
const lowerBoundIndex = indices.shift()!;
const upperBoundIndex = indices.pop()!;
const fractionalIndices = generateNKeysBetween(
elements[lowerBoundIndex]?.index,
elements[upperBoundIndex]?.index,
indices.length,
) as FractionalIndex[];
for (let i = 0; i < indices.length; i++) {
const element = elements[indices[i]];
elementsUpdates.set(element, {
index: fractionalIndices[i],
});
}
}
return elementsUpdates;
};
const isOrderedElement = (
element: ExcalidrawElement,
): element is OrderedExcalidrawElement => {
// for now it's sufficient whether the index is there
// meaning, the element was already ordered in the past
// meaning, it is not a newly inserted element, not an unrestored element, etc.
// it does not have to mean that the index itself is valid
if (element.index) {
return true;
}
return false;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
import type {
AppClassProperties,
AppState,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type {
GroupId,
ExcalidrawElement,
NonDeleted,
ElementsMapOrArray,
ElementsMap,
NonDeletedExcalidrawElement,
NonDeletedElementsMapOrArray,
} from "./types";
export const selectGroup = (
groupId: GroupId,
appState: InteractiveCanvasAppState,
elements: readonly NonDeletedExcalidrawElement[],
): Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "selectedElementIds" | "editingGroupId"
> => {
const elementsInGroup = elements.reduce(
(acc: Record<string, true>, element) => {
if (element.groupIds.includes(groupId)) {
acc[element.id] = true;
}
return acc;
},
{},
);
if (Object.keys(elementsInGroup).length < 2) {
if (
appState.selectedGroupIds[groupId] ||
appState.editingGroupId === groupId
) {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: false },
editingGroupId: null,
};
}
return appState;
}
return {
editingGroupId: appState.editingGroupId,
selectedGroupIds: { ...appState.selectedGroupIds, [groupId]: true },
selectedElementIds: {
...appState.selectedElementIds,
...elementsInGroup,
},
};
};
export const selectGroupsForSelectedElements = (function () {
type SelectGroupsReturnType = Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
>;
let lastSelectedElements: readonly NonDeletedExcalidrawElement[] | null =
null;
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
let lastReturnValue: SelectGroupsReturnType | null = null;
const _selectGroups = (
selectedElements: readonly NonDeletedExcalidrawElement[],
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
prevAppState: InteractiveCanvasAppState,
): SelectGroupsReturnType => {
if (
lastReturnValue !== undefined &&
elements === lastElements &&
selectedElements === lastSelectedElements &&
appState.editingGroupId === lastReturnValue?.editingGroupId
) {
return lastReturnValue;
}
const selectedGroupIds: Record<GroupId, boolean> = {};
// Gather all the groups withing selected elements
for (const selectedElement of selectedElements) {
let groupIds = selectedElement.groupIds;
if (appState.editingGroupId) {
// handle the case where a group is nested within a group
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const lastSelectedGroup = groupIds[groupIds.length - 1];
selectedGroupIds[lastSelectedGroup] = true;
}
}
// Gather all the elements within selected groups
const groupElementsIndex: Record<GroupId, string[]> = {};
const selectedElementIdsInGroups = elements.reduce(
(acc: Record<string, true>, element) => {
if (element.isDeleted) {
return acc;
}
const groupId = element.groupIds.find((id) => selectedGroupIds[id]);
if (groupId) {
acc[element.id] = true;
// Populate the index
if (!Array.isArray(groupElementsIndex[groupId])) {
groupElementsIndex[groupId] = [element.id];
} else {
groupElementsIndex[groupId].push(element.id);
}
}
return acc;
},
{},
);
for (const groupId of Object.keys(groupElementsIndex)) {
// If there is one element in the group, and the group is selected or it's being edited, it's not a group
if (groupElementsIndex[groupId].length < 2) {
if (selectedGroupIds[groupId]) {
selectedGroupIds[groupId] = false;
}
}
}
lastElements = elements;
lastSelectedElements = selectedElements;
lastReturnValue = {
editingGroupId: appState.editingGroupId,
selectedGroupIds,
selectedElementIds: makeNextSelectedElementIds(
{
...appState.selectedElementIds,
...selectedElementIdsInGroups,
},
prevAppState,
),
};
return lastReturnValue;
};
/**
* When you select an element, you often want to actually select the whole group it's in, unless
* you're currently editing that group.
*/
const selectGroupsForSelectedElements = (
appState: Pick<AppState, "selectedElementIds" | "editingGroupId">,
elements: readonly NonDeletedExcalidrawElement[],
prevAppState: InteractiveCanvasAppState,
/**
* supply null in cases where you don't have access to App instance and
* you don't care about optimizing selectElements retrieval
*/
app: AppClassProperties | null,
): Mutable<
Pick<
InteractiveCanvasAppState,
"selectedGroupIds" | "editingGroupId" | "selectedElementIds"
>
> => {
const selectedElements = app
? app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
// supplying elements explicitly in case we're passed non-state elements
elements,
})
: getSelectedElements(elements, appState);
if (!selectedElements.length) {
return {
selectedGroupIds: {},
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds(
appState.selectedElementIds,
prevAppState,
),
};
}
return _selectGroups(selectedElements, elements, appState, prevAppState);
};
selectGroupsForSelectedElements.clearCache = () => {
lastElements = null;
lastSelectedElements = null;
lastReturnValue = null;
};
return selectGroupsForSelectedElements;
})();
/**
* If the element's group is selected, don't render an individual
* selection border around it.
*/
export const isSelectedViaGroup = (
appState: Pick<
InteractiveCanvasAppState,
"editingGroupId" | "selectedGroupIds"
>,
element: ExcalidrawElement,
) => getSelectedGroupForElement(appState, element) != null;
export const getSelectedGroupForElement = (
appState: Pick<
InteractiveCanvasAppState,
"editingGroupId" | "selectedGroupIds"
>,
element: ExcalidrawElement,
) =>
element.groupIds
.filter((groupId) => groupId !== appState.editingGroupId)
.find((groupId) => appState.selectedGroupIds[groupId]);
export const getSelectedGroupIds = (
appState: Pick<InteractiveCanvasAppState, "selectedGroupIds">,
): GroupId[] =>
Object.entries(appState.selectedGroupIds)
.filter(([groupId, isSelected]) => isSelected)
.map(([groupId, isSelected]) => groupId);
// given a list of elements, return the the actual group ids that should be selected
// or used to update the elements
export const selectGroupsFromGivenElements = (
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
let nextAppState: InteractiveCanvasAppState = {
...appState,
selectedGroupIds: {},
};
for (const element of elements) {
let groupIds = element.groupIds;
if (appState.editingGroupId) {
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
if (indexOfEditingGroup > -1) {
groupIds = groupIds.slice(0, indexOfEditingGroup);
}
}
if (groupIds.length > 0) {
const groupId = groupIds[groupIds.length - 1];
nextAppState = {
...nextAppState,
...selectGroup(groupId, nextAppState, elements),
};
}
}
return nextAppState.selectedGroupIds;
};
export const editGroupForSelectedElement = (
appState: AppState,
element: NonDeleted<ExcalidrawElement>,
): AppState => {
return {
...appState,
editingGroupId: element.groupIds.length ? element.groupIds[0] : null,
selectedGroupIds: {},
selectedElementIds: {
[element.id]: true,
},
};
};
export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
element.groupIds.includes(groupId);
export const getElementsInGroup = <
P extends NonDeletedExcalidrawElement | ExcalidrawElement,
>(
elements: P extends NonDeletedExcalidrawElement
? NonDeletedElementsMapOrArray
: ElementsMapOrArray,
groupId: string,
): P[] => {
const elementsInGroup: P[] = [];
for (const element of elements.values()) {
if (isElementInGroup(element, groupId)) {
elementsInGroup.push(element as P);
}
}
return elementsInGroup;
};
export const getSelectedGroupIdForElement = (
element: ExcalidrawElement,
selectedGroupIds: { [groupId: string]: boolean },
) => element.groupIds.find((groupId) => selectedGroupIds[groupId]);
export const addToGroup = (
prevGroupIds: ExcalidrawElement["groupIds"],
newGroupId: GroupId,
editingGroupId: AppState["editingGroupId"],
) => {
// insert before the editingGroupId, or push to the end.
const groupIds = [...prevGroupIds];
const positionOfEditingGroupId = editingGroupId
? groupIds.indexOf(editingGroupId)
: -1;
const positionToInsert =
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
groupIds.splice(positionToInsert, 0, newGroupId);
return groupIds;
};
export const removeFromSelectedGroups = (
groupIds: ExcalidrawElement["groupIds"],
selectedGroupIds: { [groupId: string]: boolean },
) => groupIds.filter((groupId) => !selectedGroupIds[groupId]);
export const getMaximumGroups = <
T extends NonDeletedExcalidrawElement | ExcalidrawElement,
>(
elements: T[],
elementsMap: ElementsMap,
): T[][] => {
const groups: Map<String, T[]> = new Map<String, T[]>();
elements.forEach((element: T) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
// Include bound text if present when grouping
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement as T);
}
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
const nonDeletedGroupIds = new Set<string>();
for (const [, element] of elements) {
// defensive check
if (element.isDeleted) {
continue;
}
// defensive fallback
for (const groupId of element.groupIds ?? []) {
nonDeletedGroupIds.add(groupId);
}
}
return nonDeletedGroupIds;
};
export const elementsAreInSameGroup = (
elements: readonly ExcalidrawElement[],
) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();
let maxGroup = 0;
for (const group of allGroups) {
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
if (groupCount.get(group)! > maxGroup) {
maxGroup = groupCount.get(group)!;
}
}
return maxGroup === elements.length;
};
export const isInGroup = (element: ExcalidrawElement) => {
return element.groupIds.length > 0;
};
export const getNewGroupIdsForDuplication = (
groupIds: ExcalidrawElement["groupIds"],
editingGroupId: AppState["editingGroupId"],
mapper: (groupId: GroupId) => GroupId,
) => {
const copy = [...groupIds];
const positionOfEditingGroupId = editingGroupId
? groupIds.indexOf(editingGroupId)
: -1;
const endIndex =
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
for (let index = 0; index < endIndex; index++) {
copy[index] = mapper(copy[index]);
}
return copy;
};
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<Pick<AppState, "selectedGroupIds" | "editingGroupId">>,
): NonDeletedExcalidrawElement[][] => {
const buckets: Map<string, NonDeletedExcalidrawElement[]> = new Map();
const selectedGroupIds = getSelectedGroupIds(appState);
const isSingleSelectedGroupCase =
selectedGroupIds.length === 1 &&
selectedElements.every((element) => isSelectedViaGroup(appState, element));
selectedElements.forEach((element) => {
// skip dependent boundTextElements, they are appended after their container
if (isBoundToContainer(element)) {
return;
}
let bucketKey: string;
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
bucketKey = `${element.id}_element`;
} else {
// if only one group is selected, grouping is based on inner hierarchy
const keyIndex = isSingleSelectedGroupCase
? element.groupIds.indexOf(selectedGroupId) - 1
: element.groupIds.indexOf(selectedGroupId);
// edge case: single selected group where element is non member of inner group
bucketKey =
keyIndex < 0
? `${element.id}_element`
: `${element.groupIds[keyIndex]}_group`;
}
const currentBucketMembers = buckets.get(bucketKey) ?? [];
const boundTextElement = getBoundTextElement(element, elementsMap);
// preserve boundtext after container ordering
buckets.set(bucketKey, [
...currentBucketMembers,
element,
...(boundTextElement ? [boundTextElement] : []),
]);
});
return [...buckets.values()];
};

View File

@@ -0,0 +1,287 @@
import {
invariant,
isDevEnv,
isTestEnv,
type Bounds,
} from "@excalidraw/common";
import {
pointFrom,
pointFromVector,
pointRotateRads,
pointScaleFromOrigin,
pointsEqual,
triangleIncludesPoint,
vectorCross,
vectorFromPoint,
vectorScale,
} from "@excalidraw/math";
import type {
LocalPoint,
GlobalPoint,
Triangle,
Vector,
} from "@excalidraw/math";
import { getCenterForBounds } from "./bounds";
import type { ExcalidrawBindableElement } from "./types";
export const HEADING_RIGHT = [1, 0] as Heading;
export const HEADING_DOWN = [0, 1] as Heading;
export const HEADING_LEFT = [-1, 0] as Heading;
export const HEADING_UP = [0, -1] as Heading;
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
export const vectorToHeading = (vec: Vector): Heading => {
const [x, y] = vec;
const absX = Math.abs(x);
const absY = Math.abs(y);
if (x > absY) {
return HEADING_RIGHT;
} else if (x <= -absY) {
return HEADING_LEFT;
} else if (y > absX) {
return HEADING_DOWN;
}
return HEADING_UP;
};
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => vectorToHeading(vectorFromPoint<P>(p, o));
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
p: P,
o: P,
) => headingIsHorizontal(headingForPoint<P>(p, o));
export const compareHeading = (a: Heading, b: Heading) =>
a[0] === b[0] && a[1] === b[1];
export const headingIsHorizontal = (a: Heading) =>
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
const headingForPointFromDiamondElement = (
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
point: Readonly<GlobalPoint>,
): Heading => {
const midPoint = getCenterForBounds(aabb);
if (isDevEnv() || isTestEnv()) {
invariant(
element.width > 0 && element.height > 0,
"Diamond element has no width or height",
);
invariant(
!pointsEqual(midPoint, point),
"The point is too close to the element mid point to determine heading",
);
}
const SHRINK = 0.95; // Rounded elements tolerance
const top = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const right = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const bottom = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
const left = pointFromVector(
vectorScale(
vectorFromPoint(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
midPoint,
element.angle,
),
midPoint,
),
SHRINK,
),
midPoint,
);
// Corners
if (
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
0 &&
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
) {
return headingForPoint(top, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, right),
vectorFromPoint(right, bottom),
) <= 0 &&
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
) {
return headingForPoint(right, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, left),
) <= 0 &&
vectorCross(
vectorFromPoint(point, bottom),
vectorFromPoint(bottom, right),
) > 0
) {
return headingForPoint(bottom, midPoint);
} else if (
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
0 &&
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
) {
return headingForPoint(left, midPoint);
}
// Sides
if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(top, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) > 0
) {
const p = element.width > element.height ? top : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(right, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : right;
return headingForPoint(p, midPoint);
} else if (
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(bottom, midPoint),
) <= 0 &&
vectorCross(
vectorFromPoint(point, midPoint),
vectorFromPoint(left, midPoint),
) > 0
) {
const p = element.width > element.height ? bottom : left;
return headingForPoint(p, midPoint);
}
const p = element.width > element.height ? top : left;
return headingForPoint(p, midPoint);
};
// Gets the heading for the point by creating a bounding box around the rotated
// close fitting bounding box, then creating 4 search cones around the center of
// the external bbox.
export const headingForPointFromElement = <Point extends GlobalPoint>(
element: Readonly<ExcalidrawBindableElement>,
aabb: Readonly<Bounds>,
p: Readonly<Point>,
): Heading => {
const SEARCH_CONE_MULTIPLIER = 2;
const midPoint = getCenterForBounds(aabb);
if (element.type === "diamond") {
return headingForPointFromDiamondElement(element, aabb, p);
}
const topLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const topRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[1]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomLeft = pointScaleFromOrigin(
pointFrom(aabb[0], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
const bottomRight = pointScaleFromOrigin(
pointFrom(aabb[2], aabb[3]),
midPoint,
SEARCH_CONE_MULTIPLIER,
) as Point;
return triangleIncludesPoint<Point>(
[topLeft, topRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_UP
: triangleIncludesPoint<Point>(
[topRight, bottomRight, midPoint] as Triangle<Point>,
p,
)
? HEADING_RIGHT
: triangleIncludesPoint<Point>(
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
p,
)
? HEADING_DOWN
: HEADING_LEFT;
};
export const flipHeading = (h: Heading): Heading =>
[
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
] as Heading;

View File

@@ -0,0 +1,153 @@
// -----------------------------------------------------------------------------
// ExcalidrawImageElement & related helpers
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "@excalidraw/common";
import type {
AppClassProperties,
DataURL,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import { isInitializedImageElement } from "./typeChecks";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
} from "./types";
export const loadHTMLImageElement = (dataURL: DataURL) => {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve(image);
};
image.onerror = (error) => {
reject(error);
};
image.src = dataURL;
});
};
/** NOTE: updates cache even if already populated with given image. Thus,
* you should filter out the images upstream if you want to optimize this. */
export const updateImageCache = async ({
fileIds,
files,
imageCache,
}: {
fileIds: FileId[];
files: BinaryFiles;
imageCache: AppClassProperties["imageCache"];
}) => {
const updatedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
await Promise.all(
fileIds.reduce((promises, fileId) => {
const fileData = files[fileId as string];
if (fileData && !updatedFiles.has(fileId)) {
updatedFiles.set(fileId, true);
return promises.concat(
(async () => {
try {
if (fileData.mimeType === MIME_TYPES.binary) {
throw new Error("Only images can be added to ImageCache");
}
const imagePromise = loadHTMLImageElement(fileData.dataURL);
const data = {
image: imagePromise,
mimeType: fileData.mimeType,
} as const;
// store the promise immediately to indicate there's an in-progress
// initialization
imageCache.set(fileId, data);
const image = await imagePromise;
imageCache.set(fileId, { ...data, image });
} catch (error: any) {
erroredFiles.set(fileId, true);
}
})(),
);
}
return promises;
}, [] as Promise<any>[]),
);
return {
imageCache,
/** includes errored files because they cache was updated nonetheless */
updatedFiles,
/** files that failed when creating HTMLImageElement */
erroredFiles,
};
};
export const getInitializedImageElements = (
elements: readonly ExcalidrawElement[],
) =>
elements.filter((element) =>
isInitializedImageElement(element),
) as InitializedExcalidrawImageElement[];
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
// lower-casing due to XML/HTML convention differences
// https://johnresig.com/blog/nodename-case-sensitivity
return node?.nodeName.toLowerCase() === "svg";
};
export const normalizeSVG = (SVGString: string) => {
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
throw new Error("Invalid SVG");
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
}
let width = svg.getAttribute("width");
let height = svg.getAttribute("height");
// Do not use % or auto values for width/height
// to avoid scaling issues when rendering at different sizes/zoom levels
if (width?.includes("%") || width === "auto") {
width = null;
}
if (height?.includes("%") || height === "auto") {
height = null;
}
const viewBox = svg.getAttribute("viewBox");
if (!width || !height) {
width = width || "50";
height = height || "50";
if (viewBox) {
const match = viewBox.match(
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
);
if (match) {
[, width, height] = match;
}
}
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
// Make sure viewBox is set
if (!viewBox) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
return svg.outerHTML;
}
};

View File

@@ -0,0 +1,108 @@
import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeleted,
ElementsMapOrArray,
} from "./types";
/**
* @deprecated unsafe, use hashElementsVersion instead
*/
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
elements.reduce((acc, el) => acc + el.version, 0);
/**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/
export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
let hash = 5381;
for (const element of toIterable(elements)) {
hash = (hash << 5) + hash + element.versionNonce;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
// string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such.
// note: hashes individual code units (not code points),
// but for hashing purposes this is fine as it iterates through every code unit
// (as such, no need to encode to byte string first)
export const hashString = (s: string): number => {
let hash: number = 5381;
for (let i = 0; i < s.length; i++) {
const char: number = s.charCodeAt(i);
hash = (hash << 5) + hash + char;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T,
): element is NonDeleted<T> => !element.isDeleted;
export const getVisibleElements = (
elements: readonly ExcalidrawElement[],
): readonly NonDeletedExcalidrawElement[] =>
elements.filter(
(el): el is NonDeletedExcalidrawElement =>
isNonDeletedElement(el) && !isInvisiblySmallElement(el),
);
export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements: readonly T[],
): readonly NonDeleted<T>[] => elements.filter(isNonDeletedElement);
export * from "./align";
export * from "./arrowEndpointText";
export * from "./binding";
export * from "./bounds";
export * from "./bucketFill";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
export * from "./cropElement";
export * from "./delta";
export * from "./distance";
export * from "./distribute";
export * from "./dragElements";
export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
export * from "./heading";
export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./shape";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./stickyNote";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transform";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
export * from "./arrowheads";
export * from "./convertToShape";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,196 @@
import {
getSizeFromPoints,
randomInteger,
getUpdatedTimestamp,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types";
import { ShapeCache } from "./shape";
import { updateElbowArrowPoints } from "./elbowArrow";
import { isElbowArrow } from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
// `created` is lifetime metadata: it is set once when an element instance is
// constructed (or duplicated) and never updated, so it is not part of an
// element update. An explicit repair must construct a new element object.
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "updated" | "created"
>;
/**
* This function tracks updates of text elements for the purposes for collaboration.
* The version is used to compare updates when more than one user is working in
* the same drawing.
*
* WARNING: this won't trigger the component to update, so if you need to trigger component update,
* use `scene.mutateElement` or `ExcalidrawImperativeAPI.mutateElement` instead.
*/
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
elementsMap: ElementsMap,
updates: ElementUpdate<TElement>,
options?: {
isDragging?: boolean;
isBindingEnabled?: boolean;
isMidpointSnappingEnabled?: boolean;
},
) => {
let didChange = false;
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId } = updates as any;
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
) {
updates = {
...updates,
angle: 0 as Radians,
...updateElbowArrowPoints(
{
...(element as ExcalidrawElbowArrowElement),
x: updates.x || element.x,
y: updates.y || element.y,
},
elementsMap as NonDeletedSceneElementsMap,
updates as ElementUpdate<ExcalidrawElbowArrowElement>,
options,
),
};
} else if (typeof points !== "undefined") {
updates = { ...getSizeFromPoints(points), ...updates };
}
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
// (except for specific keys we handle below)
(typeof value !== "object" ||
value === null ||
key === "groupIds" ||
key === "scale")
) {
continue;
}
if (key === "scale") {
const prevScale = (element as any)[key];
const nextScale = value;
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
continue;
}
} else if (key === "points") {
const prevPoints = (element as any)[key];
const nextPoints = value;
if (prevPoints.length === nextPoints.length) {
let didChangePoints = false;
let index = prevPoints.length;
while (--index) {
const prevPoint = prevPoints[index];
const nextPoint = nextPoints[index];
if (
prevPoint[0] !== nextPoint[0] ||
prevPoint[1] !== nextPoint[1]
) {
didChangePoints = true;
break;
}
}
if (!didChangePoints) {
continue;
}
}
}
(element as any)[key] = value;
didChange = true;
}
}
if (!didChange) {
return element;
}
if (
typeof updates.height !== "undefined" ||
typeof updates.width !== "undefined" ||
typeof fileId != "undefined" ||
typeof points !== "undefined"
) {
ShapeCache.delete(element);
}
element.version = updates.version ?? element.version + 1;
element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp();
return element;
};
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
/** pass `true` to always regenerate */
force = false,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
didChange = true;
}
}
if (!didChange && !force) {
return element;
}
return {
...element,
...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(),
};
};
/**
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
*
* NOTE: does not trigger re-render.
*/
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
element: T,
version?: ExcalidrawElement["version"],
) => {
element.version = (version ?? element.version) + 1;
element.versionNonce = randomInteger();
element.updated = getUpdatedTimestamp();
return element;
};

View File

@@ -0,0 +1,660 @@
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_STICKY_NOTE_SIZE,
STICKY_NOTE_MIN_SIZE,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN,
randomInteger,
randomId,
getFontString,
getUpdatedTimestamp,
getLineHeight,
} from "@excalidraw/common";
import type { Radians } from "@excalidraw/math";
import type { MarkOptional, Merge } from "@excalidraw/common/utility-types";
import {
getElementAbsoluteCoords,
getResizedElementAbsoluteCoords,
} from "./bounds";
import { newElementWith } from "./mutateElement";
import {
normalizeStickyNoteBackgroundColor,
normalizeStickyNoteStrokeColor,
} from "./stickyNote";
import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
ExcalidrawTextElement,
ExcalidrawLinearElement,
ExcalidrawGenericElement,
NonDeleted,
TextAlign,
VerticalAlign,
Arrowhead,
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawTextContainer,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawIframeElement,
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
ExcalidrawStickyNoteElement,
} from "./types";
export type ElementConstructorOpts = MarkOptional<
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
| "width"
| "height"
| "angle"
| "groupIds"
| "frameId"
| "index"
| "boundElements"
| "seed"
| "version"
| "versionNonce"
| "link"
| "strokeStyle"
| "fillStyle"
| "strokeColor"
| "backgroundColor"
| "roughness"
| "strokeWidth"
| "roundness"
| "locked"
| "opacity"
| "customData"
| "created"
>;
const _newElementBase = <T extends ExcalidrawElement>(
type: T["type"],
{
x,
y,
strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
roughness = DEFAULT_ELEMENT_PROPS.roughness,
opacity = DEFAULT_ELEMENT_PROPS.opacity,
width = 0,
height = 0,
angle = 0 as Radians,
groupIds = [],
frameId = null,
index = null,
roundness = null,
boundElements = null,
link = null,
locked = DEFAULT_ELEMENT_PROPS.locked,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
// NOTE (mtolmacs): This is a temporary check to detect extremely large
// element position or sizing
if (
x < -1e6 ||
x > 1e6 ||
y < -1e6 ||
y > 1e6 ||
width < -1e6 ||
width > 1e6 ||
height < -1e6 ||
height > 1e6
) {
console.error("New element size or position is too large", {
x,
y,
width,
height,
// @ts-ignore
points: rest.points,
});
}
const timestamp = getUpdatedTimestamp();
// assign type to guard against excess properties
const element: Merge<
ExcalidrawGenericElement,
{ type: T["type"]; isDeleted: false }
> = {
id: rest.id || randomId(),
type,
x,
y,
width,
height,
angle,
strokeColor,
backgroundColor,
fillStyle,
strokeWidth,
strokeStyle,
roughness,
opacity,
groupIds,
frameId,
index,
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,
isDeleted: false as false,
boundElements,
updated: timestamp,
// Preserve explicit null when reconstructing a legacy element with its id.
created: rest.created === undefined ? timestamp : rest.created,
link,
locked,
customData: rest.customData,
};
return element;
};
export const newElement = (
opts: {
type: ExcalidrawGenericElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawGenericElement> =>
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
/**
* Style invariants of a sticky note: never-transparent colors, solid fill.
* Applied by the constructor and by every normalization pass. Returns the
* same object when nothing needs fixing.
*/
export const normalizeStickyNoteStyle = <T extends ExcalidrawStickyNoteElement>(
element: T,
): T => {
return newElementWith(element as ExcalidrawStickyNoteElement, {
backgroundColor: normalizeStickyNoteBackgroundColor(
element.backgroundColor,
),
strokeColor: normalizeStickyNoteStrokeColor(element.strokeColor),
fillStyle: "solid",
}) as T;
};
/**
* Geometry invariants of a *finalized* sticky note: minimum size and
* `baseHeight ≤ height`. Deliberately not part of the constructor — a
* pointer-down draft starts at 0×0 like every other tool and is previewed at
* its true dragged size; pointer-up, restore, the skeleton path and the
* action post-passes enforce this.
*/
export const normalizeStickyNoteGeometry = <
T extends ExcalidrawStickyNoteElement,
>(
element: T,
): T => {
const width = Math.max(element.width, STICKY_NOTE_MIN_SIZE);
const baseHeight = Math.max(
element.baseHeight || element.height || DEFAULT_STICKY_NOTE_SIZE,
STICKY_NOTE_MIN_SIZE,
);
return newElementWith(element as ExcalidrawStickyNoteElement, {
width,
height: Math.max(element.height, baseHeight),
baseHeight,
}) as T;
};
/** all sticky note invariants (style + finalized geometry) */
export const normalizeStickyNote = <T extends ExcalidrawStickyNoteElement>(
element: T,
): T => {
return normalizeStickyNoteGeometry(normalizeStickyNoteStyle(element));
};
export const newStickyNoteElement = (
opts: {
type: "stickynote";
baseHeight?: number;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawStickyNoteElement> => {
const base = _newElementBase<ExcalidrawStickyNoteElement>("stickynote", opts);
// no size inflation here (see `normalizeStickyNoteGeometry`)
return normalizeStickyNoteStyle({
...base,
baseHeight: opts.baseHeight ?? base.height,
});
};
export const newEmbeddableElement = (
opts: {
type: "embeddable";
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawEmbeddableElement> => {
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
};
export const newIframeElement = (
opts: {
type: "iframe";
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawIframeElement> => {
return {
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
};
};
export const newFrameElement = (
opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
type: "frame",
name: opts?.name || null,
},
{},
);
return frameElement;
};
export const newMagicFrameElement = (
opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawMagicFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
type: "magicframe",
name: opts?.name || null,
},
{},
);
return frameElement;
};
/**
* The point of the text box its alignment pins, as ratios of width/height.
*
* This is the point that stays put as the text grows — see the sides passed to
* `adjustXYWithRotation` in `getAdjustedDimensions`.
*/
export const getTextAnchorRatios = (opts: {
textAlign: ExcalidrawTextElement["textAlign"];
verticalAlign: ExcalidrawTextElement["verticalAlign"];
}) => ({
x: opts.textAlign === "center" ? 0.5 : opts.textAlign === "right" ? 1 : 0,
y:
opts.verticalAlign === VERTICAL_ALIGN.MIDDLE
? 0.5
: opts.verticalAlign === VERTICAL_ALIGN.BOTTOM
? 1
: 0,
});
/** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = (
opts: {
textAlign: ExcalidrawTextElement["textAlign"];
verticalAlign: ExcalidrawTextElement["verticalAlign"];
},
metrics: {
width: number;
height: number;
},
) => {
const ratios = getTextAnchorRatios(opts);
return {
x: metrics.width * ratios.x,
y: metrics.height * ratios.y,
};
};
export const newTextElement = (
opts: {
text: string;
originalText?: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"];
autoResize?: ExcalidrawTextElement["autoResize"];
labelPosition?: ExcalidrawTextElement["labelPosition"];
baseFontSize?: ExcalidrawTextElement["baseFontSize"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => {
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
const text = normalizeText(opts.text);
const metrics = measureText(
text,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
const offsets = getTextElementPositionOffsets(
{ textAlign, verticalAlign },
metrics,
);
const textElementProps: NonDeleted<ExcalidrawTextElement> = {
..._newElementBase<ExcalidrawTextElement>("text", opts),
text,
fontSize,
baseFontSize: opts.baseFontSize ?? null,
fontFamily,
textAlign,
verticalAlign,
x: opts.x - offsets.x,
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
containerId: opts.containerId || null,
originalText: opts.originalText ?? text,
autoResize: opts.autoResize ?? true,
lineHeight,
labelPosition: opts.labelPosition ?? null,
};
const textElement: NonDeleted<ExcalidrawTextElement> = newElementWith(
textElementProps,
{},
);
return textElement;
};
const getAdjustedDimensions = (
element: ExcalidrawTextElement,
elementsMap: ElementsMap,
nextText: string,
): {
x: number;
y: number;
width: number;
height: number;
} => {
let { width: nextWidth, height: nextHeight } = measureText(
nextText,
getFontString(element),
element.lineHeight,
);
// wrapped text
if (!element.autoResize) {
nextWidth = element.width;
}
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
if (
textAlign === "center" &&
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
!element.containerId &&
element.autoResize
) {
const prevMetrics = measureText(
element.text,
getFontString(element),
element.lineHeight,
);
const offsets = getTextElementPositionOffsets(element, {
width: nextWidth - prevMetrics.width,
height: nextHeight - prevMetrics.height,
});
x = element.x - offsets.x;
y = element.y - offsets.y;
} else {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
element,
nextWidth,
nextHeight,
false,
);
const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2;
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
// grow away from the edge(s) the alignment anchors the text to, so that
// the anchor stays put as the text is edited. `verticalAlign` has no
// visual effect on unbound text, but standalone text bound to an arrow
// endpoint uses it to pin the side the arrow attaches to.
[x, y] = adjustXYWithRotation(
{
n:
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
verticalAlign === VERTICAL_ALIGN.BOTTOM,
s:
verticalAlign === VERTICAL_ALIGN.MIDDLE ||
verticalAlign === VERTICAL_ALIGN.TOP,
e: textAlign === "center" || textAlign === "left",
w: textAlign === "center" || textAlign === "right",
},
element.x,
element.y,
element.angle,
deltaX1,
deltaY1,
deltaX2,
deltaY2,
);
}
return {
width: nextWidth,
height: nextHeight,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
};
};
const adjustXYWithRotation = (
sides: {
n?: boolean;
e?: boolean;
s?: boolean;
w?: boolean;
},
x: number,
y: number,
angle: number,
deltaX1: number,
deltaY1: number,
deltaX2: number,
deltaY2: number,
): [number, number] => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
if (sides.e && sides.w) {
x += deltaX1 + deltaX2;
} else if (sides.e) {
x += deltaX1 * (1 + cos);
y += deltaX1 * sin;
x += deltaX2 * (1 - cos);
y += deltaX2 * -sin;
} else if (sides.w) {
x += deltaX1 * (1 - cos);
y += deltaX1 * -sin;
x += deltaX2 * (1 + cos);
y += deltaX2 * sin;
}
if (sides.n && sides.s) {
y += deltaY1 + deltaY2;
} else if (sides.n) {
x += deltaY1 * sin;
y += deltaY1 * (1 - cos);
x += deltaY2 * -sin;
y += deltaY2 * (1 + cos);
} else if (sides.s) {
x += deltaY1 * -sin;
y += deltaY1 * (1 + cos);
x += deltaY2 * sin;
y += deltaY2 * (1 - cos);
}
return [x, y];
};
export const refreshTextDimensions = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
elementsMap: ElementsMap,
text = textElement.text,
) => {
if (textElement.isDeleted) {
return;
}
if (container || !textElement.autoResize) {
text = wrapText(
text,
getFontString(textElement),
container
? getBoundTextMaxWidth(container, textElement)
: textElement.width,
);
}
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
return { text, ...dimensions };
};
export const newFreeDrawElement = (
opts: {
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
};
};
export const newLinearElement = (
opts: {
type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => {
const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [],
startBinding: null,
endBinding: null,
startArrowhead: null,
endArrowhead: null,
};
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
};
export const newArrowElement = <T extends boolean>(
opts: {
type: ExcalidrawArrowElement["type"];
startArrowhead?: Arrowhead | null;
endArrowhead?: Arrowhead | null;
points?: ExcalidrawArrowElement["points"];
elbowed?: T;
fixedSegments?: ExcalidrawElbowArrowElement["fixedSegments"] | null;
} & ElementConstructorOpts,
): T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement> => {
if (opts.elbowed) {
return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [],
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: true,
fixedSegments: opts.fixedSegments || [],
startIsSpecial: false,
endIsSpecial: false,
} as NonDeleted<ExcalidrawElbowArrowElement>;
}
return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [],
startBinding: null,
endBinding: null,
startArrowhead: opts.startArrowhead || null,
endArrowhead: opts.endArrowhead || null,
elbowed: false,
} as T extends true
? NonDeleted<ExcalidrawElbowArrowElement>
: NonDeleted<ExcalidrawArrowElement>;
};
export const newImageElement = (
opts: {
type: ExcalidrawImageElement["type"];
status?: ExcalidrawImageElement["status"];
fileId?: ExcalidrawImageElement["fileId"];
scale?: ExcalidrawImageElement["scale"];
crop?: ExcalidrawImageElement["crop"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawImageElement> => {
return {
..._newElementBase<ExcalidrawImageElement>("image", opts),
// in the future we'll support changing stroke color for some SVG elements,
// and `transparent` will likely mean "use original colors of the image"
strokeColor: "transparent",
status: opts.status ?? "pending",
fileId: opts.fileId ?? null,
scale: opts.scale ?? [1, 1],
crop: opts.crop ?? null,
};
};

View File

@@ -0,0 +1,112 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
import {
pointFrom,
pointOnLineSegment,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import {
SIDE_RESIZING_THRESHOLD,
type EditorInterface,
} from "@excalidraw/common";
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds";
import {
getTransformHandlesFromCoords,
getTransformHandles,
getOmitSidesForEditorInterface,
canResizeFromSides,
} from "./transformHandles";
import { isImageElement, isLinearElement } from "./typeChecks";
import type {
TransformHandleType,
TransformHandle,
MaybeTransformHandleType,
} from "./transformHandles";
import type {
ExcalidrawElement,
PointerType,
NonDeletedExcalidrawElement,
ElementsMap,
} from "./types";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
x: number,
y: number,
) =>
x >= transformHandle[0] &&
x <= transformHandle[0] + transformHandle[2] &&
y >= transformHandle[1] &&
y <= transformHandle[1] + transformHandle[3];
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
x: number,
y: number,
zoom: Zoom,
pointerType: PointerType,
editorInterface: EditorInterface,
): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) {
return false;
}
const { rotation: rotationTransformHandle, ...transformHandles } =
getTransformHandles(
element,
zoom,
elementsMap,
pointerType,
getOmitSidesForEditorInterface(editorInterface),
);
if (
rotationTransformHandle &&
isInsideTransformHandle(rotationTransformHandle, x, y)
) {
return "rotation" as TransformHandleType;
}
const filter = Object.keys(transformHandles).filter((key) => {
const transformHandle =
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
if (!transformHandle) {
return false;
}
return isInsideTransformHandle(transformHandle, x, y);
});
if (filter.length > 0) {
return filter[0] as TransformHandleType;
}
if (canResizeFromSides(editorInterface)) {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// do not resize from the sides for linear elements with only two points
if (!(isLinearElement(element) && element.points.length <= 2)) {
const SPACING = isImageElement(element)
? 0
: SIDE_RESIZING_THRESHOLD / zoom.value;
const ZOOMED_SIDE_RESIZING_THRESHOLD =
SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING),
pointFrom(cx, cy),
element.angle,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (
pointOnLineSegment(
pointFrom(x, y),
side as LineSegment<Point>,
ZOOMED_SIDE_RESIZING_THRESHOLD,
)
) {
return dir as TransformHandleType;
}
}
}
}
return false;
};
export const getElementWithTransformHandleType = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
scenePointerX: number,
scenePointerY: number,
zoom: Zoom,
pointerType: PointerType,
elementsMap: ElementsMap,
editorInterface: EditorInterface,
) => {
return elements.reduce((result, element) => {
if (result) {
return result;
}
const transformHandleType = resizeTest(
element,
elementsMap,
appState,
scenePointerX,
scenePointerY,
zoom,
pointerType,
editorInterface,
);
return transformHandleType ? { element, transformHandleType } : null;
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
};
export const getTransformHandleTypeFromCoords = <
Point extends GlobalPoint | LocalPoint,
>(
[x1, y1, x2, y2]: Bounds,
scenePointerX: number,
scenePointerY: number,
zoom: Zoom,
pointerType: PointerType,
editorInterface: EditorInterface,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0 as Radians,
zoom,
pointerType,
getOmitSidesForEditorInterface(editorInterface),
);
const found = Object.keys(transformHandles).find((key) => {
const transformHandle =
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
return (
transformHandle &&
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
);
});
if (found) {
return found as MaybeTransformHandleType;
}
if (canResizeFromSides(editorInterface)) {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
pointFrom(x1 - SPACING, y1 - SPACING),
pointFrom(x2 + SPACING, y2 + SPACING),
pointFrom(cx, cy),
0 as Radians,
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (
pointOnLineSegment(
pointFrom(scenePointerX, scenePointerY),
side as LineSegment<Point>,
SPACING,
)
) {
return dir as TransformHandleType;
}
}
}
return false;
};
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
const rotateResizeCursor = (cursor: string, angle: number) => {
const index = RESIZE_CURSORS.indexOf(cursor);
if (index >= 0) {
const a = Math.round(angle / (Math.PI / 4));
cursor = RESIZE_CURSORS[(index + a) % RESIZE_CURSORS.length];
}
return cursor;
};
/*
* Returns bi-directional cursor for the element being resized
*/
export const getCursorForResizingElement = (resizingElement: {
element?: ExcalidrawElement;
transformHandleType: MaybeTransformHandleType;
}): string => {
const { element, transformHandleType } = resizingElement;
const shouldSwapCursors =
element && Math.sign(element.height) * Math.sign(element.width) === -1;
let cursor = null;
switch (transformHandleType) {
case "n":
case "s":
cursor = "ns";
break;
case "w":
case "e":
cursor = "ew";
break;
case "nw":
case "se":
if (shouldSwapCursors) {
cursor = "nesw";
} else {
cursor = "nwse";
}
break;
case "ne":
case "sw":
if (shouldSwapCursors) {
cursor = "nwse";
} else {
cursor = "nesw";
}
break;
case "rotation":
return "grab";
}
if (cursor && element) {
cursor = rotateResizeCursor(cursor, element.angle);
}
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angle: Radians,
) => {
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
return {
n: [topLeft, topRight],
e: [topRight, bottomRight],
s: [bottomRight, bottomLeft],
w: [bottomLeft, topLeft],
};
};

View File

@@ -0,0 +1,308 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
import { isElementInViewport } from "./sizeHelpers";
import {
isBoundToContainer,
isFrameLikeElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { getFrameChildren } from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { isNonDeletedElement } from ".";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
const shouldIgnoreElementFromSelection = (element: ExcalidrawElement) =>
element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
* we only keep the frames.
* @param selectedElements
*/
export const excludeElementsInFramesFromSelection = <
T extends ExcalidrawElement,
>(
selectedElements: readonly T[],
) => {
const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
});
return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = <T extends ExcalidrawElement>(
elements: readonly T[],
selection: ExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain",
): T[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
return elementsOverlappingBBox({
elements,
bounds: selectionBounds,
elementsMap,
type: boxSelectionMode,
shouldIgnoreElementFromSelection,
excludeElementsInFrames,
});
};
export const getVisibleAndNonSelectedElements = <
T extends NonDeletedExcalidrawElement,
>(
elements: readonly T[],
selectedElements: readonly ExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) => {
const selectedElementsSet = new Set(
selectedElements.map((element) => element.id),
);
return elements.filter((element) => {
const isVisible = isElementInViewport(
element,
appState.width,
appState.height,
appState,
elementsMap,
);
return !selectedElementsSet.has(element.id) && isVisible;
});
};
// FIXME move this into the editor instance to keep utility methods stateless
export const isSomeElementSelected = (function () {
let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
let lastSelectedElementIds: AppState["selectedElementIds"] | null = null;
let isSelected: boolean | null = null;
const ret = (
elements: readonly NonDeletedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
): boolean => {
if (
isSelected != null &&
elements === lastElements &&
appState.selectedElementIds === lastSelectedElementIds
) {
return isSelected;
}
isSelected = elements.some(
(element) => appState.selectedElementIds[element.id],
);
lastElements = elements;
lastSelectedElementIds = appState.selectedElementIds;
return isSelected;
};
ret.clearCache = () => {
lastElements = null;
lastSelectedElementIds = null;
isSelected = null;
};
return ret;
})();
export const getSelectedElements = (
elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
opts?: {
includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean;
},
) => {
const addedElements = new Set<ExcalidrawElement["id"]>();
// selection can only contain non-deleted elements
const selectedElements: NonDeletedExcalidrawElement[] = [];
for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) {
if (isNonDeletedElement(element)) {
selectedElements.push(element as NonDeletedExcalidrawElement);
addedElements.add(element.id);
} else {
console.error(
"[NONDELETED][INVARIANT] getSelectedElements skipping deleted selected element which should not be in the selection",
);
}
continue;
}
if (
opts?.includeBoundTextElement &&
isBoundToContainer(element) &&
isNonDeletedElement(element) &&
appState.selectedElementIds[element?.containerId]
) {
selectedElements.push(element as NonDeletedExcalidrawElement);
addedElements.add(element.id);
continue;
}
}
if (opts?.includeElementsInFrames) {
const elementsToInclude: NonDeletedExcalidrawElement[] = [];
selectedElements.forEach((element) => {
if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach(
(e) =>
!addedElements.has(e.id) &&
elementsToInclude.push(e as NonDeletedExcalidrawElement),
);
}
elementsToInclude.push(element);
});
return elementsToInclude;
}
return selectedElements;
};
export const getTargetElements = (
elements: ElementsMapOrArray,
appState: Pick<
AppState,
"selectedElementIds" | "editingTextElement" | "newElement" | "activeTool"
>,
) =>
appState.editingTextElement
? [appState.editingTextElement]
: // the drawShape recognition preview in `newElement` is not a real
// in-progress element — targeting it would make the styles panel track
// whatever shape is currently recognized instead of the tool defaults
appState.newElement && appState.activeTool.type !== "autoshape"
? [appState.newElement]
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
});
/**
* returns prevState's selectedElementids if no change from previous, so as to
* retain reference identity for memoization
*/
export const makeNextSelectedElementIds = (
nextSelectedElementIds: AppState["selectedElementIds"],
prevState: Pick<AppState, "selectedElementIds">,
) => {
if (isShallowEqual(prevState.selectedElementIds, nextSelectedElementIds)) {
return prevState.selectedElementIds;
}
return nextSelectedElementIds;
};
const _getLinearElementEditor = (
targetElements: readonly NonDeletedExcalidrawElement[],
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 NonDeletedExcalidrawElement[],
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,
),
};
};
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { getSelectedElements } from "./selection";
import type { NonDeletedExcalidrawElement } from "./types";
export const showSelectedShapeActions = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) =>
Boolean(
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
((appState.activeTool.type !== "custom" &&
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "lasso" &&
appState.activeTool.type !== "eraser" &&
appState.activeTool.type !== "hand" &&
appState.activeTool.type !== "laser"))) ||
getSelectedElements(elements, appState).length),
);

View File

@@ -0,0 +1,284 @@
import {
SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types";
export type VerticalResizeAnchor = "top" | "bottom" | "center";
/** Keeps the chosen edge or center fixed when an element's height changes. */
export const getPositionAfterHeightChange = (
element: Pick<ExcalidrawElement, "x" | "y" | "height" | "angle">,
nextHeight: number,
anchor: VerticalResizeAnchor = "top",
) => {
const delta = (element.height - nextHeight) / 2;
if (anchor === "center") {
// Rotation is about the center, so holding it is angle-independent.
return { x: element.x, y: element.y + delta };
}
const sin = Math.sin(element.angle);
const cos = Math.cos(element.angle);
if (anchor === "bottom") {
return {
x: element.x - delta * sin,
y: element.y + delta * (1 + cos),
};
}
return {
x: element.x + delta * sin,
y: element.y + delta * (1 - cos),
};
};
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements`
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
}
return element.width === 0 && element.height === 0;
};
export const isElementInViewport = (
element: ExcalidrawElement,
width: number,
height: number,
viewTransformations: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
elementsMap: ElementsMap,
) => {
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft,
clientY: viewTransformations.offsetTop,
},
viewTransformations,
);
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + width,
clientY: viewTransformations.offsetTop + height,
},
viewTransformations,
);
return (
topLeftSceneCoords.x <= x2 &&
topLeftSceneCoords.y <= y2 &&
bottomRightSceneCoords.x >= x1 &&
bottomRightSceneCoords.y >= y1
);
};
export const isElementCompletelyInViewport = (
elements: ExcalidrawElement[],
width: number,
height: number,
viewTransformations: {
zoom: Zoom;
offsetLeft: number;
offsetTop: number;
scrollX: number;
scrollY: number;
},
elementsMap: ElementsMap,
padding?: Offsets,
) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + (padding?.left || 0),
clientY: viewTransformations.offsetTop + (padding?.top || 0),
},
viewTransformations,
);
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
{
clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
},
viewTransformations,
);
return (
x1 >= topLeftSceneCoords.x &&
y1 >= topLeftSceneCoords.y &&
x2 <= bottomRightSceneCoords.x &&
y2 <= bottomRightSceneCoords.y
);
};
/**
* Makes a perfect shape or diagonal/horizontal/vertical line
*/
export const getPerfectElementSize = (
elementType: AppState["activeTool"]["type"],
width: number,
height: number,
): { width: number; height: number } => {
const absWidth = Math.abs(width);
const absHeight = Math.abs(height);
if (
elementType === "line" ||
elementType === "arrow" ||
elementType === "freedraw"
) {
const lockedAngle =
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE;
if (lockedAngle === 0) {
height = 0;
} else if (lockedAngle === Math.PI / 2) {
width = 0;
} else {
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
}
} else if (elementType !== "selection") {
height = absWidth * Math.sign(height);
}
return { width, height };
};
export const getLockedLinearCursorAlignSize = (
originX: number,
originY: number,
x: number,
y: number,
customAngle?: number,
) => {
let width = x - originX;
let height = y - originY;
const angle = Math.atan2(height, width) as Radians;
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) {
height = 0;
} else if (lockedAngle === Math.PI / 2) {
width = 0;
} else {
// locked angle line, y = mx + b => mx - y + b = 0
const a1 = Math.tan(lockedAngle);
const b1 = -1;
const c1 = originY - a1 * originX;
// line through cursor, perpendicular to locked angle line
const a2 = -1 / a1;
const b2 = -1;
const c2 = y - a2 * x;
// intersection of the two lines above
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
// delta
width = intersectX - originX;
height = intersectY - originY;
}
return { width, height };
};
export const getNormalizedDimensions = (
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
): {
width: ExcalidrawElement["width"];
height: ExcalidrawElement["height"];
x: ExcalidrawElement["x"];
y: ExcalidrawElement["y"];
} => {
const ret = {
width: element.width,
height: element.height,
x: element.x,
y: element.y,
};
if (element.width < 0) {
const nextWidth = Math.abs(element.width);
ret.width = nextWidth;
ret.x = element.x - nextWidth;
}
if (element.height < 0) {
const nextHeight = Math.abs(element.height);
ret.height = nextHeight;
ret.y = element.y - nextHeight;
}
return ret;
};

View File

@@ -0,0 +1,119 @@
import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
}
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
return elements;
}
return sortedElements;
};
/**
* In theory, when we have text elements bound to a container, they
* should be right after the container element in the elements array.
* However, this is not guaranteed due to old and potential future bugs.
*
* This function sorts containers and their bound texts together. It prefers
* original z-index of container (i.e. it moves bound text elements after
* containers).
*/
export const normalizeBoundElementsOrder = <T extends ExcalidrawElement>(
elements: readonly T[],
) => {
const elementsMap = arrayToMap(elements);
const sortedElements = new Set<T>();
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
}
if (element.boundElements?.length) {
sortedElements.add(element);
for (const boundElement of element.boundElements) {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child);
}
}
continue;
}
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.size !== elements.length) {
console.error(
"normalizeBoundElementsOrder: lost some elements... bailing!",
);
return elements;
}
return [...sortedElements];
};
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(defragmentGroups(elements));
};

View File

@@ -0,0 +1,971 @@
import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_STICKY_NOTE_BG,
MIN_FONT_SIZE,
arrayToMap,
STICKY_NOTE_BODY_INSET_Y,
STICKY_NOTE_FALLBACK_FONT_SIZE,
STICKY_NOTE_FOOTER,
STICKY_NOTE_FONT_STEP,
STICKY_NOTE_MAX_FONT_SIZE,
STICKY_NOTE_MIN_SIZE,
STICKY_NOTE_MIN_FONT_SIZE,
STICKY_NOTE_PADDING,
STICKY_NOTE_SHADOW_OFFSET,
getFontString,
getLineHeight,
isTransparent,
seededRandom,
} from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import { updateBoundElements } from "./binding";
import { newElementWith } from "./mutateElement";
import { getPositionAfterHeightChange } from "./sizeHelpers";
import { computeBoundTextPosition, getBoundTextElement } from "./textElement";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import { isStickyNoteElement, isTextElement } from "./typeChecks";
import type { Scene } from "./Scene";
import type { VerticalResizeAnchor } from "./sizeHelpers";
import type { TransformHandleDirection } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawStickyNoteElement,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "./types";
export type StickyNoteRenderPoint = {
x: number;
y: number;
};
export type StickyNotePathCommand =
| {
type: "move";
point: StickyNoteRenderPoint;
}
| {
type: "line";
point: StickyNoteRenderPoint;
}
| {
type: "quadratic";
control: StickyNoteRenderPoint;
point: StickyNoteRenderPoint;
};
const STICKY_NOTE_RENDER_ROUGHNESS = [0, 1.5, 8] as const;
const STICKY_NOTE_CORNER_RADIUS_RATIO = 0.04;
const STICKY_NOTE_MAX_CORNER_RADIUS = 16;
export const normalizeStickyNoteStrokeColor = (
strokeColor: string | null | undefined,
) => {
return !strokeColor || isTransparent(strokeColor)
? DEFAULT_ELEMENT_PROPS.strokeColor
: strokeColor;
};
export const normalizeStickyNoteBackgroundColor = (
backgroundColor: string | null | undefined,
) => {
return !backgroundColor || isTransparent(backgroundColor)
? DEFAULT_STICKY_NOTE_BG
: backgroundColor;
};
/**
* The update that applies a picked color to an element under the sticky
* note policy: a note is always filled and its label — the visible text,
* which the note's own `strokeColor` seeds — never goes transparent. Every
* color surface (actions, both eyedroppers, paste styles, bind) routes
* through this instead of re-deriving the rule.
*/
/**
* The element a color pick on `element` lands on. A note's label has no fill
* of its own, so a background pick on the label — the styles panel while
* editing it — goes to the note; everything else colors itself.
*/
export const getColorTargetElement = (
element: ExcalidrawElement,
property: "strokeColor" | "backgroundColor",
elementsMap: ElementsMap,
): ExcalidrawElement => {
if (
property === "backgroundColor" &&
isTextElement(element) &&
isStickyNoteBoundText(element, elementsMap)
) {
return elementsMap.get(element.containerId!) ?? element;
}
return element;
};
export const getColorUpdate = (
element: ExcalidrawElement,
property: "strokeColor" | "backgroundColor",
color: string,
elementsMap: ElementsMap,
): { strokeColor: string } | { backgroundColor: string } => {
if (isStickyNoteElement(element)) {
return property === "backgroundColor"
? { backgroundColor: normalizeStickyNoteBackgroundColor(color) }
: { strokeColor: normalizeStickyNoteStrokeColor(color) };
}
if (
property === "strokeColor" &&
isTextElement(element) &&
isStickyNoteBoundText(element, elementsMap)
) {
const container = elementsMap.get(element.containerId!);
return {
strokeColor: isTransparent(color)
? normalizeStickyNoteStrokeColor(container?.strokeColor)
: color,
};
}
return property === "backgroundColor"
? { backgroundColor: color }
: { strokeColor: color };
};
/**
* A note's ink is one color: the container's `strokeColor` — the seed for a
* new label and what the creation-date footer paints with — and its label's.
* Run after a property write to copy the side that changed onto the other;
* when both changed, or neither (data that drifted), the label wins: it is
* the text the user styled. A transparent label always takes the note's
* color. Returns the same array when nothing needs to change.
*/
export const syncStickyNoteInk = <T extends ExcalidrawElement>(
elements: readonly T[],
prevElementsMap: ElementsMap,
): readonly T[] => {
const elementsMap = arrayToMap(elements);
const inkById = new Map<string, string>();
for (const container of elements) {
if (!isStickyNoteElement(container) || container.isDeleted) {
continue;
}
const label = getBoundTextElement(container, elementsMap);
if (!label || label.strokeColor === container.strokeColor) {
continue;
}
const containerChanged =
prevElementsMap.get(container.id)?.strokeColor !== container.strokeColor;
const labelChanged =
prevElementsMap.get(label.id)?.strokeColor !== label.strokeColor;
if (
isTransparent(label.strokeColor) ||
(containerChanged && !labelChanged)
) {
inkById.set(
label.id,
normalizeStickyNoteStrokeColor(container.strokeColor),
);
} else {
inkById.set(
container.id,
normalizeStickyNoteStrokeColor(label.strokeColor),
);
}
}
if (!inkById.size) {
return elements;
}
return elements.map((element) => {
const strokeColor = inkById.get(element.id);
return strokeColor === undefined
? element
: (newElementWith(element as ExcalidrawElement, { strokeColor }) as T);
});
};
const jitter = (random: () => number, amount: number) =>
(random() * 2 - 1) * amount;
const pointAtDistance = (
from: StickyNoteRenderPoint,
to: StickyNoteRenderPoint,
distance: number,
) => {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy);
if (!length) {
return from;
}
const ratio = Math.min(distance / length, 1);
return {
x: from.x + dx * ratio,
y: from.y + dy * ratio,
};
};
export const getStickyNoteCornerRadius = (
element: ExcalidrawStickyNoteElement,
) => {
if (!element.roundness) {
return 0;
}
return Math.min(
Math.min(element.width, element.height) * STICKY_NOTE_CORNER_RADIUS_RATIO,
STICKY_NOTE_MAX_CORNER_RADIUS,
);
};
export const getStickyNoteRenderPoints = (
element: ExcalidrawStickyNoteElement,
{
offsetX = 0,
offsetY = 0,
seedOffset = 0,
}: {
offsetX?: number;
offsetY?: number;
seedOffset?: number;
} = {},
): StickyNoteRenderPoint[] => {
const roughness = Math.max(0, Math.min(2, Math.round(element.roughness)));
const amount = Math.min(
STICKY_NOTE_RENDER_ROUGHNESS[roughness],
Math.min(element.width, element.height) * 0.012,
);
if (!amount) {
return [
{ x: offsetX, y: offsetY },
{ x: offsetX + element.width, y: offsetY },
{ x: offsetX + element.width, y: offsetY + element.height },
{ x: offsetX, y: offsetY + element.height },
];
}
const random = seededRandom(element.seed + seedOffset);
return [
{
x: offsetX + jitter(random, amount),
y: offsetY + jitter(random, amount),
},
{
x: offsetX + element.width + jitter(random, amount),
y: offsetY + jitter(random, amount),
},
{
x: offsetX + element.width + jitter(random, amount),
y: offsetY + element.height + jitter(random, amount),
},
{
x: offsetX + jitter(random, amount),
y: offsetY + element.height + jitter(random, amount),
},
];
};
export const getStickyNotePathCommands = (
element: ExcalidrawStickyNoteElement,
{ shadow = false }: { shadow?: boolean } = {},
): StickyNotePathCommand[] => {
const points = getStickyNoteRenderPoints(
element,
shadow
? {
offsetX: STICKY_NOTE_SHADOW_OFFSET,
offsetY: STICKY_NOTE_SHADOW_OFFSET,
seedOffset: 1,
}
: undefined,
);
const radius = getStickyNoteCornerRadius(element);
// The note's seed keeps the same corner for the paper and shadow through
// redraws and resizes.
const liftedCorner =
element.roughness === 2
? Math.floor(seededRandom(element.seed)() * points.length)
: -1;
if (!radius && liftedCorner === -1) {
return [
{ type: "move", point: points[0] },
...points.slice(1).map(
(point): StickyNotePathCommand => ({
type: "line",
point,
}),
),
];
}
const corners = points.map((point, index): StickyNotePathCommand[] => {
const prev = points[(index + points.length - 1) % points.length];
const next = points[(index + 1) % points.length];
const cornerRadius = Math.min(
radius,
Math.hypot(point.x - prev.x, point.y - prev.y) / 2,
Math.hypot(point.x - next.x, point.y - next.y) / 2,
);
if (index === liftedCorner) {
const size = Math.min(element.width, element.height);
const reach = Math.min(size * 0.18, 40);
// The shadow follows the paper inward at half the bend, keeping its
// usual down-right offset from the light at the top left.
const lift = Math.min(size * 0.02, 5) * (shadow ? 0.5 : 1);
// The bend joins the straight edges before the corner, keeping it local.
const tip = {
x: point.x + (index === 1 || index === 2 ? -lift : lift),
y: point.y + (index >= 2 ? -lift : lift),
};
const start = pointAtDistance(point, prev, reach);
const end = pointAtDistance(point, next, reach);
return [
{ type: "line", point: start },
{
type: "quadratic",
control: pointAtDistance(point, prev, reach / 2),
point: pointAtDistance(tip, start, cornerRadius),
},
{
type: "quadratic",
control: tip,
point: pointAtDistance(tip, end, cornerRadius),
},
{
type: "quadratic",
control: pointAtDistance(point, next, reach / 2),
point: end,
},
];
}
return [
{ type: "line", point: pointAtDistance(point, prev, cornerRadius) },
{
type: "quadratic",
control: point,
point: pointAtDistance(point, next, cornerRadius),
},
];
});
const commands: StickyNotePathCommand[] = [
{ type: "move", point: corners[0][corners[0].length - 1].point },
];
for (let index = 1; index <= corners.length; index++) {
commands.push(...corners[index % corners.length]);
}
return commands;
};
// -----------------------------------------------------------------------------
// font size — `baseFontSize` is the size the user picked, `fontSize` the fitted one
// -----------------------------------------------------------------------------
// clamping the ceiling: the fit steps down from it in `STICKY_NOTE_FONT_STEP`
// increments, and for values >= ~2^56 (1e20 - 2 === 1e20) that would never
// progress and hang the editor
export const normalizeStickyNoteFontSize = (fontSize: number) => {
if (!Number.isFinite(fontSize)) {
return STICKY_NOTE_FALLBACK_FONT_SIZE;
}
return Math.min(STICKY_NOTE_MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, fontSize));
};
/** whether the text element is the label of a sticky note */
export const isStickyNoteBoundText = (
textElement: ExcalidrawTextElement,
elementsMap: ElementsMap,
) => {
return (
!!textElement.containerId &&
isStickyNoteElement(elementsMap.get(textElement.containerId))
);
};
/**
* The font size the user picked: the ceiling the auto-fit shrinks from for a
* sticky note label, plain `fontSize` for any other text.
*
* Container-aware on purpose — generic binding repair (duplication, history)
* rewrites `containerId` without touching the ceiling, so a numeric
* `baseFontSize` alone does not prove the text is still sticky-bound.
*/
export const getBaseFontSize = (
textElement: ExcalidrawTextElement,
elementsMap: ElementsMap,
) => {
return isStickyNoteBoundText(textElement, elementsMap)
? textElement.baseFontSize ?? textElement.fontSize
: textElement.fontSize;
};
/** the update that applies a user-picked font size (see `getBaseFontSize`) */
export const getBaseFontSizeUpdate = (
textElement: ExcalidrawTextElement,
fontSize: number,
elementsMap: ElementsMap,
): { fontSize: number } | { baseFontSize: number } => {
return isStickyNoteBoundText(textElement, elementsMap)
? { baseFontSize: normalizeStickyNoteFontSize(fontSize) }
: { fontSize };
};
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/**
* The creation-date label: absolute, so painting needs no clock and exports
* don't go stale, and short ("7 Sep") while the year is the current one.
* `null` when the note has no usable timestamp (files restored without one).
* Fixed English in the viewer's local time zone — the element package has no
* locale, and the footer is chosen by width bucket rather than measured.
*/
export const getStickyNoteDateLabel = (
created: ExcalidrawStickyNoteElement["created"],
{ short = false, now = Date.now() }: { short?: boolean; now?: number } = {},
): string | null => {
if (created === null || !Number.isFinite(created)) {
return null;
}
const date = new Date(created);
if (Number.isNaN(date.getTime())) {
return null;
}
const label = `${date.getDate()} ${MONTHS[date.getMonth()]}`;
const year = date.getFullYear();
return short || year === new Date(now).getFullYear()
? label
: `${label} ${year}`;
};
/**
* What the footer paints and where, in note-local coordinates — shared by the
* canvas and SVG renderers, which take the font and opacity from
* `STICKY_NOTE_FOOTER`. `null` for the 0×0 creation draft and anything under
* the data floor, where the band would overlap the top padding.
*/
export const getStickyNoteFooter = (
element: Pick<ExcalidrawStickyNoteElement, "created" | "width" | "height">,
now = Date.now(),
) => {
if (
element.width < STICKY_NOTE_MIN_SIZE ||
element.height < STICKY_NOTE_MIN_SIZE
) {
return null;
}
const text = getStickyNoteDateLabel(element.created, {
short:
element.width - STICKY_NOTE_PADDING * 2 <
STICKY_NOTE_FOOTER.minBodyWidthForYear,
now,
});
if (!text) {
return null;
}
return {
text,
x: element.width - STICKY_NOTE_PADDING,
y: element.height - STICKY_NOTE_FOOTER.baselineFromBottom,
};
};
/**
* The smallest note the UI lets a user create or resize to: one line at the
* label's font ceiling plus padding (and, vertically, the footer), never below
* `STICKY_NOTE_MIN_SIZE`. Without the font term a fresh note would grow on
* the very first keystroke. Data-level passes (restore, action post-passes)
* only enforce the constant floor — the layout grows a note as needed.
*/
export const getStickyNoteMinSize = ({
fontSize,
fontFamily,
}: Pick<ExcalidrawTextElement, "fontSize" | "fontFamily">) => {
const lineHeightPx = Math.ceil(
normalizeStickyNoteFontSize(fontSize) * getLineHeight(fontFamily),
);
return {
width: Math.max(
STICKY_NOTE_MIN_SIZE,
lineHeightPx + STICKY_NOTE_PADDING * 2,
),
height: Math.max(
STICKY_NOTE_MIN_SIZE,
lineHeightPx + STICKY_NOTE_BODY_INSET_Y,
),
};
};
// -----------------------------------------------------------------------------
// layout — one pure calculation, two ways of applying it
// -----------------------------------------------------------------------------
export type StickyNoteLayoutAnchor = VerticalResizeAnchor;
export type StickyNoteLayoutOpts = {
/** unwrapped text to lay out; defaults to the label's `originalText` */
originalText?: string;
/**
* absolute base-height intent — the gesture-start value to preserve, or the
* height a resize asked for. Omitted = keep the note's live `baseHeight`
* (typing, style changes, restore).
*/
baseHeight?: number;
/**
* absolute font-ceiling intent (a proportional resize passes
* `gesture-start ceiling × scale`). Omitted = keep the label's live ceiling.
* Never a multiplier on the live value — that compounds across pointer-moves.
*/
baseFontSize?: number;
/** the edge that stays put when the content correction changes the height */
anchor?: StickyNoteLayoutAnchor;
};
export type StickyNoteLayout = {
container: Pick<
ExcalidrawStickyNoteElement,
"x" | "y" | "width" | "height" | "baseHeight"
>;
text: Pick<
ExcalidrawTextElement,
| "text"
| "fontSize"
| "baseFontSize"
| "width"
| "height"
| "x"
| "y"
| "angle"
> | null;
};
type FontFit = {
text: string;
fontSize: number;
width: number;
height: number;
};
// `computeBoundTextPosition` only consults the map for arrow containers
const NO_ELEMENTS: ElementsMap = new Map();
const getStickyNoteBaseWidth = (container: ExcalidrawStickyNoteElement) => {
return Math.max(container.width, STICKY_NOTE_MIN_SIZE);
};
/**
* Largest font size on the grid `{ceiling − k·STEP} ∪ {min}` whose wrapped
* text fits the note; `min` when nothing fits (the note then grows). The grid
* is anchored at the ceiling so the result never depends on earlier edits.
* Warm-started from the previously fitted size: the common keystroke costs one
* or two wrap+measure passes, a cold search at most log2(steps) + 2.
*/
const fitStickyNoteFont = (
fit: (fontSize: number) => FontFit,
{
baseFontSize,
fontSizeMin,
maxWidth,
maxHeight,
warmStart,
}: {
baseFontSize: number;
fontSizeMin: number;
maxWidth: number;
maxHeight: number;
warmStart: number;
},
): FontFit => {
const steps = Math.max(
0,
Math.ceil((baseFontSize - fontSizeMin) / STICKY_NOTE_FONT_STEP),
);
const sizeAt = (index: number) =>
index >= steps ? fontSizeMin : baseFontSize - index * STICKY_NOTE_FONT_STEP;
const fits = new Map<number, FontFit>();
const at = (index: number) => {
let result = fits.get(index);
if (!result) {
result = fit(sizeAt(index));
fits.set(index, result);
}
return result;
};
const doesFit = (index: number) => {
const result = at(index);
return result.width <= maxWidth && result.height <= maxHeight;
};
if (steps === 0) {
return at(0);
}
// the previous fitted size, snapped onto the grid and clamped into the
// current interval (a lowered ceiling must not keep the old larger size)
const warm = clamp(
Math.round((baseFontSize - warmStart) / STICKY_NOTE_FONT_STEP),
0,
steps,
);
// binary search for the smallest index (largest font) that fits
let lo: number;
let hi: number;
if (doesFit(warm)) {
if (warm === 0 || !doesFit(warm - 1)) {
return at(warm);
}
lo = 0;
hi = warm - 1;
} else {
if (warm === steps) {
return at(steps);
}
lo = warm + 1;
hi = steps;
}
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (doesFit(mid)) {
hi = mid;
} else {
lo = mid + 1;
}
}
return at(lo);
};
/**
* The single source of truth for a sticky note's geometry: wraps the label at
* the note's width, fits the font under the ceiling, grows the note past
* `baseHeight` only when the text still overflows at the minimum size, and
* positions the label inside. Pure — returns the updates for both elements.
*/
export const getStickyNoteLayout = (
container: ExcalidrawStickyNoteElement,
textElement: ExcalidrawTextElement | null,
opts: StickyNoteLayoutOpts = {},
): StickyNoteLayout => {
const baseWidth = getStickyNoteBaseWidth(container);
const baseHeight = Math.max(
opts.baseHeight ?? (container.baseHeight || container.height),
STICKY_NOTE_MIN_SIZE,
);
const anchor = opts.anchor ?? "top";
if (!textElement) {
// an empty note sits at its base height
return {
container: {
...getPositionAfterHeightChange(container, baseHeight, anchor),
width: baseWidth,
height: baseHeight,
baseHeight,
},
text: null,
};
}
const originalText = opts.originalText ?? textElement.originalText;
const baseFontSize = normalizeStickyNoteFontSize(
opts.baseFontSize ?? textElement.baseFontSize ?? textElement.fontSize,
);
const fontSizeMin = Math.min(STICKY_NOTE_MIN_FONT_SIZE, baseFontSize);
const maxWidth = Math.max(baseWidth - STICKY_NOTE_PADDING * 2, 1);
const maxHeight = Math.max(baseHeight - STICKY_NOTE_BODY_INSET_Y, 0);
const { fontFamily, lineHeight } = textElement;
const fit = (fontSize: number): FontFit => {
const font = getFontString({ fontFamily, fontSize });
const text = wrapText(originalText, font, maxWidth);
return { text, fontSize, ...measureText(text, font, lineHeight) };
};
const isBlank = !originalText.trim();
const fitted: FontFit = isBlank
? {
text: "",
fontSize: baseFontSize,
...measureText(
"",
getFontString({ fontFamily, fontSize: baseFontSize }),
lineHeight,
),
}
: fitStickyNoteFont(fit, {
baseFontSize,
fontSizeMin,
maxWidth,
maxHeight,
warmStart: textElement.fontSize,
});
const height = isBlank
? baseHeight
: Math.max(baseHeight, fitted.height + STICKY_NOTE_BODY_INSET_Y);
const nextContainer = {
...getPositionAfterHeightChange(container, height, anchor),
width: baseWidth,
height,
baseHeight,
};
const { x, y } = computeBoundTextPosition(
{ ...container, ...nextContainer },
{
...textElement,
text: fitted.text,
fontSize: fitted.fontSize,
width: fitted.width,
height: fitted.height,
} as ExcalidrawTextElementWithContainer,
NO_ELEMENTS,
);
return {
container: nextContainer,
text: {
text: fitted.text,
fontSize: fitted.fontSize,
baseFontSize,
width: fitted.width,
height: fitted.height,
x,
y,
angle: container.angle,
},
};
};
/**
* Resize intents for a sticky note — the §3.3 matrix of the plan. Every value
* is absolute and derived from the gesture-start snapshot on each pointer-move,
* so releasing Shift (or Alt) mid-gesture restores the original base height
* and ceiling instead of keeping values a previous move scaled.
*
* - width-only gestures (free E/W, Stats W) preserve the base height
* - height-changing gestures use the requested height as the new base
* - proportional gestures (Shift on any handle, aspect-locked multi-select,
* Stats group) additionally scale the font ceiling with the note
* - flips preserve everything
* - the content correction anchors at the edge the gesture holds still
*/
export const getStickyNoteResizeIntent = (
/** the note with the requested geometry already applied */
container: ExcalidrawStickyNoteElement,
originalElementsMap: ElementsMap,
handleDirection: TransformHandleDirection,
{
proportional,
fromCenter,
flip = false,
}: { proportional: boolean; fromCenter: boolean; flip?: boolean },
): Pick<StickyNoteLayoutOpts, "baseHeight" | "baseFontSize" | "anchor"> => {
const origContainer = originalElementsMap.get(container.id);
const origSticky = isStickyNoteElement(origContainer)
? origContainer
: container;
const origText = getBoundTextElement(origSticky, originalElementsMap);
const ceiling = origText
? origText.baseFontSize ?? origText.fontSize
: undefined;
if (flip) {
return { baseHeight: origSticky.baseHeight, baseFontSize: ceiling };
}
const changesHeight =
proportional ||
handleDirection.includes("n") ||
handleDirection.includes("s");
const scale =
proportional && origSticky.width ? container.width / origSticky.width : 1;
const isSideHandle = handleDirection.length === 1;
return {
baseHeight: changesHeight ? container.height : origSticky.baseHeight,
baseFontSize: ceiling === undefined ? undefined : ceiling * scale,
anchor: fromCenter
? "center"
: handleDirection.includes("n")
? "bottom"
: proportional &&
isSideHandle &&
(handleDirection === "e" || handleDirection === "w")
? // Shift+E/W holds the opposite side's midpoint, i.e. the vertical center
"center"
: "top",
};
};
/**
* Applies `getStickyNoteLayout` to a live scene, then runs the bound-arrow
* pass unless the caller already owns one (`bindings: false`) or needs to
* forward its context (`bindings: { simultaneouslyUpdated }` — arrows resized
* in the same gesture must not be moved by the pass, `updateBoundElements`
* skips them only when told).
*/
export const updateStickyNoteLayout = (
container: ExcalidrawStickyNoteElement,
scene: Scene,
{
text,
bindings,
...layoutOpts
}: StickyNoteLayoutOpts & {
/** the label to lay out when it is an uncommitted clone (font actions clone before install) */
text?: ExcalidrawTextElement | null;
bindings?:
| { simultaneouslyUpdated?: readonly NonDeletedExcalidrawElement[] }
| false;
} = {},
): StickyNoteLayout => {
const elementsMap = scene.getNonDeletedElementsMap();
const textElement =
text === undefined ? getBoundTextElement(container, elementsMap) : text;
const layout = getStickyNoteLayout(container, textElement, layoutOpts);
scene.mutateElement(container, layout.container);
if (textElement && layout.text) {
scene.mutateElement(textElement, layout.text);
}
if (bindings !== false && !container.isDeleted) {
updateBoundElements(
container as NonDeletedExcalidrawElement,
scene,
bindings || undefined,
);
}
return layout;
};
const STICKY_NOTE_LAYOUT_INPUTS = {
container: ["x", "y", "width", "baseHeight", "angle"],
text: [
"originalText",
"baseFontSize",
"fontFamily",
"lineHeight",
"textAlign",
"verticalAlign",
],
} as const;
const hasStickyNoteLayoutInputChanged = (
container: ExcalidrawStickyNoteElement,
textElement: ExcalidrawTextElement | null,
prevElementsMap: ElementsMap,
) => {
const prevContainer = prevElementsMap.get(container.id);
const prevText = textElement ? prevElementsMap.get(textElement.id) : null;
if (
!prevContainer ||
(textElement && !prevText) ||
(!textElement &&
prevContainer.boundElements?.some(({ type }) => type === "text"))
) {
return true;
}
return (
STICKY_NOTE_LAYOUT_INPUTS.container.some(
(key) => (prevContainer as any)[key] !== container[key],
) ||
(!!textElement &&
STICKY_NOTE_LAYOUT_INPUTS.text.some(
(key) => (prevText as any)[key] !== textElement[key],
))
);
};
/**
* Applies `getStickyNoteLayout` inside an immutable elements array (property
* actions, paste styles, restore): the notes among `affectedIds` — or the
* notes whose labels are — get both halves merged into their clones. Elements
* that need no change keep their identity and version; when `prevElementsMap`
* is given, notes whose layout inputs did not change skip the fit entirely.
* Bound arrows are the caller's job once the result is installed.
*/
export const relayoutStickyNotes = <T extends ExcalidrawElement>(
elements: readonly T[],
affectedIds: ReadonlySet<ExcalidrawElement["id"]>,
opts?: { prevElementsMap?: ElementsMap },
): readonly T[] => {
if (!affectedIds.size) {
return elements;
}
const elementsMap = arrayToMap(elements);
const containerIds = new Set<ExcalidrawElement["id"]>();
for (const id of affectedIds) {
const element = elementsMap.get(id);
if (!element || element.isDeleted) {
continue;
}
if (isStickyNoteElement(element)) {
containerIds.add(element.id);
} else if (isTextElement(element) && element.containerId) {
const container = elementsMap.get(element.containerId);
if (container && !container.isDeleted && isStickyNoteElement(container)) {
containerIds.add(container.id);
}
}
}
if (!containerIds.size) {
return elements;
}
const replacements = new Map<ExcalidrawElement["id"], T>();
for (const id of containerIds) {
const container = elementsMap.get(id) as ExcalidrawStickyNoteElement;
const textElement = getBoundTextElement(container, elementsMap);
if (
opts?.prevElementsMap &&
!hasStickyNoteLayoutInputChanged(
container,
textElement,
opts.prevElementsMap,
)
) {
continue;
}
const layout = getStickyNoteLayout(container, textElement);
const nextContainer = newElementWith(container, layout.container);
if (nextContainer !== container) {
replacements.set(id, nextContainer as unknown as T);
}
if (textElement && layout.text) {
const nextText = newElementWith(textElement, layout.text);
if (nextText !== textElement) {
replacements.set(textElement.id, nextText as unknown as T);
}
}
}
if (!replacements.size) {
return elements;
}
return elements.map((element) => replacements.get(element.id) ?? element);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,588 @@
import {
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
ARROW_LABEL_WIDTH_FRACTION,
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
STICKY_NOTE_BODY_INSET_Y,
STICKY_NOTE_PADDING,
TEXT_ALIGN,
VERTICAL_ALIGN,
getFontString,
isProdEnv,
invariant,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { LinearElementEditor } from "./linearElementEditor";
import { getPositionAfterHeightChange } from "./sizeHelpers";
import { updateStickyNoteLayout } from "./stickyNote";
import { measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping";
import {
isBoundToContainer,
isArrowElement,
isStickyNoteElement,
isTextElement,
} from "./typeChecks";
import { isNonDeletedElement } from ".";
import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
import type {
ElementsMap,
ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawTextContainer,
ExcalidrawTextElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
} from "./types";
export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
scene: Scene,
) => {
const elementsMap = scene.getNonDeletedElementsMap();
if (container && isStickyNoteElement(container)) {
// the sticky fit owns both halves (label + note geometry). `textElement`
// may be an uncommitted clone (font actions clone before install), so it
// is passed explicitly instead of looked up in the scene
updateStickyNoteLayout(container, scene, { text: textElement });
return;
}
let maxWidth = undefined;
if (!isProdEnv()) {
invariant(
!container || !isArrowElement(container) || textElement.angle === 0,
"text element angle must be 0 if bound to arrow container",
);
}
const boundTextUpdates = {
x: textElement.x,
y: textElement.y,
text: textElement.text,
width: textElement.width,
height: textElement.height,
angle: (container
? isArrowElement(container)
? 0
: container.angle
: textElement.angle) as Radians,
};
boundTextUpdates.text = textElement.text;
if (container || !textElement.autoResize) {
maxWidth = container
? getBoundTextMaxWidth(container, textElement)
: textElement.width;
boundTextUpdates.text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
}
const metrics = measureText(
boundTextUpdates.text,
getFontString(textElement),
textElement.lineHeight,
);
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
if (textElement.autoResize) {
boundTextUpdates.width = metrics.width;
}
boundTextUpdates.height = metrics.height;
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
container,
textElement as ExcalidrawTextElementWithContainer,
);
const maxContainerWidth = getBoundTextMaxWidth(container, textElement);
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
const nextHeight = computeContainerDimensionForBoundText(
metrics.height,
container.type,
);
scene.mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
const nextWidth = computeContainerDimensionForBoundText(
metrics.width,
container.type,
);
scene.mutateElement(container, { width: nextWidth });
}
const updatedTextElement = {
...textElement,
...boundTextUpdates,
} as ExcalidrawTextElementWithContainer;
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement,
elementsMap,
);
boundTextUpdates.x = x;
boundTextUpdates.y = y;
}
scene.mutateElement(textElement, boundTextUpdates);
};
export const handleBindTextResize = (
container: ExcalidrawElement,
scene: Scene,
transformHandleType: MaybeTransformHandleType,
shouldMaintainAspectRatio = false,
shouldResizeFromCenter = false,
flipByY = false,
) => {
if (isStickyNoteElement(container)) {
// resize callers pass their intents to `updateStickyNoteLayout` directly
// and own the bound-arrow pass; this is the fallback for generic callers
updateStickyNoteLayout(container, scene, { bindings: false });
return;
}
const elementsMap = scene.getNonDeletedElementsMap();
const boundTextElementId = getBoundTextElementId(container);
if (!boundTextElementId) {
return;
}
resetOriginalContainerCache(container.id);
const textElement = getBoundTextElement(container, elementsMap);
if (textElement && textElement.text) {
if (!container) {
return;
}
let text = textElement.text;
let nextHeight = textElement.height;
let nextWidth = textElement.width;
const maxWidth = getBoundTextMaxWidth(container, textElement);
const maxHeight = getBoundTextMaxHeight(container, textElement);
let containerHeight = container.height;
if (
shouldMaintainAspectRatio ||
(transformHandleType !== "n" && transformHandleType !== "s")
) {
if (text) {
text = wrapText(
textElement.originalText,
getFontString(textElement),
maxWidth,
);
}
const metrics = measureText(
text,
getFontString(textElement),
textElement.lineHeight,
);
nextHeight = metrics.height;
nextWidth = metrics.width;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
containerHeight = computeContainerDimensionForBoundText(
nextHeight,
container.type,
);
// Crossing the opposite edge swaps the anchor for text-driven growth.
const shouldResizeFromTop =
(transformHandleType === "n" ||
transformHandleType === "ne" ||
transformHandleType === "nw") !== flipByY;
scene.mutateElement(container, {
height: containerHeight,
...(!isArrowElement(container) &&
getPositionAfterHeightChange(
container,
containerHeight,
shouldResizeFromCenter
? "center"
: shouldResizeFromTop
? "bottom"
: "top",
)),
});
}
scene.mutateElement(textElement, {
text,
width: nextWidth,
height: nextHeight,
});
if (!isArrowElement(container)) {
scene.mutateElement(
textElement,
computeBoundTextPosition(container, textElement, elementsMap),
);
}
}
};
export const computeBoundTextPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
elementsMap,
);
}
const containerCoords = getContainerCoords(container);
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement);
let x;
let y;
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
y = containerCoords.y;
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
} else if (isStickyNoteElement(container)) {
// a note's label body ends above the creation-date footer, but a label
// centered in that body sits visibly high — center it in the whole
// padded note while it stays clear of the footer, and only push it up
// against the body's bottom once it would overlap
const paddedHeight = container.height - STICKY_NOTE_PADDING * 2;
y =
containerCoords.y +
Math.min(
(paddedHeight - boundTextElement.height) / 2,
maxContainerHeight - boundTextElement.height,
);
} else {
y =
containerCoords.y +
(maxContainerHeight / 2 - boundTextElement.height / 2);
}
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
x = containerCoords.x;
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
} else {
x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
}
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
// A sticky's footer makes its body asymmetric. The body still rotates
// about the note's center, rather than about its own (higher) center.
const contentCenter = isStickyNoteElement(container)
? pointFrom(
container.x + container.width / 2,
container.y + container.height / 2,
)
: pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y };
};
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
return container?.boundElements?.length
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
: null;
};
export const getBoundTextElement = (
element: ExcalidrawElement | null,
elementsMap: ElementsMap,
): NonDeleted<ExcalidrawTextElementWithContainer> | null => {
if (!element) {
return null;
}
const boundTextElementId = getBoundTextElementId(element);
if (boundTextElementId) {
const boundTextElement = (elementsMap.get(boundTextElementId) ||
null) as NonDeleted<ExcalidrawTextElementWithContainer> | null;
// SAFETY: This should never happen, but log it just in case
if (boundTextElement && !isNonDeletedElement(boundTextElement)) {
console.error(
"[NONDELETED][INVARIANT] Bound text element `isDeleted: true` which is not expected.",
);
}
return boundTextElement;
}
return null;
};
export const getContainerElement = <
T extends ExcalidrawTextElement,
R extends ExcalidrawTextContainer,
>(
element: T | null,
elementsMap: ElementsMap,
): R | null => {
if (!element) {
return null;
}
if (element.containerId) {
return (elementsMap.get(element.containerId) || null) as R | null;
}
return null;
};
/**
* The point a text bound to this container centers on — and, for arrows, the
* point the text tool snaps a new label to.
*/
export const getContainerCenter = (
container: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (!isArrowElement(container)) {
return {
x: container.x + container.width / 2,
y: container.y + container.height / 2,
};
}
const center = LinearElementEditor.getBoundTextElementCenter(
container,
elementsMap,
);
return { x: center[0], y: center[1] };
};
export const getContainerCoords = (container: ExcalidrawElement) => {
const padding = isStickyNoteElement(container)
? STICKY_NOTE_PADDING
: BOUND_TEXT_PADDING;
let offsetX = padding;
let offsetY = padding;
if (container.type === "ellipse") {
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
}
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
if (container.type === "diamond") {
offsetX += container.width / 4;
offsetY += container.height / 4;
}
return {
x: container.x + offsetX,
y: container.y + offsetY,
};
};
export const getTextElementAngle = (
textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
) => {
if (isArrowElement(container)) {
return 0;
}
if (!container) {
return textElement.angle;
}
return container.angle;
};
export const getBoundTextElementPosition = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
elementsMap: ElementsMap,
) => {
if (isArrowElement(container)) {
return LinearElementEditor.getBoundTextElementPosition(
container,
boundTextElement,
elementsMap,
);
}
};
export const shouldAllowVerticalAlign = (
selectedElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap,
) => {
return selectedElements.some((element) => {
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
return false;
}
return true;
}
return false;
});
};
export const suppportsHorizontalAlign = (
selectedElements: readonly ExcalidrawElement[],
elementsMap: ElementsMap,
) => {
return selectedElements.some((element) => {
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
return false;
}
return true;
}
return isTextElement(element);
});
};
const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"stickynote",
"ellipse",
"diamond",
"arrow",
]);
export const isValidTextContainer = (element: {
type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
) => {
dimension = Math.ceil(dimension);
const padding = BOUND_TEXT_PADDING * 2;
if (containerType === "ellipse") {
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
}
if (containerType === "arrow") {
return dimension + padding * 8;
}
if (containerType === "diamond") {
return 2 * (dimension + padding);
}
return dimension + padding;
};
export const getBoundTextMaxWidth = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElement | null,
) => {
const { width } = container;
if (isArrowElement(container)) {
const minWidth =
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
}
if (container.type === "ellipse") {
// The width of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The width of the largest rectangle inscribed inside a rhombus is
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
}
return (
width -
(isStickyNoteElement(container)
? STICKY_NOTE_PADDING
: BOUND_TEXT_PADDING) *
2
);
};
export const getBoundTextMaxHeight = (
container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer,
) => {
const { height } = container;
if (isStickyNoteElement(container)) {
// the label body ends above the creation-date footer
return Math.max(0, height - STICKY_NOTE_BODY_INSET_Y);
}
if (isArrowElement(container)) {
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
if (containerHeight <= 0) {
return boundTextElement.height;
}
return height;
}
if (container.type === "ellipse") {
// The height of the largest rectangle inscribed inside an ellipse is
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
}
if (container.type === "diamond") {
// The height of the largest rectangle inscribed inside a rhombus is
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
}
return height - BOUND_TEXT_PADDING * 2;
};
/** retrieves text from text elements and concatenates to a single string */
export const getTextFromElements = (
elements: readonly ExcalidrawElement[],
separator = "\n\n",
) => {
const text = elements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join(separator);
return text;
};
export const DEFAULT_BOUND_TEXT_LABEL_POSITION = 0.5;

View File

@@ -0,0 +1,227 @@
import {
BOUND_TEXT_PADDING,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
getFontString,
isTestEnv,
normalizeEOL,
} from "@excalidraw/common";
import type { FontString, ExcalidrawTextElement } from "./types";
export const measureText = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const _text = text
.split("\n")
// replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation
.map((x) => x || " ")
.join("\n");
const fontSize = parseFloat(font);
const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(_text, font);
return { width, height };
};
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
// FIXME rename to getApproxMinContainerWidth
export const getApproxMinLineWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const maxCharWidth = getMaxCharWidth(font);
if (maxCharWidth === 0) {
return (
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
BOUND_TEXT_PADDING * 2
);
}
return maxCharWidth + BOUND_TEXT_PADDING * 2;
};
export const getMinTextElementWidth = (
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
};
export const isMeasureTextSupported = () => {
const width = getTextWidth(
DUMMY_TEXT,
getFontString({
fontSize: DEFAULT_FONT_SIZE,
fontFamily: DEFAULT_FONT_FAMILY,
}),
);
return width > 0;
};
export const normalizeText = (text: string) => {
return (
normalizeEOL(text)
// replace tabs with spaces so they render and measure correctly
.replace(/\t/g, " ")
);
};
const splitIntoLines = (text: string) => {
return normalizeText(text).split("\n");
};
/**
* To get unitless line-height (if unknown) we can calculate it by dividing
* height-per-line by fontSize.
*/
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
const lineCount = splitIntoLines(textElement.text).length;
return (textElement.height /
lineCount /
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
};
/**
* We calculate the line height from the font size and the unitless line height,
* aligning with the W3C spec.
*/
export const getLineHeightInPx = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return fontSize * lineHeight;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
};
let textMetricsProvider: TextMetricsProvider | undefined;
/**
* Set a custom text metrics provider.
*
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
*/
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
textMetricsProvider = provider;
};
export interface TextMetricsProvider {
getLineWidth(text: string, fontString: FontString): number;
}
class CanvasTextMetricsProvider implements TextMetricsProvider {
private canvas: HTMLCanvasElement;
constructor() {
this.canvas = document.createElement("canvas");
}
/**
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
* - text wrapping
* - wysiwyg editor (+padding)
*
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
*/
public getLineWidth(text: string, fontString: FontString): number {
const context = this.canvas.getContext("2d")!;
context.font = fontString;
const metrics = context.measureText(text);
const advanceWidth = metrics.width;
// since in test env the canvas measureText algo
// doesn't measure text and instead just returns number of
// characters hence we assume that each letteris 10px
if (isTestEnv()) {
return advanceWidth * 10;
}
return advanceWidth;
}
}
export const getLineWidth = (text: string, font: FontString) => {
if (!textMetricsProvider) {
textMetricsProvider = new CanvasTextMetricsProvider();
}
return textMetricsProvider.getLineWidth(text, font);
};
export const getTextWidth = (text: string, font: FontString) => {
const lines = splitIntoLines(text);
let width = 0;
lines.forEach((line) => {
width = Math.max(width, getLineWidth(line, font));
});
return width;
};
export const getTextHeight = (
text: string,
fontSize: number,
lineHeight: ExcalidrawTextElement["lineHeight"],
) => {
const lineCount = splitIntoLines(text).length;
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
};
export const charWidth = (() => {
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
const calculate = (char: string, font: FontString) => {
const unicode = char.charCodeAt(0);
if (!cachedCharWidth[font]) {
cachedCharWidth[font] = [];
}
if (!cachedCharWidth[font][unicode]) {
const width = getLineWidth(char, font);
cachedCharWidth[font][unicode] = width;
}
return cachedCharWidth[font][unicode];
};
const getCache = (font: FontString) => {
return cachedCharWidth[font];
};
const clearCache = (font: FontString) => {
cachedCharWidth[font] = [];
};
return {
calculate,
getCache,
clearCache,
};
})();
export const getMinCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.min(...cacheWithOutEmpty);
};
export const getMaxCharWidth = (font: FontString) => {
const cache = charWidth.getCache(font);
if (!cache) {
return 0;
}
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
return Math.max(...cacheWithOutEmpty);
};

View File

@@ -0,0 +1,740 @@
import { isDevEnv, isTestEnv } from "@excalidraw/common";
import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
/**
* Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
*/
export const containsCJK = (text: string) => {
if (!cachedCjkRegex) {
cachedCjkRegex = Regex.class(...Object.values(CJK));
}
return cachedCjkRegex.test(text);
};
const getLineBreakRegex = () => {
if (!cachedLineBreakRegex) {
try {
cachedLineBreakRegex = getLineBreakRegexAdvanced();
} catch {
cachedLineBreakRegex = getLineBreakRegexSimple();
}
}
return cachedLineBreakRegex;
};
const getEmojiRegex = () => {
if (!cachedEmojiRegex) {
cachedEmojiRegex = getEmojiRegexUnicode();
}
return cachedEmojiRegex;
};
/**
* Common symbols used across different languages.
*/
const COMMON = {
/**
* Natural breaking points for any grammars.
*
* Hello world
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
* Hello-world
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
*/
WHITESPACE: /\s/u,
HYPHEN: /-/u,
/**
* Generally do not break, unless closed symbol is followed by an opening symbol.
*
* Also, western punctation is often used in modern Korean and expects to be treated
* similarly to the CJK opening and closing symbols.
*
* Hello(한글)→ ["Hello", "(한", "글)"]
* ↑ BREAK BEFORE "("
* ↑ BREAK AFTER ")"
*/
OPENING: /<\(\[\{/u,
CLOSING: />\)\]\}.,:;!\?…\//u,
};
/**
* Characters and symbols used in Chinese, Japanese and Korean.
*/
const CJK = {
/**
* Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
*
* Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
*/
CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u,
/**
* Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
* and creates pairs with neighboring characters.
*
* Hello た。→ ["Hello", "た。"]
* ↑ DON'T BREAK "た。"
* * Hello「た」 World → ["Hello", "「た」", "World"]
* ↑ DON'T BREAK "「た"
* ↑ DON'T BREAK "た"
* ↑ BREAK BEFORE "「"
* ↑ BREAK AFTER "」"
*/
// eslint-disable-next-line prettier/prettier
OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u,
CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u,
/**
* Currency symbols break before, not after
*
* Price¥100 → ["Price", "¥100"]
* ↑ BREAK BEFORE "¥"
*/
CURRENCY: /¥₩£¢$/u,
};
const EMOJI = {
FLAG: /\p{RI}\p{RI}/u,
JOINER:
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
ZWJ: /\u200D/u,
ANY: /[\p{Emoji}]/u,
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
};
/**
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
*
* Browser support as of 10/2024:
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
*
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
*/
const getLineBreakRegexSimple = () =>
Regex.or(
getEmojiRegex(),
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
);
/**
* Specifies the line breaking rules based for alphabetic-based languages,
* Chinese, Japanese, Korean and Emojis.
*
* "Hello-world" → ["Hello-", "world"]
* "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
*/
const getLineBreakRegexAdvanced = () =>
Regex.or(
// Unicode-defined regex for (multi-codepoint) Emojis
getEmojiRegex(),
// Rules for whitespace and hyphen
Break.Before(COMMON.WHITESPACE).Build(),
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
// Rules for CJK (chars, symbols, currency)
Break.Before(CJK.CHAR, CJK.CURRENCY)
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
.Build(),
Break.After(CJK.CHAR)
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
.Build(),
// Rules for opening and closing punctuation
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
);
/**
* Matches various emoji types.
*
* 1. basic emojis (😀, 🌍)
* 2. flags (🇨🇿)
* 3. multi-codepoint emojis:
* - skin tones (👍🏽)
* - variation selectors (☂️)
* - keycaps (1️⃣)
* - tag sequences (🏴󠁧󠁢󠁥󠁮󠁧󠁿)
* - emoji sequences (👨‍👩‍👧‍👦, 👩‍🚀, 🏳️‍🌈)
*
* Unicode points:
* - \uFE0F: presentation selector
* - \u20E3: enclosing keycap
* - \u200D: zero width joiner
* - \u{E0020}-\u{E007E}: tags
* - \u{E007F}: cancel tag
*
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
*/
const getEmojiRegexUnicode = () =>
Regex.group(
Regex.or(
EMOJI.FLAG,
Regex.and(
EMOJI.MOST,
EMOJI.JOINER,
Regex.build(
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
),
),
),
);
/**
* Regex utilities for unicode character classes.
*/
const Regex = {
/**
* Builds a regex from a string.
*/
build: (regex: string): RegExp => new RegExp(regex, "u"),
/**
* Joins regexes into a single string.
*/
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
/**
* Joins regexes into a single regex as with "and" operator.
*/
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
/**
* Joins regexes into a single regex with "or" operator.
*/
or: (...regexes: RegExp[]): RegExp =>
Regex.build(regexes.map((x) => x.source).join("|")),
/**
* Puts regexes into a matching group.
*/
group: (...regexes: RegExp[]): RegExp =>
Regex.build(`(${Regex.join(...regexes)})`),
/**
* Puts regexes into a character class.
*/
class: (...regexes: RegExp[]): RegExp =>
Regex.build(`[${Regex.join(...regexes)}]`),
};
/**
* Human-readable lookahead and lookbehind utilities for defining line break
* opportunities between pairs of character classes.
*/
const Break = {
/**
* Break on the given class of characters.
*/
On: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
return Regex.build(`([${joined}])`);
},
/**
* Break before the given class of characters.
*/
Before: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after the given class of characters.
*/
After: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Break before one or multiple characters of the same class.
*/
BeforeMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Break after one or multiple character from the same class.
*/
AfterMany: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Do not break before the given class of characters.
*/
NotBefore: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
/**
* Do not break after the given class of characters.
*/
NotAfter: (...regexes: RegExp[]) => {
const joined = Regex.join(...regexes);
const builder = () => Regex.build(`(?<![${joined}])`);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
Chain: (rootBuilder: () => RegExp) => ({
/**
* Build the root regex.
*/
Build: rootBuilder,
/**
* Specify additional class of characters that should precede the root regex.
*/
PreceededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const preceeded = Break.After(...regexes).Build();
const builder = () => Regex.and(preceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"PreceededBy"
>;
},
/**
* Specify additional class of characters that should follow the root regex.
*/
FollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const followed = Break.Before(...regexes).Build();
const builder = () => Regex.and(root, followed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"FollowedBy"
>;
},
/**
* Specify additional class of characters that should not precede the root regex.
*/
NotPrecededBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notPreceeded = Break.NotAfter(...regexes).Build();
const builder = () => Regex.and(notPreceeded, root);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotPrecededBy"
>;
},
/**
* Specify additional class of characters that should not follow the root regex.
*/
NotFollowedBy: (...regexes: RegExp[]) => {
const root = rootBuilder();
const notFollowed = Break.NotBefore(...regexes).Build();
const builder = () => Regex.and(root, notFollowed);
return Break.Chain(builder) as Omit<
ReturnType<typeof Break.Chain>,
"NotFollowedBy"
>;
},
}),
};
/**
* Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
// normalizing to single-codepoint composed chars due to canonical equivalence
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
// filtering due to multi-codepoint chars like 👨‍👩‍👧‍👦, 👩🏽‍🦰
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
};
/**
* Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return getHardLineBreaks(text);
}
const lines: WrappedTextLine[] = [];
let offset = 0;
for (const originalLine of text.split("\n")) {
const originalLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) {
lines.push({
text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
}
offset += originalLine.length + 1;
}
return lines;
};
/**
* Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
lineStart: number,
): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line);
let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
const tokenStart = tokenOffset;
const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
const testLineWidth = isSingleCharacter(token)
? currentLineWidth + charWidth.calculate(token, font)
: getLineWidth(testLine, font);
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth;
tokenOffset = tokenEnd;
tokenIndex++;
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine.text;
currentLineStart = trailingLine.start;
currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine);
}
return lines;
};
/**
* Wraps a single word that could not be placed on an empty line as-is.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
}
satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = [];
const chars = Array.from(word);
let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth;
offset = charEnd;
continue;
}
if (currentLine) {
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth;
offset = charEnd;
}
if (currentLine) {
lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
}
return lines;
};
/**
* Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/
const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
return {
text: line,
start,
end,
};
}
// defensively default to `trimeEnd` in case the regex does not match
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
line,
line.trimEnd(),
"",
];
let trimmedLineWidth = getLineWidth(trimmedLine, font);
for (const whitespace of Array.from(whitespaces)) {
const _charWidth = charWidth.calculate(whitespace, font);
const testLineWidth = trimmedLineWidth + _charWidth;
if (testLineWidth > maxWidth) {
break;
}
trimmedLine = trimmedLine + whitespace;
trimmedLineWidth = testLineWidth;
}
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Check if the given string is a single character.
*
* Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨‍👩‍👧‍👦, 👩🏽‍🦰).
*/
const isSingleCharacter = (maybeSingleCharacter: string) => {
return (
maybeSingleCharacter.codePointAt(0) !== undefined &&
maybeSingleCharacter.codePointAt(1) === undefined
);
};
/**
* Invariant for the word wrapping algorithm.
*/
const satisfiesWordInvariant = (word: string) => {
if (isTestEnv() || isDevEnv()) {
if (/\s/.test(word)) {
throw new Error("Word should not contain any whitespaces!");
}
}
};

View File

@@ -0,0 +1,895 @@
import { pointFrom, type LocalPoint } from "@excalidraw/math";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
TEXT_ALIGN,
VERTICAL_ALIGN,
getSizeFromPoints,
randomId,
arrayToMap,
assertNever,
cloneJSON,
getFontString,
getUpdatedTimestamp,
isDevEnv,
toBrandedType,
getLineHeight,
DEFAULT_STICKY_NOTE_SIZE,
isTransparent,
} from "@excalidraw/common";
import type { MarkOptional } from "@excalidraw/common/utility-types";
import { bindBindingElement } from "./binding";
import {
newArrowElement,
newElement,
newEmbeddableElement,
newFrameElement,
newFreeDrawElement,
newIframeElement,
newImageElement,
newLinearElement,
newMagicFrameElement,
newTextElement,
type ElementConstructorOpts,
newStickyNoteElement,
normalizeStickyNoteGeometry,
} from "./newElement";
import { normalizeStickyNoteStrokeColor } from "./stickyNote";
import { measureText, normalizeText } from "./textMeasurements";
import { isArrowElement, isStickyNoteElement } from "./typeChecks";
import { syncInvalidIndices } from "./fractionalIndex";
import {
DEFAULT_BOUND_TEXT_LABEL_POSITION,
redrawTextBoundingBox,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { getCommonBounds } from "./bounds";
import { Scene } from "./Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawFreeDrawElement,
ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FileId,
FontFamilyValues,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
TextAlign,
VerticalAlign,
ExcalidrawStickyNoteElement,
} from "./types";
/**
* Options for elements generated from a skeleton fragment (bound labels and
* binding endpoints). Such elements are always constructed fresh, hence they
* never carry a creation time of their own.
*/
type FragmentConstructorOpts = MarkOptional<
Omit<ElementConstructorOpts, "created">,
"x" | "y"
>;
export type ValidLinearElement = {
type: "arrow" | "line";
x: number;
y: number;
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & FragmentConstructorOpts;
end?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
FragmentConstructorOpts;
start?:
| (
| (
| {
type: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
id?: ExcalidrawGenericElement["id"];
}
| {
id: ExcalidrawGenericElement["id"];
type?: Exclude<
ExcalidrawBindableElement["type"],
| "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>;
}
)
| ((
| {
type: "text";
text: string;
}
| {
type?: "text";
id: ExcalidrawTextElement["id"];
text: string;
}
) &
Partial<Omit<ExcalidrawTextElement, "created">>)
) &
FragmentConstructorOpts;
} & Partial<ExcalidrawLinearElement>;
export type ValidContainer =
| {
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
id?: ExcalidrawGenericElement["id"];
label?: {
text: string;
fontSize?: number;
fontFamily?: FontFamilyValues;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign;
} & FragmentConstructorOpts;
} & ElementConstructorOpts;
/**
* A sticky note: an always-filled note whose label auto-fits. `label.fontSize`
* is the font ceiling the fit shrinks from; the note grows past its height
* (kept as `baseHeight`) only once the label hits the minimum font size.
*/
export type ValidStickyNote = {
type: "stickynote";
id?: ExcalidrawStickyNoteElement["id"];
label?: Extract<ValidContainer, { label?: unknown }>["label"];
} & ElementConstructorOpts &
Partial<Pick<ExcalidrawStickyNoteElement, "baseHeight">>;
export type ExcalidrawElementSkeleton =
| Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
>
| ({
type: Extract<ExcalidrawLinearElement["type"], "line">;
x: number;
y: number;
} & Partial<ExcalidrawLinearElement>)
| ValidContainer
| ValidStickyNote
| ValidLinearElement
| ({
type: "text";
text: string;
x: number;
y: number;
id?: ExcalidrawTextElement["id"];
} & Partial<ExcalidrawTextElement>)
| ({
type: Extract<ExcalidrawImageElement["type"], "image">;
x: number;
y: number;
fileId: FileId;
} & Partial<ExcalidrawImageElement>)
| ({
type: "frame";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100,
height: 0,
};
const DEFAULT_DIMENSION = 100;
const bindTextToContainer = (
container: ExcalidrawElement,
textProps: { text: string } & FragmentConstructorOpts,
scene: Scene,
) => {
// a note and its label share one ink: a label that sets its own color
// gives it to the note (the footer paints with it); transparent falls
// back to the note's
const stickyInk = isStickyNoteElement(container)
? normalizeStickyNoteStrokeColor(
textProps.strokeColor && !isTransparent(textProps.strokeColor)
? textProps.strokeColor
: container.strokeColor,
)
: null;
const textElement: ExcalidrawTextElement = newTextElement({
x: 0,
y: 0,
textAlign: TEXT_ALIGN.CENTER,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
...textProps,
containerId: container.id,
strokeColor: stickyInk ?? (textProps.strokeColor || container.strokeColor),
labelPosition: isArrowElement(container)
? DEFAULT_BOUND_TEXT_LABEL_POSITION
: null,
});
Object.assign(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
...(stickyInk && stickyInk !== container.strokeColor
? { strokeColor: stickyInk }
: null),
});
redrawTextBoundingBox(textElement, container, scene);
return [container, textElement] as const;
};
const bindLinearElementToElement = (
linearElement: NonDeleted<ExcalidrawArrowElement>,
start: ValidLinearElement["start"],
end: ValidLinearElement["end"],
elementStore: ElementStore,
scene: Scene,
): {
linearElement: ExcalidrawLinearElement;
startBoundElement?: ExcalidrawElement;
endBoundElement?: ExcalidrawElement;
} => {
let startBoundElement;
let endBoundElement;
Object.assign(linearElement, {
startBinding: linearElement?.startBinding || null,
endBinding: linearElement.endBinding || null,
});
if (start) {
const width = start?.width ?? DEFAULT_DIMENSION;
const height = start?.height ?? DEFAULT_DIMENSION;
let existingElement;
if (start.id) {
existingElement = elementStore.getElement(start.id);
if (!existingElement) {
console.error(`No element for start binding with id ${start.id} found`);
}
}
const startX = start.x || linearElement.x - width;
const startY = start.y || linearElement.y - height / 2;
const startType = existingElement ? existingElement.type : start.type;
if (startType) {
if (startType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (start.type === "text") {
text = start.text;
}
if (!text) {
console.error(
`No text found for start binding text element for ${linearElement.id}`,
);
}
startBoundElement = newTextElement({
x: startX,
y: startY,
type: "text",
...existingElement,
...start,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(startBoundElement, {
x: start.x || linearElement.x - startBoundElement.width,
y: start.y || linearElement.y - startBoundElement.height / 2,
});
} else {
switch (startType) {
case "rectangle":
case "ellipse":
case "diamond": {
startBoundElement = newElement({
x: startX,
y: startY,
width,
height,
...existingElement,
...start,
type: startType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element start type "${start.type}"`,
true,
);
}
}
}
bindBindingElement(
linearElement,
startBoundElement as NonDeleted<ExcalidrawBindableElement>,
"orbit",
"start",
scene,
);
}
}
if (end) {
const height = end?.height ?? DEFAULT_DIMENSION;
const width = end?.width ?? DEFAULT_DIMENSION;
let existingElement;
if (end.id) {
existingElement = elementStore.getElement(end.id);
if (!existingElement) {
console.error(`No element for end binding with id ${end.id} found`);
}
}
const endX = end.x || linearElement.x + linearElement.width;
const endY = end.y || linearElement.y - height / 2;
const endType = existingElement ? existingElement.type : end.type;
if (endType) {
if (endType === "text") {
let text = "";
if (existingElement && existingElement.type === "text") {
text = existingElement.text;
} else if (end.type === "text") {
text = end.text;
}
if (!text) {
console.error(
`No text found for end binding text element for ${linearElement.id}`,
);
}
endBoundElement = newTextElement({
x: endX,
y: endY,
type: "text",
...existingElement,
...end,
text,
});
// to position the text correctly when coordinates not provided
Object.assign(endBoundElement, {
y: end.y || linearElement.y - endBoundElement.height / 2,
});
} else {
switch (endType) {
case "rectangle":
case "ellipse":
case "diamond": {
endBoundElement = newElement({
x: endX,
y: endY,
width,
height,
...existingElement,
...end,
type: endType,
});
break;
}
default: {
assertNever(
linearElement as never,
`Unhandled element end type "${endType}"`,
true,
);
}
}
}
bindBindingElement(
linearElement,
endBoundElement as NonDeleted<ExcalidrawBindableElement>,
"orbit",
"end",
scene,
);
}
}
// Safe check to early return for single point
if (linearElement.points.length < 2) {
return {
linearElement,
startBoundElement,
endBoundElement,
};
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(
linearElement,
LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement,
points: newPoints,
}),
);
return {
linearElement,
startBoundElement,
endBoundElement,
};
};
class ElementStore {
excalidrawElements = new Map<string, ExcalidrawElement>();
add = (ele?: ExcalidrawElement) => {
if (!ele) {
return;
}
this.excalidrawElements.set(ele.id, ele);
};
getElements = () => {
// programmatically created elements are always non-deleted
return syncInvalidIndices(
Array.from(this.excalidrawElements.values()),
) as Ordered<NonDeletedExcalidrawElement>[];
};
getElementsMap = () => {
return toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(this.getElements()),
);
};
getElement = (id: string) => {
return this.excalidrawElements.get(id);
};
}
export const convertToExcalidrawElements = (
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => {
if (!elementsSkeleton) {
return [];
}
const elements = cloneJSON(elementsSkeleton);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
// regenerated ids mean new instances, hence a fresh creation time as well
const created = getUpdatedTimestamp();
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId(), created });
}
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond": {
const width =
element?.label?.text && element.width === undefined
? 0
: element?.width || DEFAULT_DIMENSION;
const height =
element?.label?.text && element.height === undefined
? 0
: element?.height || DEFAULT_DIMENSION;
excalidrawElement = newElement({
...element,
width,
height,
});
break;
}
case "line": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newLinearElement({
width,
height,
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
});
break;
}
case "arrow": {
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
excalidrawElement = newArrowElement({
width,
height,
endArrowhead: "arrow",
points: [pointFrom(0, 0), pointFrom(width, height)],
...element,
type: "arrow",
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
const text = element.text ?? "";
const normalizedText = normalizeText(text);
const metrics = measureText(
normalizedText,
getFontString({ fontFamily, fontSize }),
lineHeight,
);
excalidrawElement = newTextElement({
width: metrics.width,
height: metrics.height,
fontFamily,
fontSize,
...element,
});
break;
}
case "image": {
excalidrawElement = newImageElement({
width: element?.width || DEFAULT_DIMENSION,
height: element?.height || DEFAULT_DIMENSION,
...element,
});
break;
}
case "frame": {
excalidrawElement = newFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw": {
excalidrawElement = newFreeDrawElement({ ...element });
break;
}
case "iframe": {
excalidrawElement = newIframeElement({ ...element });
break;
}
case "stickynote": {
const width = element.width || DEFAULT_STICKY_NOTE_SIZE;
const height = element.height || DEFAULT_STICKY_NOTE_SIZE;
// finalized geometry (min size, baseHeight ≤ height) — only a
// pointer-down draft is exempt from it
excalidrawElement = normalizeStickyNoteGeometry(
newStickyNoteElement({
...element,
type: "stickynote",
width,
height,
baseHeight: element.baseHeight ?? height,
}),
);
break;
}
case "embeddable": {
excalidrawElement = newEmbeddableElement({ ...element });
break;
}
default: {
excalidrawElement = element;
assertNever(
element,
`Unhandled element type "${(element as any).type}"`,
true,
);
}
}
const existingElement = elementStore.getElement(excalidrawElement.id);
if (existingElement) {
console.error(`Duplicate id found for ${excalidrawElement.id}`);
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
const elementsMap = elementStore.getElementsMap();
// we don't have a real scene, so we just use a temp scene to query and mutate elements
const scene = new Scene(elementsMap);
// Add labels and arrow bindings
for (const [id, element] of elementsWithIds) {
const excalidrawElement = elementStore.getElement(id)!;
switch (element.type) {
case "rectangle":
case "ellipse":
case "diamond":
case "stickynote":
case "arrow": {
if (element.label?.text) {
// for a sticky note this runs the sticky fit (via
// `redrawTextBoundingBox`): the label's font size becomes its ceiling
let [container, text] = bindTextToContainer(
excalidrawElement,
element?.label,
scene,
);
elementStore.add(container);
elementStore.add(text);
if (isArrowElement(container)) {
const originalStart =
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as NonDeleted<ExcalidrawArrowElement>,
originalStart,
originalEnd,
elementStore,
scene,
);
container = linearElement;
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
}
} else {
switch (element.type) {
case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as NonDeleted<ExcalidrawArrowElement>,
start,
end,
elementStore,
scene,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
break;
}
}
}
break;
}
}
}
// Once all the excalidraw elements are created, we can add frames since we
// need to calculate coordinates and dimensions of frame which is possible after all
// frame children are processed.
for (const [id, element] of elementsWithIds) {
if (element.type !== "frame" && element.type !== "magicframe") {
continue;
}
const frame = elementStore.getElement(id);
if (!frame) {
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
}
const childrenElements: ExcalidrawElement[] = [];
element.children.forEach((id) => {
const newElementId = oldToNewElementIdMap.get(id);
if (!newElementId) {
throw new Error(`Element with ${id} wasn't mapped correctly`);
}
const elementInFrame = elementStore.getElement(newElementId);
if (!elementInFrame) {
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
}
Object.assign(elementInFrame, { frameId: frame.id });
elementInFrame?.boundElements?.forEach((boundElement) => {
const ele = elementStore.getElement(boundElement.id);
if (!ele) {
throw new Error(
`Bound element with id ${boundElement.id} doesn't exist`,
);
}
Object.assign(ele, { frameId: frame.id });
childrenElements.push(ele);
});
childrenElements.push(elementInFrame);
});
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
const PADDING = 10;
minX = minX - PADDING;
minY = minY - PADDING;
maxX = maxX + PADDING;
maxY = maxY + PADDING;
const frameX = frame?.x || minX;
const frameY = frame?.y || minY;
const frameWidth = frame?.width || maxX - minX;
const frameHeight = frame?.height || maxY - minY;
Object.assign(frame, {
x: frameX,
y: frameY,
width: frameWidth,
height: frameHeight,
});
if (
isDevEnv() &&
element.children.length &&
(frame?.x || frame?.y || frame?.width || frame?.height)
) {
console.info(
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
);
}
}
return elementStore.getElements();
};

View File

@@ -0,0 +1,354 @@
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
type EditorInterface,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads } from "@excalidraw/math";
import type { Radians } from "@excalidraw/math";
import type {
InteractiveCanvasAppState,
Zoom,
} from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "./bounds";
import {
isElbowArrow,
isFrameLikeElement,
isImageElement,
isLinearElement,
} from "./typeChecks";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
PointerType,
} from "./types";
export type TransformHandleDirection =
| "n"
| "s"
| "w"
| "e"
| "nw"
| "ne"
| "sw"
| "se";
export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = Bounds;
export type TransformHandles = Partial<{
[T in TransformHandleType]: TransformHandle;
}>;
export type MaybeTransformHandleType = TransformHandleType | false;
const transformHandleSizes: { [k in PointerType]: number } = {
mouse: 8,
pen: 16,
touch: 28,
};
const ROTATION_RESIZE_HANDLE_GAP = 16;
export const DEFAULT_OMIT_SIDES = {
e: true,
s: true,
n: true,
w: true,
};
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
e: true,
s: true,
n: true,
w: true,
};
export const OMIT_SIDES_FOR_FRAME = {
e: true,
s: true,
n: true,
w: true,
rotation: true,
};
const OMIT_SIDES_FOR_LINE_SLASH = {
e: true,
s: true,
n: true,
w: true,
nw: true,
se: true,
};
const OMIT_SIDES_FOR_LINE_BACKSLASH = {
e: true,
s: true,
n: true,
w: true,
};
const generateTransformHandle = (
x: number,
y: number,
width: number,
height: number,
cx: number,
cy: number,
angle: Radians,
): TransformHandle => {
const [xx, yy] = pointRotateRads(
pointFrom(x + width / 2, y + height / 2),
pointFrom(cx, cy),
angle,
);
return [xx - width / 2, yy - height / 2, width, height];
};
export const canResizeFromSides = (editorInterface: EditorInterface) => {
if (
editorInterface.formFactor === "phone" &&
editorInterface.userAgent.isMobileDevice
) {
return false;
}
return true;
};
export const getOmitSidesForEditorInterface = (
editorInterface: EditorInterface,
) => {
if (canResizeFromSides(editorInterface)) {
return DEFAULT_OMIT_SIDES;
}
return {};
};
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: Radians,
zoom: Zoom,
pointerType: PointerType,
omitSides: { [T in TransformHandleType]?: boolean } = {},
margin = 4,
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
): TransformHandles => {
const size = transformHandleSizes[pointerType];
const handleWidth = size / zoom.value;
const handleHeight = size / zoom.value;
const handleMarginX = size / zoom.value;
const handleMarginY = size / zoom.value;
const width = x2 - x1;
const height = y2 - y1;
const dashedLineMargin = margin / zoom.value;
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
const transformHandles: TransformHandles = {
nw: omitSides.nw
? undefined
: generateTransformHandle(
x1 - dashedLineMargin - handleMarginX + centeringOffset,
y1 - dashedLineMargin - handleMarginY + centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
),
ne: omitSides.ne
? undefined
: generateTransformHandle(
x2 + dashedLineMargin - centeringOffset,
y1 - dashedLineMargin - handleMarginY + centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
),
sw: omitSides.sw
? undefined
: generateTransformHandle(
x1 - dashedLineMargin - handleMarginX + centeringOffset,
y2 + dashedLineMargin - centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
),
se: omitSides.se
? undefined
: generateTransformHandle(
x2 + dashedLineMargin - centeringOffset,
y2 + dashedLineMargin - centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
),
rotation: omitSides.rotation
? undefined
: generateTransformHandle(
x1 + width / 2 - handleWidth / 2,
y1 -
dashedLineMargin -
handleMarginY +
centeringOffset -
ROTATION_RESIZE_HANDLE_GAP / zoom.value,
handleWidth,
handleHeight,
cx,
cy,
angle,
),
};
// We only want to show height handles (all cardinal directions) above a certain size
// Note: we render using "mouse" size so we should also use "mouse" size for this check
const minimumSizeForEightHandles =
(5 * transformHandleSizes.mouse) / zoom.value;
if (Math.abs(width) > minimumSizeForEightHandles) {
if (!omitSides.n) {
transformHandles.n = generateTransformHandle(
x1 + width / 2 - handleWidth / 2,
y1 - dashedLineMargin - handleMarginY + centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
);
}
if (!omitSides.s) {
transformHandles.s = generateTransformHandle(
x1 + width / 2 - handleWidth / 2,
y2 + dashedLineMargin - centeringOffset,
handleWidth,
handleHeight,
cx,
cy,
angle,
);
}
}
if (Math.abs(height) > minimumSizeForEightHandles) {
if (!omitSides.w) {
transformHandles.w = generateTransformHandle(
x1 - dashedLineMargin - handleMarginX + centeringOffset,
y1 + height / 2 - handleHeight / 2,
handleWidth,
handleHeight,
cx,
cy,
angle,
);
}
if (!omitSides.e) {
transformHandles.e = generateTransformHandle(
x2 + dashedLineMargin - centeringOffset,
y1 + height / 2 - handleHeight / 2,
handleWidth,
handleHeight,
cx,
cy,
angle,
);
}
}
return transformHandles;
};
export const getTransformHandles = (
element: ExcalidrawElement,
zoom: Zoom,
elementsMap: ElementsMap,
pointerType: PointerType = "mouse",
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
): TransformHandles => {
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
// you can't move/resize
if (
element.locked ||
// Elbow arrows cannot be rotated
isElbowArrow(element)
) {
return {};
}
if (element.type === "freedraw" || isLinearElement(element)) {
if (element.points.length === 2) {
// only check the last point because starting point is always (0,0)
const [, p1] = element.points;
if (p1[0] === 0 || p1[1] === 0) {
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
} else if (p1[0] > 0 && p1[1] < 0) {
omitSides = OMIT_SIDES_FOR_LINE_SLASH;
} else if (p1[0] > 0 && p1[1] > 0) {
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
} else if (p1[0] < 0 && p1[1] > 0) {
omitSides = OMIT_SIDES_FOR_LINE_SLASH;
} else if (p1[0] < 0 && p1[1] < 0) {
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
}
}
} else if (isFrameLikeElement(element)) {
omitSides = {
...omitSides,
rotation: true,
};
}
const margin = isLinearElement(element)
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
: isImageElement(element)
? 0
: DEFAULT_TRANSFORM_HANDLE_SPACING;
return getTransformHandlesFromCoords(
getElementAbsoluteCoords(element, elementsMap, true),
element.angle,
zoom,
pointerType,
omitSides,
margin,
isImageElement(element) ? 0 : undefined,
);
};
export const hasBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
editorInterface: EditorInterface,
) => {
if (
appState.selectedLinearElement?.isEditing ||
appState.selectedLinearElement?.isDragging
) {
return false;
}
if (elements.length > 1) {
return true;
}
const element = elements[0];
if (isElbowArrow(element)) {
// Elbow arrows cannot be resized as single selected elements
return false;
}
if (!isLinearElement(element)) {
return true;
}
// on mobile/tablet we currently don't show bbox because of resize issues
// (also prob best for simplicity's sake)
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
};

View File

@@ -0,0 +1,432 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
ExcalidrawEmbeddableElement,
ExcalidrawLinearElement,
ExcalidrawBindableElement,
ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement,
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer,
ExcalidrawFrameElement,
RoundnessType,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
ExcalidrawStickyNoteElement,
} from "./types";
export const isInitializedImageElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & InitializedExcalidrawImageElement => {
return !!element && element.type === "image" && !!element.fileId;
};
export const isImageElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawImageElement => {
return !!element && element.type === "image";
};
export const isEmbeddableElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
): element is T & ExcalidrawEmbeddableElement => {
return !!element && element.type === "embeddable";
};
export const isIframeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawIframeElement => {
return !!element && element.type === "iframe";
};
export const isIframeLikeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawIframeLikeElement => {
return (
!!element && (element.type === "iframe" || element.type === "embeddable")
);
};
export const isTextElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawTextElement => {
return element != null && element.type === "text";
};
export const isStickyNoteElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
): element is T & ExcalidrawStickyNoteElement => {
return element != null && element.type === "stickynote";
};
export const isFrameElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawFrameElement => {
return element != null && element.type === "frame";
};
export const isMagicFrameElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawMagicFrameElement => {
return element != null && element.type === "magicframe";
};
export const isFrameLikeElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawFrameLikeElement => {
return (
element != null &&
(element.type === "frame" || element.type === "magicframe")
);
};
export const isFreeDrawElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawFreeDrawElement => {
return element != null && isFreeDrawElementType(element.type);
};
export const isFreeDrawElementType = (
elementType: ExcalidrawElementType,
): boolean => {
return elementType === "freedraw";
};
export const isLinearElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawLinearElement => {
return element != null && isLinearElementType(element.type);
};
export const isLineElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawArrowElement => {
return element != null && element.type === "arrow";
};
export const isElbowArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawElbowArrowElement => {
return isArrowElement(element) && element.elbowed;
};
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed && !element.roundness;
};
export const isCurvedArrow = <T extends ExcalidrawElement>(
element?: T,
): element is T & ExcalidrawArrowElement => {
return (
isArrowElement(element) && !element.elbowed && element.roundness !== null
);
};
export const isLinearElementType = (
elementType: ElementOrToolType,
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
);
};
export const isBindingElement = <T extends ExcalidrawElement>(
element?: T | null,
includeLocked = true,
): element is T & ExcalidrawArrowElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
isBindingElementType(element.type)
);
};
export const isBindingElementType = (
elementType: ElementOrToolType,
): boolean => {
return elementType === "arrow";
};
export const isBindableElement = <T extends ExcalidrawElement>(
element: T | null | undefined,
includeLocked = true,
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
export const isRectanguloidElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId))
);
};
// TODO: Remove this when proper distance calculation is introduced
// @see binding.ts:distanceToBindableElement()
export const isRectangularElement = <T extends ExcalidrawElement>(
element?: T | null,
): element is T & ExcalidrawBindableElement => {
return (
element != null &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "image" ||
element.type === "text" ||
element.type === "iframe" ||
element.type === "embeddable" ||
element.type === "frame" ||
element.type === "magicframe" ||
element.type === "freedraw")
);
};
export const isTextBindableContainer = <T extends ExcalidrawElement>(
element: T | null,
includeLocked = true,
): element is T & ExcalidrawTextContainer => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "diamond" ||
element.type === "ellipse" ||
isArrowElement(element))
);
};
export const isExcalidrawElement = (
element: any,
): element is ExcalidrawElement => {
const type: ExcalidrawElementType | undefined = element?.type;
if (!type) {
return false;
}
switch (type) {
case "text":
case "diamond":
case "rectangle":
case "stickynote":
case "iframe":
case "embeddable":
case "ellipse":
case "arrow":
case "freedraw":
case "line":
case "frame":
case "magicframe":
case "image":
case "selection": {
return true;
}
default: {
assertNever(type, null);
return false;
}
}
};
export const isFlowchartNodeElement = <T extends ExcalidrawElement>(
element: T,
): element is T & ExcalidrawFlowchartNodeElement => {
return (
element.type === "rectangle" ||
element.type === "stickynote" ||
element.type === "ellipse" ||
element.type === "diamond"
);
};
export const hasBoundTextElement = <T extends ExcalidrawElement>(
element: T | null,
): element is T &
MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
return (
isTextBindableContainer(element) &&
!!element.boundElements?.some(({ type }) => type === "text")
);
};
export const isBoundToContainer = <T extends ExcalidrawElement>(
element: T | null,
): element is T & ExcalidrawTextElementWithContainer => {
return (
element !== null &&
"containerId" in element &&
element.containerId !== null &&
isTextElement(element)
);
};
export const isArrowBoundToElement = (element: ExcalidrawArrowElement) => {
return !!element.startBinding || !!element.endBinding;
};
export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" ||
type === "embeddable" ||
type === "iframe" ||
type === "image";
export const isUsingProportionalRadius = (type: string) =>
type === "line" ||
type === "arrow" ||
type === "diamond" ||
type === "stickynote";
export const canApplyRoundnessTypeToElement = (
roundnessType: RoundnessType,
element: ExcalidrawElement,
) => {
if (
(roundnessType === ROUNDNESS.ADAPTIVE_RADIUS ||
// if legacy roundness, it can be applied to elements that currently
// use adaptive radius
roundnessType === ROUNDNESS.LEGACY) &&
isUsingAdaptiveRadius(element.type)
) {
return true;
}
if (
roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS &&
isUsingProportionalRadius(element.type)
) {
return true;
}
return false;
};
export const getDefaultRoundnessTypeForElement = (
element: ExcalidrawElement,
) => {
if (isUsingProportionalRadius(element.type)) {
return {
type: ROUNDNESS.PROPORTIONAL_RADIUS,
};
}
if (isUsingAdaptiveRadius(element.type)) {
return {
type: ROUNDNESS.ADAPTIVE_RADIUS,
};
}
return null;
};
export const getLinearElementSubType = (
element: ExcalidrawLinearElement,
): ExcalidrawLinearElementSubType => {
if (isSharpArrow(element)) {
return "sharpArrow";
}
if (isCurvedArrow(element)) {
return "curvedArrow";
}
if (isElbowArrow(element)) {
return "elbowArrow";
}
return "line";
};
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
export const isEligibleFrameChildType = (type: ElementOrToolType) => {
switch (type) {
case "rectangle":
case "stickynote":
case "diamond":
case "ellipse":
case "arrow":
case "line":
case "freedraw":
case "text":
case "image":
case "frame":
case "embeddable": {
return true;
}
default: {
return false;
}
}
};

View File

@@ -0,0 +1,496 @@
import type { LocalPoint, Radians } from "@excalidraw/math";
import type {
FONT_FAMILY,
ROUNDNESS,
TEXT_ALIGN,
THEME,
VERTICAL_ALIGN,
} from "@excalidraw/common";
import type {
MakeBrand,
MarkNonNullable,
Merge,
ValueOf,
} from "@excalidraw/common/utility-types";
export type ChartType = "bar" | "line" | "radar";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
export type Theme = typeof THEME[keyof typeof THEME];
export type FontString = string & { _brand: "fontString" };
export type GroupId = string;
export type PointerType = "mouse" | "pen" | "touch";
export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeStyle = "solid" | "dashed" | "dotted";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
export type FractionalIndex = string & { _brand: "franctionalIndex" };
export type BoundElement = Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>;
type _ExcalidrawElementBase = Readonly<{
id: string;
x: number;
y: number;
strokeColor: string;
backgroundColor: string;
fillStyle: FillStyle;
strokeWidth: number;
strokeStyle: StrokeStyle;
roundness: null | { type: RoundnessType; value?: number };
roughness: number;
opacity: number;
width: number;
height: number;
angle: Radians;
/** Random integer used to seed shape generation so that the roughjs shape
doesn't differ across renders. */
seed: number;
/** Integer that is sequentially incremented on each change. Used to reconcile
elements during collaboration or when saving to server. */
version: number;
/** Random integer that is regenerated on each change.
Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */
versionNonce: number;
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
Could be null, i.e. for new elements which were not yet assigned to the scene. */
index: FractionalIndex | null;
isDeleted: boolean;
/** List of groups the element belongs to.
Ordered from deepest to shallowest. */
groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */
boundElements: readonly BoundElement[] | null;
/** epoch (ms) timestamp of last element update */
updated: number;
/** Client wall-clock creation time in epoch milliseconds; null if unknown.
Preserved for this element's lifetime, including edits and undo/redo,
and excluded from `ElementUpdate` (mutateElement / newElementWith).
Duplicating an element starts a new lifetime. Not an ordering clock. */
created: number | null;
link: string | null;
locked: boolean;
customData?: Record<string, any>;
}>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
type: "selection";
};
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
type: "rectangle";
};
export type ExcalidrawStickyNoteElement = _ExcalidrawElementBase &
Readonly<{
type: "stickynote";
/**
* The height the user set, from which the layout derives `height`: the
* note grows above it to fit its label and never shrinks below it
*/
baseHeight: number;
}>;
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
type: "diamond";
};
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
type: "ellipse";
};
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
Readonly<{
type: "embeddable";
}>;
export type MagicGenerationData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{
type: "iframe";
// TODO move later to AI-specific frame
customData?: { generationData?: MagicGenerationData };
}>;
export type ExcalidrawIframeLikeElement =
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type IframeData =
| {
intrinsicSize: { w: number; h: number };
error?: Error;
sandbox?: { allowSameOrigin?: boolean };
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
);
export type ImageCrop = {
x: number;
y: number;
width: number;
height: number;
naturalWidth: number;
naturalHeight: number;
};
export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{
type: "image";
fileId: FileId | null;
/** whether respective file is persisted */
status: "pending" | "saved" | "error";
/** X and Y scale factors <-1, 1>, used for image axis flipping */
scale: [number, number];
/** whether an element is cropped */
crop: ImageCrop | null;
}>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
>;
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
type: "frame";
name: string | null;
};
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
type: "magicframe";
name: string | null;
};
export type ExcalidrawFrameLikeElement =
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
/**
* These are elements that don't have any additional properties.
*/
export type ExcalidrawGenericElement =
| ExcalidrawSelectionElement
| ExcalidrawRectangleElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawFlowchartNodeElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement;
export type ExcalidrawRectanguloidElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawImageElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/**
* ExcalidrawElement should be JSON serializable and (eventually) contain
* no computed data. The list of all ExcalidrawElements should be shareable
* between peers and contain no state local to the peer.
*/
export type ExcalidrawElement =
| ExcalidrawGenericElement
| ExcalidrawStickyNoteElement
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawArrowElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type ExcalidrawNonSelectionElement = Exclude<
ExcalidrawElement,
ExcalidrawSelectionElement
>;
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
index: FractionalIndex;
};
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: false;
};
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
export type ExcalidrawTextElement = _ExcalidrawElementBase &
Readonly<{
type: "text";
fontSize: number;
fontFamily: FontFamilyValues;
/**
* The font size the user picked, from which the layout derives `fontSize`.
* Today only sticky note labels have one: the auto-fit shrinks below it
* and never above it (compare `baseHeight`, which the note grows above).
* `null` for every other text. Read it through `getBaseFontSize` —
* generic binding repair can detach a label without clearing this, so
* the container decides its meaning.
*/
baseFontSize: number | null;
text: string;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawTextContainer["id"] | null;
originalText: string;
/**
* If `true` the width will fit the text. If `false`, the text will
* wrap to fit the width.
*
* @default true
*/
autoResize: boolean;
/**
* Unitless line height (aligned to W3C). To get line height in px, multiply
* with font size (using `getLineHeightInPx` helper).
*/
lineHeight: number & { _brand: "unitlessLineHeight" };
/**
* Position of text bound to a linear element (such as an arrow),
* expressed as a normalized arc-length parameter (0–1) along the
* container's whole path. Independent of how the path is segmented,
* so it survives midpoint insertion and other geometry changes.
* */
labelPosition?: number | null;
}>;
export type ExcalidrawBindableElement =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
| ExcalidrawStickyNoteElement
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawArrowElement;
export type ExcalidrawTextElementWithContainer = {
containerId: ExcalidrawTextContainer["id"];
} & ExcalidrawTextElement;
export type FixedPoint = [number, number];
export type BindMode = "inside" | "orbit" | "skip";
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"];
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
// Determines whether the arrow remains outside the shape or is allowed to
// go all the way inside the shape up to the exact fixed point.
mode: BindMode;
};
type Index = number;
export type PointsPositionUpdates = Map<
Index,
{ point: LocalPoint; isDragging?: boolean }
>;
export type CardinalityArrowhead =
| "cardinality_one"
| "cardinality_many"
| "cardinality_one_or_many"
| "cardinality_exactly_one"
| "cardinality_zero_or_one"
| "cardinality_zero_or_many";
export type ArrowheadLegacy =
| "dot"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type Arrowhead =
| "arrow"
| "bar"
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
| CardinalityArrowhead;
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
type: "line" | "arrow";
points: readonly LocalPoint[];
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = {
start: LocalPoint;
end: LocalPoint;
index: Index;
};
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
Readonly<{
type: "arrow";
elbowed: boolean;
}>;
export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
/**
* Marks that the 3rd point should be used as the 2nd point of the arrow in
* order to temporarily hide the first segment of the arrow without losing
* the data from the points array. It allows creating the expected arrow
* path when the arrow with fixed segments is bound on a horizontal side and
* moved to a vertical and vica versa.
*/
startIsSpecial: boolean | null;
/**
* Marks that the 3rd point backwards from the end should be used as the 2nd
* point of the arrow in order to temporarily hide the last segment of the
* arrow without losing the data from the points array. It allows creating
* the expected arrow path when the arrow with fixed segments is bound on a
* horizontal side and moved to a vertical and vica versa.
*/
endIsSpecial: boolean | null;
}
>;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>;
export type FileId = string & { _brand: "FileId" };
export type ExcalidrawElementType = ExcalidrawElement["type"];
/**
* Map of excalidraw elements.
* Unspecified whether deleted or non-deleted.
* Can be a subset of Scene elements.
*/
export type ElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement>;
/**
* Map of non-deleted elements.
* Can be a subset of Scene elements.
*/
export type NonDeletedElementsMap = Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
> &
MakeBrand<"NonDeletedElementsMap">;
/**
* Map of all excalidraw Scene elements, including deleted.
* Not a subset. Use this type when you need access to current Scene elements.
*/
export type SceneElementsMap = Map<
ExcalidrawElement["id"],
Ordered<ExcalidrawElement>
> &
MakeBrand<"SceneElementsMap">;
/**
* Map of all non-deleted Scene elements.
* Not a subset. Use this type when you need access to current Scene elements.
*/
export type NonDeletedSceneElementsMap = Map<
ExcalidrawElement["id"],
Ordered<NonDeletedExcalidrawElement>
> &
MakeBrand<"NonDeletedSceneElementsMap">;
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;
export type NonDeletedElementsMapOrArray =
| readonly NonDeletedExcalidrawElement[]
| Readonly<NonDeletedElementsMap | NonDeletedSceneElementsMap>;
export type ExcalidrawLinearElementSubType =
| "line"
| "sharpArrow"
| "curvedArrow"
| "elbowArrow";
export type ConvertibleGenericTypes = "rectangle" | "diamond" | "ellipse";
export type ConvertibleLinearTypes = ExcalidrawLinearElementSubType;
export type ConvertibleTypes = ConvertibleGenericTypes | ConvertibleLinearTypes;

View File

@@ -0,0 +1,788 @@
import {
DEFAULT_ADAPTIVE_RADIUS,
DEFAULT_PROPORTIONAL_RADIUS,
invariant,
LINE_CONFIRM_THRESHOLD,
ROUNDNESS,
} from "@excalidraw/common";
import {
bezierEquation,
curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
lineSegmentIntersectionPoints,
pointDistance,
pointFrom,
pointFromArray,
pointFromVector,
pointRotateRads,
pointTranslate,
rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint,
} from "@excalidraw/math";
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type {
AppState,
NormalizedZoomValue,
Zoom,
} from "@excalidraw/excalidraw/types";
import { elementCenterPoint, getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./shape";
import { hitElementItself, isPointInElement } from "./collision";
import { LinearElementEditor } from "./linearElementEditor";
import { isElbowArrow, isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import { getStickyNoteCornerRadius } from "./stickyNote";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
} from "./types";
export type LinearPathSegment = LineSegment<GlobalPoint> | Curve<GlobalPoint>;
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
/**
* Returns the **rotated** components of freedraw, line or arrow elements.
*
* @param element The linear element to deconstruct
* @returns The rotated in components.
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element, elementsMap);
const lines: LineSegment<GlobalPoint>[] = [];
const curves: Curve<GlobalPoint>[] = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape: ElementShape = [lines, curves];
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
export function getLinearElementPathSegments(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): LinearPathSegment[] {
// For now, model elbow arrows as their unrounded logical path. Rounded
// joints can be incorporated once the path model supports mixed straight
// and curved segments.
if (isElbowArrow(element)) {
return element.points
.slice(1)
.map((point, index) =>
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + element.points[index][0],
element.y + element.points[index][1],
),
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
),
);
}
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
// Non-elbow paths currently contain only one segment type. Mixed paths
// should consume an ordered operation stream instead of these type buckets.
return curves.length > 0 ? curves : lines;
}
/**
* Get the building components of a rectanguloid element in the form of
* line segments and curves **unrotated**.
*
* @param element Target rectanguloid element
* @param offset Optional offset to expand the rectanguloid shape
* @returns Tuple of **unrotated** line segments (0) and curves (1)
*/
export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement,
offset: number = 0,
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius =
element.type === "stickynote"
? getStickyNoteCornerRadius(element)
: getCornerRadius(Math.min(element.width, element.height), element);
if (radius === 0) {
radius = 0.01;
}
const r = rectangle(
pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
);
const baseCorners = [
curve(
left[1],
pointFrom<GlobalPoint>(
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
pointFrom<GlobalPoint>(
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
top[0],
), // TOP LEFT
curve(
top[1],
pointFrom<GlobalPoint>(
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
pointFrom<GlobalPoint>(
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
right[0],
), // TOP RIGHT
curve(
right[1],
pointFrom<GlobalPoint>(
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
pointFrom<GlobalPoint>(
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
bottom[1],
), // BOTTOM RIGHT
curve(
bottom[0],
pointFrom<GlobalPoint>(
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
pointFrom<GlobalPoint>(
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
left[0],
), // BOTTOM LEFT
];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
export function getDiamondBaseCorners(
element: ExcalidrawDiamondElement,
offset: number = 0,
): Curve<GlobalPoint>[] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY),
pointFrom(element.x + rightX, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY),
pointFrom(element.x + leftX, element.y + leftY),
];
return [
curve(
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
right,
right,
pointFrom<GlobalPoint>(
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), // RIGHT
curve(
pointFrom<GlobalPoint>(
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
bottom,
bottom,
pointFrom<GlobalPoint>(
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), // BOTTOM
curve(
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
left,
left,
pointFrom<GlobalPoint>(
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), // LEFT
curve(
pointFrom<GlobalPoint>(
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
top,
top,
pointFrom<GlobalPoint>(
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), // TOP
];
}
/**
* Get the **unrotated** building components of a diamond element
* in the form of line segments and curves as a tuple, in this order.
*
* @param element The element to deconstruct
* @param offset An optional offset
* @returns Tuple of line **unrotated** segments (0) and curves (1)
*/
export function deconstructDiamondElement(
element: ExcalidrawDiamondElement,
offset: number = 0,
): ElementShape {
const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
const baseCorners = getDiamondBaseCorners(element, offset);
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
}
// Checks if the first and last point are close enough
// to be considered a loop
export const isPathALoop = (
points: ExcalidrawLinearElement["points"],
/** supply if you want the loop detection to account for current zoom */
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
): boolean => {
if (points.length >= 3) {
const [first, last] = [points[0], points[points.length - 1]];
const distance = pointDistance(first, last);
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
// really close we make the threshold smaller, and vice versa.
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
}
return false;
};
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
if (
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
element.roundness?.type === ROUNDNESS.LEGACY
) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
if (x <= CUTOFF_SIZE) {
return x * DEFAULT_PROPORTIONAL_RADIUS;
}
return fixedRadiusSize;
}
return 0;
};
const getDiagonalsForBindableElement = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
// for rectangles, shrink the diagonals a bit because there's something
// going on with the focus points around the corners. Ask Mark for details.
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
const offset = vectorScale(v, OFFSET_PX);
return lineSegment<GlobalPoint>(
pointTranslate(seg[0], offset),
pointTranslate(seg[1], vectorScale(offset, -1)),
);
};
const center = elementCenterPoint(element, elementsMap);
const diagonalOne = shrinkSegment(
isRectangularElement(element)
? lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height,
),
center,
element.angle,
),
)
: lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
),
);
const diagonalTwo = shrinkSegment(
isRectangularElement(element)
? lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width, element.y),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height),
center,
element.angle,
),
)
: lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
),
);
return [diagonalOne, diagonalTwo];
};
export const getSnapOutlineMidPoint = (
point: GlobalPoint,
element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom: AppState["zoom"],
) => {
const center = elementCenterPoint(element, elementsMap);
const sideMidpoints =
element.type === "diamond"
? getDiamondBaseCorners(element).map((curve) => {
const point = bezierEquation(curve, 0.5);
const rotatedPoint = pointRotateRads(point, center, element.angle);
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
})
: [
// RIGHT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width,
element.y + element.height / 2,
),
center,
element.angle,
),
// BOTTOM midpoint
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + element.width / 2,
element.y + element.height,
),
center,
element.angle,
),
// LEFT midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
center,
element.angle,
),
// TOP midpoint
pointRotateRads(
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
center,
element.angle,
),
];
const candidate = sideMidpoints.find(
(midpoint) =>
pointDistance(point, midpoint) <=
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
!hitElementItself({
point,
element,
threshold: 0,
elementsMap,
overrideShouldTestInside: true,
}),
);
return candidate;
};
export const projectFixedPointOntoDiagonal = (
arrow: ExcalidrawArrowElement,
point: GlobalPoint,
element: ExcalidrawBindableElement,
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
if (isMidpointSnappingEnabled) {
const sideMidPoint = getSnapOutlineMidPoint(
point,
element,
elementsMap,
zoom,
);
if (sideMidPoint) {
return sideMidPoint;
}
}
// Do the projection onto the diagonals (or center lines
// for non-rectangular shapes)
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
element,
elementsMap,
);
// To avoid working with stale arrow state, we use the opposite focus point
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap,
);
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>(
vectorScale(
vectorFromPoint(point, a),
2 * pointDistance(a, point) +
Math.max(
pointDistance(diagonalOne[0], diagonalOne[1]),
pointDistance(diagonalTwo[0], diagonalTwo[1]),
),
),
a,
);
const intersector = lineSegment<GlobalPoint>(b, a);
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
const d1 = p1 && pointDistance(a, p1);
const d2 = p2 && pointDistance(a, p2);
let projection = null;
if (d1 != null && d2 != null) {
projection = d1 < d2 ? p1 : p2;
} else {
projection = p1 || p2 || null;
}
if (projection && isPointInElement(projection, element, elementsMap)) {
return projection;
}
return null;
};

View File

@@ -0,0 +1,257 @@
import {
isLineSegment,
lineSegment,
pointDistanceSq,
pointFrom,
type GlobalPoint,
type LocalPoint,
type LineSegment,
} from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
getElementBounds,
intersectElementWithLineSegment,
isFreeDrawElement,
isLinearElement,
isPathALoop,
} from "@excalidraw/element";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math";
// The global data holder to collect the debug operations
declare global {
interface Window {
visualDebug?: {
data: DebugElement[][];
currentFrame?: number;
};
}
}
export type DebugElement = {
color: string;
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon;
permanent: boolean;
};
export type DebugPolygon = {
type: "polygon";
points: GlobalPoint[];
fill?: boolean;
close?: boolean;
};
export const debugDrawHitVolume = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
options?: {
rays?: number;
color?: string;
fill?: boolean;
},
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
return;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const rays = options?.rays ?? 100;
const radius = Math.max(x2 - x1, y2 - y1) * 2;
const points: GlobalPoint[] = [];
for (let i = 0; i < rays; i += 1) {
const angle = (i / rays) * Math.PI * 2;
const end = pointFrom<GlobalPoint>(
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius,
);
const hits = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(center, end),
);
if (hits.length === 0) {
continue;
}
hits.sort(pointDistanceSq);
points.push(hits[0]);
}
if (points.length >= 3) {
debugDrawPolygon(points, {
color: options?.color ?? "orange",
fill: options?.fill ?? true,
});
} else {
console.warn(
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
);
}
};
export const debugDrawCubicBezier = (
c: Curve<GlobalPoint>,
opts?: {
color?: string;
permanent?: boolean;
},
) => {
addToCurrentFrame({
color: opts?.color ?? "purple",
permanent: !!opts?.permanent,
data: c,
});
};
export const debugDrawLine = (
segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
const segments = (
isLineSegment(segment) ? [segment] : segment
) as LineSegment<GlobalPoint>[];
segments.forEach((data) =>
addToCurrentFrame({
color: opts?.color ?? "red",
data,
permanent: !!opts?.permanent,
}),
);
};
export const debugDrawPolygon = (
points: GlobalPoint[],
opts?: {
color?: string;
permanent?: boolean;
fill?: boolean;
close?: boolean;
},
) => {
if (points.length < 2) {
return;
}
addToCurrentFrame({
color: opts?.color ?? "orange",
permanent: !!opts?.permanent,
data: {
type: "polygon",
points,
fill: opts?.fill,
close: opts?.close,
},
});
};
export const debugDrawPoint = (
p: GlobalPoint,
opts?: {
color?: string;
permanent?: boolean;
fuzzy?: boolean;
},
) => {
const xOffset = opts?.fuzzy ? Math.random() * 3 : 0;
const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset - 10),
pointFrom<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset + 10),
),
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
);
debugDrawLine(
lineSegment(
pointFrom<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset + 10),
pointFrom<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset - 10),
),
{
color: opts?.color ?? "cyan",
permanent: opts?.permanent,
},
);
};
export const debugDrawBounds = (
box: Bounds | Bounds[],
opts?: {
color?: string;
permanent?: boolean;
},
) => {
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) =>
debugDrawLine(
[
lineSegment(
pointFrom<GlobalPoint>(bbox[0], bbox[1]),
pointFrom<GlobalPoint>(bbox[2], bbox[1]),
),
lineSegment(
pointFrom<GlobalPoint>(bbox[2], bbox[1]),
pointFrom<GlobalPoint>(bbox[2], bbox[3]),
),
lineSegment(
pointFrom<GlobalPoint>(bbox[2], bbox[3]),
pointFrom<GlobalPoint>(bbox[0], bbox[3]),
),
lineSegment(
pointFrom<GlobalPoint>(bbox[0], bbox[3]),
pointFrom<GlobalPoint>(bbox[0], bbox[1]),
),
],
{
color: opts?.color ?? "green",
permanent: !!opts?.permanent,
},
),
);
};
export const debugDrawPoints = (
{
x,
y,
points,
}: {
x: number;
y: number;
points: readonly LocalPoint[];
},
options?: any,
) => {
points.forEach((p) =>
debugDrawPoint(pointFrom<GlobalPoint>(x + p[0], y + p[1]), options),
);
};
export const debugCloseFrame = () => {
window.visualDebug?.data.push([]);
};
export const debugClear = () => {
if (window.visualDebug?.data) {
window.visualDebug.data = [];
}
};
const addToCurrentFrame = (element: DebugElement) => {
if (window.visualDebug?.data && window.visualDebug.data.length === 0) {
window.visualDebug.data[0] = [];
}
window.visualDebug?.data &&
window.visualDebug.data[window.visualDebug.data.length - 1].push(element);
};

View File

@@ -0,0 +1,676 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import { isFiniteNumber } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
import { isFrameLikeElement, isTextElement } from "./typeChecks";
import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import { getNonDeletedElements } from ".";
import type { Scene } from "./Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
} from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId;
};
/**
* Returns indices of elements to move based on selected elements.
* Includes contiguous deleted elements that are between two selected elements,
* e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
*
* Specified elements (elementsToBeMoved) take precedence over
* appState.selectedElementsIds
*/
const getIndicesToMove = (
elements: readonly ExcalidrawElement[],
appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
let selectedIndices: number[] = [];
let deletedIndices: number[] = [];
let includeDeletedIndex = null;
let index = -1;
const selectedElementIds = arrayToMap(
elementsToBeMoved
? elementsToBeMoved
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
while (++index < elements.length) {
const element = elements[index];
if (selectedElementIds.get(element.id)) {
if (deletedIndices.length) {
selectedIndices = selectedIndices.concat(deletedIndices);
deletedIndices = [];
}
selectedIndices.push(index);
includeDeletedIndex = index + 1;
} else if (element.isDeleted && includeDeletedIndex === index) {
includeDeletedIndex = index + 1;
deletedIndices.push(index);
} else {
deletedIndices = [];
}
}
return selectedIndices;
};
const toContiguousGroups = (array: number[]) => {
let cursor = 0;
return array.reduce((acc, value, index) => {
if (index > 0 && array[index - 1] !== value - 1) {
cursor = ++cursor;
}
(acc[cursor] || (acc[cursor] = [])).push(value);
return acc;
}, [] as number[][]);
};
/**
* @returns index of target element, consindering tightly-bound elements
* (currently non-linear elements bound to a container) as a one unit.
* If no binding present, returns `undefined`.
*/
const getTargetIndexAccountingForBinding = (
nextElement: ExcalidrawElement,
elements: readonly ExcalidrawElement[],
direction: "left" | "right",
scene: Scene,
) => {
if ("containerId" in nextElement && nextElement.containerId) {
// TODO: why not to get the container from the nextElements?
const containerElement = scene.getElement(nextElement.containerId);
if (containerElement) {
return direction === "left"
? Math.min(
elements.indexOf(containerElement),
elements.indexOf(nextElement),
)
: Math.max(
elements.indexOf(containerElement),
elements.indexOf(nextElement),
);
}
} else {
const boundElementId = nextElement.boundElements?.find(
(binding) => binding.type !== "arrow",
)?.id;
if (boundElementId) {
const boundTextElement = scene.getElement(boundElementId);
if (boundTextElement) {
return direction === "left"
? Math.min(
elements.indexOf(boundTextElement),
elements.indexOf(nextElement),
)
: Math.max(
elements.indexOf(boundTextElement),
elements.indexOf(nextElement),
);
}
}
}
};
const getContiguousFrameRangeElements = (
allElements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameLikeElement["id"],
) => {
let rangeStart = -1;
let rangeEnd = -1;
allElements.forEach((element, index) => {
if (isOfTargetFrame(element, frameId)) {
if (rangeStart === -1) {
rangeStart = index;
}
rangeEnd = index;
}
});
if (rangeStart === -1) {
return [];
}
return allElements.slice(rangeStart, rangeEnd + 1);
};
/**
* Moves the arrow element above any bindable elements it intersects with or
* hovers over.
*/
export const moveArrowAboveBindable = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly OrderedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
hit?: NonDeletedExcalidrawElement,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = hit
? hit
: getHoveredElementForBinding(
point,
// SAFETY: callers that omit `hit` pass non-deleted elements
getNonDeletedElements(elements),
elementsMap,
);
if (!hoveredElement) {
return elements;
}
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
const containerElement = isTextElement(hoveredElement)
? getContainerElement(hoveredElement, elementsMap)
: null;
const bindableIds = [
hoveredElement.id,
boundTextElement?.id,
containerElement?.id,
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
const updatedElements = Array.from(elements);
const arrow = updatedElements.splice(arrowIdx, 1)[0];
updatedElements.splice(bindableIdx, 0, arrow);
scene.replaceAllElements(updatedElements);
}
return elements;
};
/**
* Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it).
*/
const getTargetIndex = (
appState: AppState,
elements: readonly ExcalidrawElement[],
boundaryIndex: number,
direction: "left" | "right",
/**
* Frame id if moving frame children.
* If whole frame (including all children) is being moved, supply `null`.
*/
containingFrame: ExcalidrawFrameLikeElement["id"] | null,
scene: Scene,
) => {
const sourceElement = elements[boundaryIndex];
const indexFilter = (element: ExcalidrawElement) => {
if (element.isDeleted) {
return false;
}
if (containingFrame) {
return element.frameId === containingFrame;
}
// if we're editing group, find closest sibling irrespective of whether
// there's a different-group element between them (for legacy reasons)
if (appState.editingGroupId) {
return element.groupIds.includes(appState.editingGroupId);
}
return true;
};
const candidateIndex =
direction === "left"
? findLastIndex(
elements,
(el) => indexFilter(el),
Math.max(0, boundaryIndex - 1),
)
: findIndex(elements, (el) => indexFilter(el), boundaryIndex + 1);
const nextElement = elements[candidateIndex];
if (!nextElement) {
return -1;
}
if (appState.editingGroupId) {
if (
// candidate element is a sibling in current editing group → return
sourceElement?.groupIds.join("") === nextElement?.groupIds.join("")
) {
return (
getTargetIndexAccountingForBinding(
nextElement,
elements,
direction,
scene,
) ?? candidateIndex
);
} else if (!nextElement?.groupIds.includes(appState.editingGroupId)) {
// candidate element is outside current editing group → prevent
return -1;
}
}
if (
!containingFrame &&
(nextElement.frameId || isFrameLikeElement(nextElement))
) {
const frameElements = getContiguousFrameRangeElements(
elements,
nextElement.frameId || nextElement.id,
);
return direction === "left"
? elements.indexOf(frameElements[0])
: elements.indexOf(frameElements[frameElements.length - 1]);
}
if (!nextElement.groupIds.length) {
return (
getTargetIndexAccountingForBinding(
nextElement,
elements,
direction,
scene,
) ?? candidateIndex
);
}
const siblingGroupId = appState.editingGroupId
? nextElement.groupIds[
nextElement.groupIds.indexOf(appState.editingGroupId) - 1
]
: nextElement.groupIds[nextElement.groupIds.length - 1];
const elementsInSiblingGroup = getElementsInGroup(elements, siblingGroupId);
if (elementsInSiblingGroup.length) {
// assumes getElementsInGroup() returned elements are sorted
// by zIndex (ascending)
return direction === "left"
? elements.indexOf(elementsInSiblingGroup[0])
: elements.indexOf(
elementsInSiblingGroup[elementsInSiblingGroup.length - 1],
);
}
return candidateIndex;
};
const getTargetElementsMap = <T extends ExcalidrawElement>(
elements: readonly T[],
indices: number[],
) => {
return indices.reduce((acc, index) => {
const element = elements[index];
acc.set(element.id, element);
return acc;
}, new Map<string, ExcalidrawElement>());
};
const hasSameElementIds = (
prevElements: readonly ExcalidrawElement[],
nextElements: readonly ExcalidrawElement[],
) => {
if (prevElements.length !== nextElements.length) {
console.error(
"z-index reordering failed: resulting array have different lengths",
);
return false;
}
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
for (const element of prevElements) {
prevElementIdCounts.set(
element.id,
(prevElementIdCounts.get(element.id) || 0) + 1,
);
}
for (const element of nextElements) {
const count = prevElementIdCounts.get(element.id);
if (!count) {
console.error(
"z-index reordering failed: element id mismatch / duplicate ids",
);
return false;
}
prevElementIdCounts.set(element.id, count - 1);
}
return true;
};
const shiftElementsByOne = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
scene: Scene,
) => {
const originalElements = elements;
const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
let groupedIndices = toContiguousGroups(indicesToMove);
if (direction === "right") {
groupedIndices = groupedIndices.reverse();
}
const selectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>(
indicesToMove
.filter((idx) => isFrameLikeElement(elements[idx]))
.map((idx) => elements[idx].id),
);
groupedIndices.forEach((indices, i) => {
const leadingIndex = indices[0];
const trailingIndex = indices[indices.length - 1];
const boundaryIndex = direction === "left" ? leadingIndex : trailingIndex;
const containingFrame = indices.some((idx) => {
const el = elements[idx];
return el.frameId && selectedFrames.has(el.frameId);
})
? null
: elements[boundaryIndex]?.frameId;
const targetIndex = getTargetIndex(
appState,
elements,
boundaryIndex,
direction,
containingFrame,
scene,
);
if (targetIndex === -1 || boundaryIndex === targetIndex) {
return;
}
const leadingElements =
direction === "left"
? elements.slice(0, targetIndex)
: elements.slice(0, leadingIndex);
const targetElements = elements.slice(leadingIndex, trailingIndex + 1);
const displacedElements =
direction === "left"
? elements.slice(targetIndex, leadingIndex)
: elements.slice(trailingIndex + 1, targetIndex + 1);
const trailingElements =
direction === "left"
? elements.slice(trailingIndex + 1)
: elements.slice(targetIndex + 1);
elements =
direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
];
});
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap);
return elements;
};
const shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
containingFrame: ExcalidrawFrameLikeElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
// elements were frame children handled in a prior pass). Bail out early —
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
// the resulting `slice()` calls overlap, duplicating elements.
if (indicesToMove.length === 0) {
return elements;
}
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number | undefined;
let trailingIndex: number | undefined;
if (direction === "left") {
if (containingFrame) {
leadingIndex = findIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup(
elements,
appState.editingGroupId,
);
if (!groupElements.length) {
return elements;
}
leadingIndex = elements.indexOf(groupElements[0]);
} else {
leadingIndex = 0;
}
trailingIndex = indicesToMove[indicesToMove.length - 1];
} else {
if (containingFrame) {
trailingIndex = findLastIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup(
elements,
appState.editingGroupId,
);
if (!groupElements.length) {
return elements;
}
trailingIndex = elements.indexOf(groupElements[groupElements.length - 1]);
} else {
trailingIndex = elements.length - 1;
}
leadingIndex = indicesToMove[0];
}
if (leadingIndex === -1) {
leadingIndex = 0;
}
const isValidIndex = (index: number | undefined): index is number => {
return isFiniteNumber(index) && index >= 0;
};
if (
!isValidIndex(leadingIndex) ||
!isValidIndex(trailingIndex) ||
leadingIndex > trailingIndex ||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
) {
return elements;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]);
}
}
const targetElements = Array.from(targetElementsMap.values());
const leadingElements = elements.slice(0, leadingIndex);
const trailingElements = elements.slice(trailingIndex + 1);
const nextElements =
direction === "left"
? [
...leadingElements,
...targetElements,
...displacedElements,
...trailingElements,
]
: [
...leadingElements,
...displacedElements,
...targetElements,
...trailingElements,
];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap);
return nextElements;
};
function shiftElementsAccountingForFrames(
allElements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
shiftFunction: (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
containingFrame: ExcalidrawFrameLikeElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | readonly ExcalidrawElement[],
) {
const elementsToMove = arrayToMap(
getSelectedElements(allElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
const frameAwareContiguousElementsToMove: {
regularElements: ExcalidrawElement[];
frameChildren: Map<ExcalidrawFrameLikeElement["id"], ExcalidrawElement[]>;
} = { regularElements: [], frameChildren: new Map() };
const fullySelectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
for (const element of allElements) {
if (elementsToMove.has(element.id) && isFrameLikeElement(element)) {
fullySelectedFrames.add(element.id);
}
}
for (const element of allElements) {
if (elementsToMove.has(element.id)) {
if (
isFrameLikeElement(element) ||
(element.frameId && fullySelectedFrames.has(element.frameId))
) {
frameAwareContiguousElementsToMove.regularElements.push(element);
} else if (!element.frameId) {
frameAwareContiguousElementsToMove.regularElements.push(element);
} else {
const frameChildren =
frameAwareContiguousElementsToMove.frameChildren.get(
element.frameId,
) || [];
frameChildren.push(element);
frameAwareContiguousElementsToMove.frameChildren.set(
element.frameId,
frameChildren,
);
}
}
}
let nextElements = allElements;
const frameChildrenSets = Array.from(
frameAwareContiguousElementsToMove.frameChildren.entries(),
);
for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction(
nextElements,
appState,
direction,
frameId,
children,
);
}
return shiftFunction(
nextElements,
appState,
direction,
null,
frameAwareContiguousElementsToMove.regularElements,
);
}
// public API
// -----------------------------------------------------------------------------
export const moveOneLeft = (
allElements: readonly ExcalidrawElement[],
appState: AppState,
scene: Scene,
) => {
return shiftElementsByOne(allElements, appState, "left", scene);
};
export const moveOneRight = (
allElements: readonly ExcalidrawElement[],
appState: AppState,
scene: Scene,
) => {
return shiftElementsByOne(allElements, appState, "right", scene);
};
export const moveAllLeft = (
allElements: readonly ExcalidrawElement[],
appState: AppState,
) => {
return shiftElementsAccountingForFrames(
allElements,
appState,
"left",
shiftElementsToEnd,
);
};
export const moveAllRight = (
allElements: readonly ExcalidrawElement[],
appState: AppState,
) => {
return shiftElementsAccountingForFrames(
allElements,
appState,
"right",
shiftElementsToEnd,
);
};

View File

@@ -0,0 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when clicked on arrow and enter pressed 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when double clicked 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should match styles for text editor 1`] = `
<textarea
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 6`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 5`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = `
"Online whiteboard
collaboration made
easy"
`;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,817 @@
import { KEYS, arrayToMap } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { actionWrapTextInContainer } from "@excalidraw/excalidraw/actions/actionBoundText";
import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
fireEvent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { bindBindingElement, updateBoundElements } from "../src/binding";
import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawLinearElement,
FixedPointBinding,
NonDeleted,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
describe("binding for simple arrows", () => {
describe("when both endpoints are bound inside the same element", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should create an `inside` binding", () => {
// Create a rectangle
UI.clickTool("rectangle");
mouse.reset();
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
// Draw arrow with endpoint inside the filled rectangle
UI.clickTool("arrow");
mouse.downAt(110, 110);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(110);
expect(arrow.y).toBe(110);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const startBinding = arrow.startBinding as FixedPointBinding;
expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
expect(startBinding.mode).toBe("inside");
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
expect(endBinding.mode).toBe("inside");
// Move the bindable
mouse.downAt(100, 150);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(290);
expect(arrow.y).toBe(70);
// Restore bindable
mouse.reset();
mouse.downAt(280, 110);
mouse.moveTo(130, 110);
mouse.up();
// Move the start point of the arrow to check if
// the behavior remains the same for old arrows
mouse.reset();
mouse.downAt(110, 110);
mouse.moveTo(120, 120);
mouse.up();
// Move the bindable again
mouse.reset();
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(290);
expect(arrow.y).toBe(70);
});
it("3+ point arrow should be dragged along with the bindable", () => {
// Create two rectangles as binding targets
const rectLeft = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectRight = API.createElement({
type: "rectangle",
x: 300,
y: 0,
width: 100,
height: 100,
});
// Create a non-elbowed arrow with inner points bound to different elements
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 50,
width: 200,
height: 0,
points: [
pointFrom(0, 0), // start point
pointFrom(50, -20), // first inner point
pointFrom(150, 20), // second inner point
pointFrom(200, 0), // end point
],
startBinding: {
elementId: rectLeft.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rectRight.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
API.setElements([rectLeft, rectRight, arrow]);
// Store original inner point positions
const originalInnerPoint1 = [...arrow.points[1]];
const originalInnerPoint2 = [...arrow.points[2]];
// Move the right rectangle down by 50 pixels
mouse.reset();
mouse.downAt(350, 50); // Click on the right rectangle
mouse.moveTo(350, 100); // Move it down
mouse.up();
// Verify that inner points did NOT move when bound to different elements
// The arrow should NOT translate inner points proportionally when only one end moves
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
});
});
describe("self-binding (both ends to the same element) single-click finalize", () => {
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
const INSIDE: [number, number] = [250, 250];
const ORBIT_LEFT: [number, number] = [187, 300];
const ORBIT_RIGHT: [number, number] = [413, 300];
const MIDDLE: [number, number] = [550, 100];
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.createElement("rectangle", {
x: 200,
y: 200,
width: 200,
height: 200,
});
});
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...start);
mouse.moveTo(...MIDDLE);
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
mouse.moveTo(...end);
mouse.clickAt(...end); // single click at the end
};
it("orbit -> orbit finalizes on a single click", () => {
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> orbit finalizes on a single click", () => {
drawSelfArrow(INSIDE, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
// ambiguous → must be confirmed with a second click, so still in progress
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
});
});
describe("when arrow is outside of shape", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should handle new arrow start point binding", () => {
// Create a rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rectangle = API.getSelectedElement();
// Create arrow with arrow tool
UI.clickTool("arrow");
mouse.downAt(205, 150); // Start close to rectangle
mouse.moveTo(250, 150); // End outside
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Arrow should have start binding to rectangle
expect(arrow.startBinding?.elementId).toBe(rectangle.id);
expect(arrow.startBinding?.mode).toBe("orbit"); // Default is orbit, not inside
expect(arrow.endBinding).toBeNull();
});
it("should handle new arrow end point binding", () => {
// Create a rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rectangle = API.getSelectedElement();
// Create arrow with end point in binding zone
UI.clickTool("arrow");
mouse.downAt(50, 150); // Start outside
mouse.moveTo(95, 95); // End near rectangle edge (should bind as orbit)
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Arrow should have end binding to rectangle
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
expect(arrow.endBinding?.mode).toBe("orbit");
expect(arrow.startBinding).toBeNull();
});
it.skip("should create orbit binding when one of the cursor is inside rectangle", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, {
fillStyle: "solid",
backgroundColor: "#a5d8ff",
});
// Draw arrow with endpoint inside the filled rectangle, since only
// filled bindables bind inside the shape
UI.clickTool("arrow");
mouse.downAt(10, 10);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBeCloseTo(85.75985931287957);
expect(arrow.height).toBeCloseTo(85.75985931288186);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
mouse.reset();
// Move the bindable
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBeCloseTo(234);
expect(arrow.height).toBeCloseTo(117);
// Restore bindable
mouse.reset();
mouse.downAt(280, 110);
mouse.moveTo(130, 110);
mouse.up();
// Move the arrow out
mouse.reset();
mouse.click(10, 10);
mouse.downAt(96.466, 96.466);
mouse.moveTo(50, 50);
mouse.up();
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
// Re-bind the arrow by moving the cursor inside the rectangle
mouse.reset();
mouse.downAt(50, 50);
mouse.moveTo(150, 150);
mouse.up();
// Check if the arrow is still on the outside
expect(arrow.width).toBeCloseTo(86, 0);
expect(arrow.height).toBeCloseTo(86, 0);
});
});
describe("additional binding behavior", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it(
"editing arrow and moving its head to bind it to element A, finalizing the" +
"editing by clicking on element A should end up selecting A",
async () => {
UI.createElement("rectangle", {
y: 0,
size: 100,
});
// Create arrow bound to rectangle
UI.clickTool("arrow");
mouse.down(50, -100);
mouse.up(0, 80);
// Edit arrow
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
// move arrow head
mouse.down();
mouse.up(0, 10);
expect(API.getSelectedElement().type).toBe("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.reset();
mouse.clickAt(-50, -50);
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(API.getSelectedElement().type).toBe("arrow");
// Edit arrow
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
mouse.reset();
mouse.clickAt(0, 0);
expect(h.state.selectedLinearElement).toBeNull();
expect(API.getSelectedElement().type).toBe("rectangle");
},
);
it("should unbind on bound element deletion", () => {
const rectangle = UI.createElement("rectangle", {
x: 60,
y: 0,
size: 100,
});
const arrow = UI.createElement("arrow", {
x: 0,
y: 5,
size: 70,
});
expect(arrow.endBinding?.elementId).toBe(rectangle.id);
mouse.select(rectangle);
expect(API.getSelectedElement().type).toBe("rectangle");
Keyboard.keyDown(KEYS.DELETE);
expect(arrow.endBinding).toBe(null);
});
it("should unbind arrow when arrow is resized", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(190, 250);
mouse.moveTo(220, 200);
mouse.moveTo(300, 200);
mouse.clickAt(300, 200);
mouse.moveTo(340, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = h.elements[h.elements.length - 1] as any;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
// Drag arrow off of bound rectangle range
const handles = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).se!;
const elX = handles[0] + handles[2] / 2;
const elY = handles[1] + handles[3] / 2;
// move first, as a real pointer would — the hover state from the spot
// where arrow creation finished must clear before the pointerdown
mouse.moveTo(elX, elY);
mouse.downAt(elX, elY);
mouse.moveTo(300, 400);
mouse.up();
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding).toBe(null);
});
it("should unbind arrow when arrow is rotated", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
width: 200,
height: 500,
});
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(190, 250);
mouse.moveTo(220, 200);
mouse.moveTo(300, 200);
mouse.clickAt(300, 200);
mouse.moveTo(350, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
const rotation = getTransformHandles(
arrow,
h.state.zoom,
arrayToMap(h.elements),
"mouse",
).rotation!;
const rotationHandleX = rotation[0] + rotation[2] / 2;
const rotationHandleY = rotation[1] + rotation[3] / 2;
mouse.reset();
mouse.down(rotationHandleX, rotationHandleY);
mouse.move(300, 400);
mouse.up();
expect(arrow.angle).toBeGreaterThan(0.7 * Math.PI);
expect(arrow.angle).toBeLessThan(1.3 * Math.PI);
expect(arrow.startBinding).toBeNull();
expect(arrow.endBinding).toBeNull();
});
it("should not unbind when duplicating via selection group", () => {
const rectLeft = UI.createElement("rectangle", {
x: 0,
width: 200,
height: 500,
});
const rectRight = UI.createElement("rectangle", {
x: 400,
y: 200,
width: 200,
height: 500,
});
const arrow = UI.createElement("arrow", {
x: 190,
y: 250,
width: 217,
height: 1,
});
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
mouse.downAt(-100, -100);
mouse.moveTo(0, 0);
mouse.moveTo(650, 750);
mouse.up(0, 0);
expect(API.getSelectedElements().length).toBe(3);
mouse.moveTo(5, 5);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(5, 5);
mouse.moveTo(1000, 1000);
mouse.up(0, 0);
expect(window.h.elements.length).toBe(6);
window.h.elements.forEach((element) => {
if (isLinearElement(element)) {
expect(element.startBinding).not.toBe(null);
expect(element.endBinding).not.toBe(null);
} else {
expect(element.boundElements).not.toBe(null);
}
});
});
});
});
describe("to text elements", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should update binding when text containerized", async () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
width: 100,
height: 100,
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "text1",
fixedPoint: [1, 0.5],
mode: "orbit",
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "text1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle1",
fixedPoint: [1, 0.5],
mode: "orbit",
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
text: "ola",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
],
});
API.setElements([rectangle1, arrow1, arrow2, text1]);
API.setSelectedElements([text1]);
expect(h.state.selectedElementIds[text1.id]).toBe(true);
API.executeAction(actionWrapTextInContainer);
// new text container will be placed before the text element
const container = h.elements.at(-2)!;
expect(container.type).toBe("rectangle");
expect(container.id).not.toBe(rectangle1.id);
expect(container).toEqual(
expect.objectContaining({
boundElements: expect.arrayContaining([
{
type: "text",
id: text1.id,
},
{
type: "arrow",
id: arrow1.id,
},
{
type: "arrow",
id: arrow2.id,
},
]),
}),
);
expect(arrow1.startBinding?.elementId).toBe(rectangle1.id);
expect(arrow1.endBinding?.elementId).toBe(container.id);
expect(arrow2.startBinding?.elementId).toBe(container.id);
expect(arrow2.endBinding?.elementId).toBe(rectangle1.id);
});
it("should keep binding on text update", async () => {
const text = API.createElement({
type: "text",
text: "ola",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 65,
});
expect(arrow.endBinding?.elementId).toBe(text.id);
// delete text element by submitting empty text
// -------------------------------------------------------------------------
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id);
});
it("should unbind on text element deletion by submitting empty text", async () => {
const text = API.createElement({
type: "text",
text: "¡olá!",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
const arrow = UI.createElement("arrow", {
x: 0,
y: 0,
size: 65,
});
expect(arrow.endBinding?.elementId).toBe(text.id);
// edit text element and submit
// -------------------------------------------------------------------------
UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50);
const editor = await getTextEditor();
fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
expect(arrow.endBinding).toBe(null);
});
});
});
describe("binding to a point-like (sub-pixel) element", () => {
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
// Regression: binding to a zero/near-zero-size element used to yield an
// enormous fixedPoint ratio (offset / ~0), which exploded into an arrow
// hundreds of thousands of px wide once the element was resized to a normal
// size
it.each([
["simple", false],
["elbow", true],
])(
"%s arrow binds to the center and stays put when the element grows",
(_label, elbowed) => {
const rect = API.createElement({
type: "rectangle",
x: 200,
y: 50,
width: 0.00005,
height: 0.00005,
}) as NonDeleted<ExcalidrawBindableElement>;
const arrow = API.createElement({
type: "arrow",
elbowed,
x: 0,
y: 50,
width: 195,
height: 0,
points: [pointFrom(0, 0), pointFrom(195, 0)],
}) as NonDeleted<ExcalidrawArrowElement>;
API.setElements([rect, arrow]);
bindBindingElement(arrow, rect, "orbit", "end", h.scene);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.elementId).toBe(rect.id);
// Point-like element => bind to center (0.5 is normalized to 0.5001)
expect(endBinding.fixedPoint[0]).toBeCloseTo(0.5001);
expect(endBinding.fixedPoint[1]).toBeCloseTo(0.5001);
// Grow the element to a normal size and let bound arrows follow
h.scene.mutateElement(rect, { width: 300, height: 200 });
updateBoundElements(rect, h.scene);
// The arrow endpoint must track the element (its center), not fly off
expect(Math.abs(arrow.width)).toBeLessThan(1000);
expect(Math.abs(arrow.height)).toBeLessThan(1000);
},
);
});

View File

@@ -0,0 +1,209 @@
import { pointFrom } from "@excalidraw/math";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { LocalPoint } from "@excalidraw/math";
import {
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
const _ce = ({
x,
y,
w,
h,
a,
t,
}: {
x: number;
y: number;
w: number;
h: number;
a?: number;
t?: string;
}) =>
({
type: t || "rectangle",
strokeColor: "#000",
backgroundColor: "#000",
fillStyle: "solid",
strokeWidth: 1,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 0,
opacity: 1,
x,
y,
width: w,
height: h,
angle: a,
} as ExcalidrawElement);
describe("getElementAbsoluteCoords", () => {
it("test x1 coordinate", () => {
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
const [x1] = getElementAbsoluteCoords(element, arrayToMap([element]));
expect(x1).toEqual(10);
});
it("test x2 coordinate", () => {
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element]));
expect(x2).toEqual(20);
});
it("test y1 coordinate", () => {
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element]));
expect(y1).toEqual(10);
});
it("test y2 coordinate", () => {
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element]));
expect(y2).toEqual(20);
});
});
describe("getElementBounds", () => {
it("rectangle", () => {
const element = _ce({
x: 40,
y: 30,
w: 20,
h: 10,
a: Math.PI / 4,
t: "rectangle",
});
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(39.39339828220179);
expect(y1).toEqual(24.393398282201787);
expect(x2).toEqual(60.60660171779821);
expect(y2).toEqual(45.60660171779821);
});
it("diamond", () => {
const element = _ce({
x: 40,
y: 30,
w: 20,
h: 10,
a: Math.PI / 4,
t: "diamond",
});
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(42.928932188134524);
expect(y1).toEqual(27.928932188134524);
expect(x2).toEqual(57.071067811865476);
expect(y2).toEqual(42.071067811865476);
});
it("ellipse", () => {
const element = _ce({
x: 40,
y: 30,
w: 20,
h: 10,
a: Math.PI / 4,
t: "ellipse",
});
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(42.09430584957905);
expect(y1).toEqual(27.09430584957905);
expect(x2).toEqual(57.90569415042095);
expect(y2).toEqual(42.90569415042095);
});
it("curved line", () => {
const element = {
..._ce({
t: "line",
x: 449.58203125,
y: 186.0625,
w: 170.12890625,
h: 92.48828125,
a: 0.6447741904932416,
}),
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(67.33984375, 92.48828125),
pointFrom<LocalPoint>(-102.7890625, 52.15625),
],
} as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(360.9291017525165);
expect(y1).toEqual(185.24770129343722);
expect(x2).toEqual(481.4815539037601);
expect(y2).toEqual(319.8162855827246);
});
});
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
import { arrayToMap, reseed } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});
describe("hitElementItself cache", () => {
beforeEach(async () => {
// reset cache
hitElementItself({
point: pointFrom<GlobalPoint>(50, 50),
element: API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
}),
threshold: Infinity,
elementsMap: new Map([]),
});
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("reuses cached result when threshold increases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
distanceSpy.mockRestore();
});
it("does not reuse cache when threshold decreases", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(105, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 6,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("invalidates cache when element version changes", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "#ffffff",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(100.5, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(1);
const movedElement = {
...element,
version: element.version + 1,
versionNonce: element.versionNonce + 1,
};
expect(
hitElementItself({
point,
element: movedElement,
threshold: 1,
elementsMap,
}),
).toBe(true);
expect(distanceSpy).toHaveBeenCalledTimes(2);
distanceSpy.mockRestore();
});
it("override does not affect caching", () => {
const element = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
});
const elementsMap = arrayToMap([element]);
const point = pointFrom<GlobalPoint>(50, 50);
const distanceSpy = jest.spyOn(distance, "distanceToElement");
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
}),
).toBe(false);
expect(distanceSpy).toHaveBeenCalledTimes(1);
expect(
hitElementItself({
point,
element,
threshold: 10,
elementsMap,
overrideShouldTestInside: true,
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,363 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, cloneJSON } from "@excalidraw/common";
import {
Excalidraw,
exportToCanvas,
exportToSvg,
} from "@excalidraw/excalidraw";
import {
actionFlipHorizontal,
actionFlipVertical,
} from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
GlobalTestState,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { duplicateElement } from "../src/duplicate";
import type {
ExcalidrawImageElement,
ImageCrop,
NonDeleted,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
beforeEach(async () => {
unmountComponent();
mouse.reset();
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
API.setAppState({
zoom: {
value: 1 as NormalizedZoomValue,
},
});
const image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
API.setAppState({
selectedElementIds: {
[image.id]: true,
},
});
});
const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => {
const initialWidth = image.width;
const initialHeight = image.height;
const scale = 1 + Math.random() * 5;
return {
naturalWidth: initialWidth * scale,
naturalHeight: initialHeight * scale,
};
};
const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
(Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => {
const propA = cropA[key];
const propB = cropB[key];
expect(propA as number).toBeCloseTo(propB as number);
});
};
describe("Enter and leave the crop editor", () => {
it("enter the editor by double clicking", () => {
const image = h.elements[0];
expect(h.state.croppingElementId).toBe(null);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
expect(h.state.croppingElementId).toBe(image.id);
});
it("enter the editor by pressing enter", () => {
const image = h.elements[0];
expect(h.state.croppingElementId).toBe(null);
Keyboard.keyDown(KEYS.ENTER);
expect(h.state.croppingElementId).not.toBe(null);
expect(h.state.croppingElementId).toBe(image.id);
});
it("leave the editor by clicking outside", () => {
const image = h.elements[0];
Keyboard.keyDown(KEYS.ENTER);
expect(h.state.croppingElementId).not.toBe(null);
mouse.click(image.x - 20, image.y - 20);
expect(h.state.croppingElementId).toBe(null);
});
it("leave the editor by pressing escape", () => {
const image = h.elements[0];
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
Keyboard.keyDown(KEYS.ESCAPE);
expect(h.state.croppingElementId).toBe(null);
});
});
describe("Crop an image", () => {
it("Cropping changes the dimension", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]);
expect(image.width).toBeLessThan(initialWidth);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]);
expect(image.height).toBeLessThan(initialHeight);
});
it("Cropping has minimal sizes", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]);
expect(image.width).toBeLessThan(initialWidth);
expect(image.width).toBeGreaterThan(0);
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]);
expect(image.height).toBeLessThan(initialHeight);
expect(image.height).toBeGreaterThan(0);
});
it("Preserve aspect ratio", async () => {
let image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 3, 0]);
let resizedWidth = image.width;
let resizedHeight = image.height;
// max height, cropping should not change anything
UI.crop(
image,
"w",
naturalWidth,
naturalHeight,
[-initialWidth / 3, 0],
true,
);
expect(image.width).toBeCloseTo(resizedWidth, 10);
expect(image.height).toBeCloseTo(resizedHeight, 10);
// re-crop to initial state
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
// change crop height and width
UI.crop(image, "s", naturalWidth, naturalHeight, [0, -initialHeight / 2]);
UI.crop(image, "e", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
resizedWidth = image.width;
resizedHeight = image.height;
// test corner handle aspect ratio preserving
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
// reset
image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
API.setAppState({
selectedElementIds: {
[image.id]: true,
},
});
// 50 x 50 square
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
expect(image.width).toBeCloseTo(image.height);
// image is at the corner, not space to its right to expand, should not be able to resize
expect(image.height).toBeCloseTo(50);
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
expect(image.width).toBeCloseTo(image.height);
// max height should be reached
expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBeCloseTo(initialHeight);
});
});
describe("Cropping and other features", async () => {
it("Cropping works independently of duplication", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const duplicatedImage = duplicateElement(null, new Map(), image);
act(() => {
h.app.scene.insertElement(duplicatedImage);
});
expect(duplicatedImage.width).toBe(image.width);
expect(duplicatedImage.height).toBe(image.height);
UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [
-initialWidth / 2,
-initialHeight / 2,
]);
expect(duplicatedImage.width).toBe(initialWidth);
expect(duplicatedImage.height).toBe(initialHeight);
const resizedWidth = image.width;
const resizedHeight = image.height;
expect(image.width).not.toBe(duplicatedImage.width);
expect(image.height).not.toBe(duplicatedImage.height);
UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [
-initialWidth / 1.5,
-initialHeight / 1.5,
]);
expect(duplicatedImage.width).not.toBe(initialWidth);
expect(image.width).toBe(resizedWidth);
expect(duplicatedImage.height).not.toBe(initialHeight);
expect(image.height).toBe(resizedHeight);
});
it("Resizing should not affect crop", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
const cropBeforeResizing = image.crop;
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
expect(cropBeforeResizing).not.toBe(null);
UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
UI.resize(image, "s", [0, -100]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
UI.resize(image, "ne", [-50, -50]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
});
it("Flipping does not change crop", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const cropBeforeResizing = image.crop;
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
API.executeAction(actionFlipHorizontal);
expect(image.crop).toBe(cropBeforeResizing);
compareCrops(cropBeforeResizingCloned, image.crop!);
API.executeAction(actionFlipVertical);
expect(image.crop).toBe(cropBeforeResizing);
compareCrops(cropBeforeResizingCloned, image.crop!);
});
it("Exports should preserve crops", async () => {
const image = h.elements[0] as NonDeleted<ExcalidrawImageElement>;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 4,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height");
expect(svgWidth).toBeDefined();
expect(svgHeight).toBeDefined();
const exportedSvgRatio = Number(svgWidth) / Number(svgHeight);
expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio);
});
});

View File

@@ -0,0 +1,853 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { arrayToMap } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import type {
ExcalidrawTextElement,
FractionalIndex,
SceneElementsMap,
} from "@excalidraw/element/types";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
import { mutateElement, newElementWith } from "../src/mutateElement";
describe("ElementsDelta", () => {
describe("visible changes", () => {
const apply = (before: SceneElementsMap, after: SceneElementsMap) =>
ElementsDelta.calculate(before, after).applyTo(before, before, {
excludedProperties: new Set(["version", "versionNonce"]),
});
const boundText = (
type: "rectangle" | "stickynote" | "arrow",
text = "",
) => {
const container = API.createElement({
type,
id: "container",
index: "a0" as FractionalIndex,
width: 250,
height: type === "arrow" ? 0 : 250,
baseHeight: type === "stickynote" ? 250 : undefined,
points:
type === "arrow" ? [pointFrom(0, 0), pointFrom(250, 0)] : undefined,
boundElements: [{ id: "label", type: "text" }],
});
const label: ExcalidrawTextElement = {
...API.createElement({
type: "text",
id: "label",
index: "a1" as FractionalIndex,
containerId: container.id,
fontSize: 20,
textAlign: "center",
verticalAlign: "middle",
}),
// API.createElement substitutes "test" for an empty string.
text,
originalText: text,
};
const before = arrayToMap([container, label]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { boundElements: [] }),
newElementWith(label, { isDeleted: true }),
]) as SceneElementsMap;
return { container, label, before, after };
};
it.each(["rectangle", "stickynote"] as const)(
"ignores empty-label deletion and restoration on a %s",
(type) => {
const { before, after, container, label } = boundText(type);
const [deleted, deletionIsVisible] = apply(before, after);
expect(deletionIsVisible).toBe(false);
expect(deleted.get(label.id)?.isDeleted).toBe(true);
expect(deleted.get(container.id)?.boundElements).toEqual([]);
const [restored, restorationIsVisible] = apply(after, before);
expect(restorationIsVisible).toBe(false);
expect(restored.get(label.id)?.isDeleted).toBe(false);
expect(restored.get(container.id)?.boundElements).toEqual([
{ id: label.id, type: "text" },
]);
},
);
it("keeps deletion and restoration of nonempty labels visible", () => {
const { before, after } = boundText("stickynote", "hello");
expect(apply(before, after)[1]).toBe(true);
expect(apply(after, before)[1]).toBe(true);
});
it.each(["excluded", "already applied"])(
"ignores %s properties when deciding visibility",
(mode) => {
const element = API.createElement({
type: "rectangle",
index: "a0" as FractionalIndex,
});
const before = arrayToMap([element]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { strokeColor: "red" }),
]) as SceneElementsMap;
const delta = ElementsDelta.calculate(before, after);
const current = mode === "excluded" ? before : after;
const [elements, isVisible] = delta.applyTo(current, current, {
excludedProperties: new Set([
"version",
"versionNonce",
...(mode === "excluded" ? ["strokeColor" as const] : []),
]),
});
expect(elements.get(element.id)?.strokeColor).toBe(
current.get(element.id)?.strokeColor,
);
expect(isVisible).toBe(false);
},
);
it("keeps snapshot restoration visible for a metadata-only update", () => {
const element = API.createElement({
type: "rectangle",
index: "a0" as FractionalIndex,
});
const before = arrayToMap([element]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { version: element.version + 1 }),
]) as SceneElementsMap;
const delta = ElementsDelta.calculate(before, after);
const [elements, isVisible] = delta.applyTo(
new Map() as SceneElementsMap,
before,
);
expect(elements.get(element.id)?.isDeleted).toBe(false);
expect(isVisible).toBe(true);
});
it("keeps an arrow's empty-label gap visible", () => {
const { before, after } = boundText("arrow");
expect(apply(before, after)[1]).toBe(true);
expect(apply(after, before)[1]).toBe(true);
});
it("detects a container resized by restoring an empty label", () => {
const { container, label } = boundText("rectangle");
const smallContainer = newElementWith(container, {
width: 20,
height: 10,
boundElements: [],
});
const before = arrayToMap([
smallContainer,
newElementWith(label, { isDeleted: true }),
]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(smallContainer, {
boundElements: [{ id: label.id, type: "text" }],
}),
newElementWith(label, { isDeleted: false }),
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.height).toBeGreaterThan(10);
expect(isVisible).toBe(true);
});
it("detects a bound label repositioned by a container's redraw", () => {
const { container, label } = boundText("rectangle", "hello");
const misplacedLabel = newElementWith(label, { x: -100, y: -100 });
const before = arrayToMap([
container,
misplacedLabel,
]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { version: container.version + 1 }),
misplacedLabel,
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.height).toBe(container.height);
expect(elements.get(label.id)?.x).not.toBe(-100);
expect(isVisible).toBe(true);
});
it("detects a bound arrow repositioned by a container's redraw", () => {
const container = API.createElement({
type: "rectangle",
id: "container",
index: "a0" as FractionalIndex,
boundElements: [{ id: "arrow", type: "arrow" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow",
index: "a1" as FractionalIndex,
x: 200,
y: 50,
points: [pointFrom(0, 0), pointFrom(100, 0)],
startBinding: {
elementId: container.id,
mode: "orbit",
fixedPoint: [1, 0.5],
},
});
const before = arrayToMap([container, arrow]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(container, { version: container.version + 1 }),
arrow,
]) as SceneElementsMap;
const [elements, isVisible] = apply(before, after);
expect(elements.get(container.id)?.width).toBe(container.width);
expect(elements.get(arrow.id)?.x).not.toBe(200);
expect(isVisible).toBe(true);
});
it.each(["test", "development", "production"])(
"guards against untracked layout mutations in %s mode",
(mode) => {
const element = API.createElement({
type: "rectangle",
id: "changed",
index: "a0" as FractionalIndex,
});
const unrelated = API.createElement({
type: "rectangle",
id: "unrelated",
index: "a1" as FractionalIndex,
});
const before = arrayToMap([element, unrelated]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(element, { strokeColor: "red" }),
unrelated,
]) as SceneElementsMap;
// Simulate a future layout dependency missing from idsToCheck.
// Mutate the shared instance so the guard must snapshot version values.
const redraw = vi
.spyOn(ElementsDelta, "redrawElements")
.mockImplementationOnce((elements) => {
mutateElement(elements.get(unrelated.id)!, elements, {
x: unrelated.x + 1,
});
return elements;
});
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
vi.stubEnv("MODE", mode);
try {
if (mode === "production") {
expect(apply(before, after)[1]).toBe(true);
expect(consoleError).not.toHaveBeenCalled();
} else {
expect(() => apply(before, after)).toThrow(
'Redrawn element "unrelated" is missing from idsToCheck',
);
}
} finally {
redraw.mockRestore();
consoleError.mockRestore();
vi.unstubAllEnvs();
}
},
);
it("keeps an empty text element's arrow bindings visible", () => {
const label: ExcalidrawTextElement = {
...API.createElement({
type: "text",
id: "label",
index: "a0" as FractionalIndex,
boundElements: [{ id: "arrow", type: "arrow" }],
}),
text: "",
originalText: "",
};
const arrow = API.createElement({
type: "arrow",
id: "arrow",
index: "a1" as FractionalIndex,
startBinding: {
elementId: label.id,
mode: "orbit",
fixedPoint: [1, 0.5],
},
});
const before = arrayToMap([label, arrow]) as SceneElementsMap;
const after = arrayToMap([
newElementWith(label, { isDeleted: true, boundElements: [] }),
newElementWith(arrow, { startBinding: null }),
]) as SceneElementsMap;
expect(apply(before, after)[1]).toBe(true);
});
});
describe("elements delta calculation", () => {
it("should not throw when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = {
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {},
activeLockedId: null,
};
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElement: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElement,
};
const prevAppState2: ObservedAppState = {
selectedLinearElement: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElement,
name,
...commonAppState,
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedElementIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: { id5: true, id2: true, id4: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id1: true,
id2: true,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: { id4: true, id2: true, id5: true },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedElementIds: {
id3: true,
id2: true,
id1: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
it("should maintain stable order for selectedGroupIds", () => {
const commonAppState = {
name: "",
viewBackgroundColor: "#ffffff",
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
const prevAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
};
const nextAppState1: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id0: true,
id1: true,
id2: false,
id3: true,
},
};
const prevAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
};
const nextAppState2: ObservedAppState = {
...commonAppState,
selectedGroupIds: {
id3: true,
id2: false,
id1: true,
id0: true,
},
};
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
});

View File

@@ -0,0 +1,128 @@
import {
distributeHorizontally,
distributeVertically,
} from "@excalidraw/excalidraw/actions";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
unmountComponent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
const mouse = new Pointer("mouse");
// Scenario: three rectangles that will be distributed with gaps
const createAndSelectThreeRectanglesWithGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(300, 300);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
// Scenario: three rectangles that will be distributed by their centers
const createAndSelectThreeRectanglesWithoutGap = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(200, 200);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
mouse.reset();
// Last rectangle is selected by default
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(0, 10);
mouse.click(10, 0);
});
};
describe("distributing", () => {
beforeEach(async () => {
unmountComponent();
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
});

View File

@@ -0,0 +1,865 @@
import { pointFrom } from "@excalidraw/math";
import {
FONT_FAMILY,
ORIG_ID,
ROUNDNESS,
isPrimitive,
getUpdatedTimestamp,
} from "@excalidraw/common";
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { LocalPoint } from "@excalidraw/math";
import {
deepCopyElement,
duplicateElement,
duplicateElements,
} from "../src/duplicate";
import type { ExcalidrawLinearElement } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
expect(clone[key]).not.toBe(source[key]);
if (source[key]) {
assertCloneObjects(source[key], clone[key]);
}
}
}
};
describe("duplicating single elements", () => {
it.each([123, 0, null])(
"preserves created=%s on a deep copy and resets it for a new instance",
(created) => {
const element = API.createElement({ type: "rectangle", created });
const cloned = deepCopyElement(element);
const duplicate = duplicateElement(null, new Map(), element);
expect(cloned).toEqual(element);
expect(duplicate).toMatchObject({
created: getUpdatedTimestamp(),
version: element.version,
versionNonce: element.versionNonce,
});
expect(duplicate.id).not.toBe(element.id);
expect(element.created).toBe(created);
},
);
it("clones arrow element", () => {
const element = API.createElement({
type: "arrow",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
roughness: 1,
opacity: 100,
});
// @ts-ignore
element.__proto__ = { hello: "world" };
mutateElement(element, new Map(), {
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
});
const copy = duplicateElement(null, new Map(), element, true);
assertCloneObjects(element, copy);
// assert we clone the object's prototype
// @ts-ignore
expect(copy.__proto__).toEqual({ hello: "world" });
expect(copy.hasOwnProperty("hello")).toBe(false);
expect(copy.points).not.toBe(element.points);
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(copy.seed).not.toBe(element.seed);
expect(typeof copy.seed).toBe("number");
expect(copy).toEqual({
...element,
id: copy.id,
seed: copy.seed,
version: copy.version,
versionNonce: copy.versionNonce,
});
});
it("clones text element", () => {
const element = API.createElement({
type: "text",
x: 0,
y: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
roundness: null,
roughness: 1,
opacity: 100,
text: "hello",
fontSize: 20,
fontFamily: FONT_FAMILY.Virgil,
textAlign: "left",
verticalAlign: "top",
});
const copy = duplicateElement(null, new Map(), element);
assertCloneObjects(element, copy);
expect(copy).not.toHaveProperty("points");
expect(copy).not.toHaveProperty("shape");
expect(copy.id).not.toBe(element.id);
expect(typeof copy.id).toBe("string");
expect(typeof copy.seed).toBe("number");
});
});
describe("duplicating multiple elements", () => {
it("duplicateElements should clone bindings", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
{ id: "arrow1", type: "arrow" },
{ id: "arrow2", type: "arrow" },
{ id: "text1", type: "text" },
],
});
const text1 = API.createElement({
type: "text",
id: "text1",
containerId: "rectangle1",
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
endBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
boundElements: [{ id: "text2", type: "text" }],
});
const text2 = API.createElement({
type: "text",
id: "text2",
containerId: "arrow2",
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
const { duplicatedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
// generic id in-equality checks
// --------------------------------------------------------------------------
expect(origElements.map((e) => e.type)).toEqual(
duplicatedElements.map((e) => e.type),
);
origElements.forEach((origElement, idx) => {
const clonedElement = duplicatedElements[idx];
expect(origElement).toEqual(
expect.objectContaining({
id: expect.not.stringMatching(clonedElement.id),
type: clonedElement.type,
}),
);
if ("containerId" in origElement) {
expect(origElement.containerId).not.toBe(
(clonedElement as any).containerId,
);
}
if ("endBinding" in origElement) {
if (origElement.endBinding) {
expect(origElement.endBinding.elementId).not.toBe(
(clonedElement as any).endBinding?.elementId,
);
} else {
expect((clonedElement as any).endBinding).toBeNull();
}
}
if ("startBinding" in origElement) {
if (origElement.startBinding) {
expect(origElement.startBinding.elementId).not.toBe(
(clonedElement as any).startBinding?.elementId,
);
} else {
expect((clonedElement as any).startBinding).toBeNull();
}
}
});
// --------------------------------------------------------------------------
const clonedArrows = duplicatedElements.filter(
(e) => e.type === "arrow",
) as ExcalidrawLinearElement[];
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
duplicatedElements as any as typeof origElements;
expect(clonedText1.containerId).toBe(clonedRectangle.id);
expect(
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
).toEqual(
expect.objectContaining({
id: clonedText1.id,
type: clonedText1.type,
}),
);
expect(clonedRectangle.type).toBe("rectangle");
clonedArrows.forEach((arrow) => {
expect(
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
).toEqual(
expect.objectContaining({
id: arrow.id,
type: arrow.type,
}),
);
if (arrow.endBinding) {
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
}
if (arrow.startBinding) {
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
}
});
expect(clonedArrow2.boundElements).toEqual([
{ type: "text", id: clonedArrowLabel.id },
]);
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
});
it("should remove id references of elements that aren't found", () => {
const rectangle1 = API.createElement({
type: "rectangle",
id: "rectangle1",
boundElements: [
// should keep
{ id: "arrow1", type: "arrow" },
// should drop
{ id: "arrow-not-exists", type: "arrow" },
// should drop
{ id: "text-not-exists", type: "text" },
],
});
const arrow1 = API.createElement({
type: "arrow",
id: "arrow1",
startBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
const text1 = API.createElement({
type: "text",
id: "text1",
containerId: "rectangle-not-exists",
});
const arrow2 = API.createElement({
type: "arrow",
id: "arrow2",
startBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle-not-exists",
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
const arrow3 = API.createElement({
type: "arrow",
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle1",
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
// -------------------------------------------------------------------------
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
const duplicatedElements = duplicateElements({
type: "everything",
elements: origElements,
}).duplicatedElements as any as typeof origElements;
const [
clonedRectangle,
clonedText1,
clonedArrow1,
clonedArrow2,
clonedArrow3,
] = duplicatedElements;
expect(clonedRectangle.boundElements).toEqual([
{ id: clonedArrow1.id, type: "arrow" },
]);
expect(clonedText1.containerId).toBe(null);
expect(clonedArrow2.startBinding).toEqual({
...arrow2.startBinding,
elementId: clonedRectangle.id,
});
expect(clonedArrow2.endBinding).toBe(null);
expect(clonedArrow3.startBinding).toBe(null);
expect(clonedArrow3.endBinding).toEqual({
...arrow3.endBinding,
elementId: clonedRectangle.id,
});
});
describe("should duplicate all group ids", () => {
it("should regenerate all group ids and keep them consistent across elements", () => {
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
groupIds: ["g2", "g1"],
});
const origElements = [rectangle1, rectangle2, rectangle3] as const;
const { duplicatedElements } = duplicateElements({
type: "everything",
elements: origElements,
});
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
duplicatedElements;
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
});
it("should keep and regenerate ids of groups even if invalid", () => {
// lone element shouldn't be able to be grouped with itself,
// but hard to check against in a performant way so we ignore it
const rectangle1 = API.createElement({
type: "rectangle",
groupIds: ["g1"],
});
const {
duplicatedElements: [clonedRectangle1],
} = duplicateElements({ type: "everything", elements: [rectangle1] });
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
});
});
});
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);
});
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", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle1]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
API.setSelectedElements([rectangle3]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle3);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ id: rectangle2.id },
{ id: rectangle3.id },
]);
});
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["group1"],
});
const rectangle2 = API.createElement({
type: "rectangle",
x: 10,
y: 10,
groupIds: ["group1"],
});
const rectangle3 = API.createElement({
type: "rectangle",
x: 20,
y: 20,
groupIds: ["group1"],
});
API.setElements([rectangle1, rectangle2, rectangle3]);
mouse.select(rectangle1);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
});
assertElements(h.elements, [
{ id: rectangle1.id },
{ id: rectangle2.id },
{ id: rectangle3.id },
{ [ORIG_ID]: rectangle1.id, selected: true },
{ [ORIG_ID]: rectangle2.id, selected: true },
{ [ORIG_ID]: rectangle3.id, selected: true },
]);
});
it("alt-duplicating text container (in-order)", async () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([rectangle, text]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
},
]);
});
it("alt-duplicating text container (out-of-order)", async () => {
const [rectangle, text] = API.createTextContainer();
API.setElements([text, rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(rectangle.x + 5, rectangle.y + 5);
mouse.up(rectangle.x + 15, rectangle.y + 15);
});
assertElements(h.elements, [
{ id: rectangle.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
},
]);
});
it("alt-duplicating labeled arrows (in-order)", async () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id,
},
]);
expect(h.state.selectedLinearElement).toEqual(
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
);
});
it("alt-duplicating labeled arrows (out-of-order)", async () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([text, arrow]);
API.setSelectedElements([arrow]);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.down(arrow.x + 5, arrow.y + 5);
mouse.up(arrow.x + 15, arrow.y + 15);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(arrow.id)?.id,
},
]);
});
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
const rect = UI.createElement("rectangle", {
x: 0,
y: 0,
width: 100,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -100,
y: 50,
width: 115,
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

@@ -0,0 +1,416 @@
import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
fireEvent,
GlobalTestState,
queryByTestId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import { Scene } from "../src/Scene";
import type {
ExcalidrawArrowElement,
ExcalidrawBindableElement,
ExcalidrawElbowArrowElement,
NonDeleted,
} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
describe("elbow arrow segment move", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can move the second segment of a fully connected elbow arrow", () => {
UI.createElement("rectangle", {
x: -100,
y: -50,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 200,
y: 150,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(200, 200);
mouse.click();
mouse.reset();
mouse.moveTo(100, 100);
mouse.down();
mouse.moveTo(115, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
expect(arrow.fixedSegments?.length).toBe(1);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
mouse.reset();
mouse.moveTo(105, 74.275);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[110, 0],
[110, 200],
[190, 200],
]);
});
it("can move the second segment of an unconnected elbow arrow", () => {
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(0, 0);
mouse.click();
mouse.moveTo(250, 200);
mouse.click();
mouse.reset();
mouse.moveTo(125, 100);
mouse.down();
mouse.moveTo(130, 100);
mouse.up();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[130, 0],
[130, 200],
[250, 200],
]);
mouse.reset();
mouse.moveTo(130, 100);
mouse.doubleClick();
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[125, 0],
[125, 200],
[250, 200],
]);
});
});
describe("elbow arrow routing", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can properly generate orthogonal arrow points", () => {
const scene = new Scene();
const arrow = API.createElement({
type: "arrow",
elbowed: true,
}) as NonDeleted<ExcalidrawElbowArrowElement>;
scene.insertElement(arrow);
h.app.scene.mutateElement(arrow, {
points: [
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
],
});
expect(arrow.points).toEqual([
[0, 0],
[0, 100],
[90, 100],
[90, 200],
]);
expect(arrow.x).toEqual(-45);
expect(arrow.y).toEqual(-100.1);
expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200);
});
it("can generate proper points for bound elbow arrow", () => {
const rectangle1 = API.createElement({
type: "rectangle",
x: -150,
y: -150,
width: 100,
height: 100,
}) as NonDeleted<ExcalidrawBindableElement>;
const rectangle2 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 100,
height: 100,
}) as NonDeleted<ExcalidrawBindableElement>;
const arrow = API.createElement({
type: "arrow",
elbowed: true,
x: -45,
y: -100.1,
width: 90,
height: 200,
points: [pointFrom(0, 0), pointFrom(90, 200)],
}) as NonDeleted<ExcalidrawElbowArrowElement>;
API.setElements([rectangle1, rectangle2, arrow]);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
h.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
});
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
]);
});
});
describe("elbow arrow ui", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
});
it("can follow bound shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset();
mouse.moveTo(-53, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(arrow.type).toBe("arrow");
expect(arrow.elbowed).toBe(true);
expect(arrow.points).toCloselyEqualPoints([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
]);
});
it("can follow bound rotated shapes", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
UI.updateInput(inputAngle, String("40"));
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0],
[36, 0],
[36, 90],
[28, 90],
[28, 164],
[101, 164],
]);
});
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionSelectAll);
});
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(6);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[2] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toCloselyEqualPoints([
[0, 0],
[39, 0],
[39, 200],
[78, 200],
]);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
});
it("changes arrow shape to unbind variant if only the connected elbow arrow is duplicated", async () => {
UI.createElement("rectangle", {
x: -150,
y: -150,
width: 100,
height: 100,
});
UI.createElement("rectangle", {
x: 50,
y: 50,
width: 100,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-53, -99);
mouse.click();
mouse.moveTo(53, 99);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
const originalArrowId = arrow.id;
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
expect(h.elements.length).toEqual(4);
const duplicatedArrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawArrowElement;
expect(duplicatedArrow.id).not.toBe(originalArrowId);
expect(duplicatedArrow.type).toBe("arrow");
expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toCloselyEqualPoints([
[0, 0],
[0, 100],
[78, 100],
[78, 200],
]);
});
});

View File

@@ -0,0 +1,233 @@
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
expectedStart: 90,
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
expectedStart: 120,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
expectedStart: 150,
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should parse YouTube URLs with timestamp in time format", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
expectedStart: 90, // 1*60 + 30
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
expectedStart: 165, // 2*60 + 45
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
expectedStart: 3723, // 1*3600 + 2*60 + 3
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
expectedStart: 45,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
expectedStart: 300, // 5*60
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
expectedStart: 7200, // 2*3600
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should handle YouTube URLs without timestamps", () => {
const testCases = [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
];
testCases.forEach((url) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).not.toContain("start=");
}
});
});
it("should handle YouTube shorts URLs with timestamps", () => {
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=30");
}
// Shorts should have portrait aspect ratio
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
});
it("should handle playlist URLs with timestamps", () => {
const url =
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=60");
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
}
});
it("should handle malformed or edge case timestamps", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
expectedStart: 0, // Invalid timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
expectedStart: 0, // Empty timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
expectedStart: 0, // Zero timestamp should be handled
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
if (expectedStart === 0) {
expect(result.link).not.toContain("start=");
} else {
expect(result.link).toContain(`start=${expectedStart}`);
}
}
});
});
it("should preserve other URL parameters", () => {
const url =
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=90");
expect(result.link).toContain("enablejsapi=1");
}
});
});
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});

View File

@@ -0,0 +1,683 @@
import { KEYS, reseed } from "@excalidraw/common";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
unmountComponent();
const { h } = window;
const mouse = new Pointer("mouse");
beforeEach(async () => {
localStorage.clear();
reseed(7);
mouse.reset();
await render(<Excalidraw handleKeyboardGlobally={true} />);
h.state.width = 1000;
h.state.height = 1000;
// The bounds of hand-drawn linear elements may change after flipping, so
// removing this style for testing
UI.clickTool("arrow");
UI.clickByTitle("Architect");
UI.clickTool("selection");
});
describe("flow chart creation", () => {
beforeEach(() => {
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
});
// multiple at once
it("create multiple successor nodes at once", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(5);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
});
it("when directions are changed, only the last same directions will apply", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(7);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
});
it("when escaped, no nodes will be created", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_UP);
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyPress(KEYS.ESCAPE);
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(1);
});
it("create nodes one at a time", () => {
const initialNode = h.elements[0];
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(3);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
const firstChildNode = h.elements.filter(
(el) => el.type === "rectangle" && el.id !== initialNode.id,
)[0];
expect(firstChildNode).not.toBe(null);
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
API.setSelectedElements([initialNode] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(5);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
const secondChildNode = h.elements.filter(
(el) =>
el.type === "rectangle" &&
el.id !== initialNode.id &&
el.id !== firstChildNode.id,
)[0];
expect(secondChildNode).not.toBe(null);
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
API.setSelectedElements([initialNode] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.length).toBe(7);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
const thirdChildNode = h.elements.filter(
(el) =>
el.type === "rectangle" &&
el.id !== initialNode.id &&
el.id !== firstChildNode.id &&
el.id !== secondChildNode.id,
)[0];
expect(thirdChildNode).not.toBe(null);
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
expect(firstChildNode.x).toBe(secondChildNode.x);
expect(secondChildNode.x).toBe(thirdChildNode.x);
});
// regression for #8518: additional siblings must not overlap existing ones
it("does not overlap existing siblings when adding more children (down)", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 400,
height: 300,
});
API.setElements([parent]);
API.setSelectedElements([parent]);
for (let i = 0; i < 4; i++) {
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
}
const children = h.elements.filter(
(el) => el.type === "rectangle" && el.id !== parent.id,
);
expect(children.length).toBe(4);
// all siblings should sit on the same row (no vertical misalignment)
const ys = new Set(children.map((c) => c.y));
expect(ys.size).toBe(1);
// no two siblings should overlap horizontally
const sorted = [...children].sort((a, b) => a.x - b.x);
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i].x).toBeGreaterThanOrEqual(
sorted[i - 1].x + sorted[i - 1].width,
);
}
});
// regression for #8518: a second batch of children (added by holding the
// modifier and pressing the arrow several times) must clear the first batch
it("does not overlap a previous batch of children (down)", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 400,
height: 300,
});
API.setElements([parent]);
// hold the modifier and press down N times to create a batch at once
const addBatch = (count: number) => {
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
for (let i = 0; i < count; i++) {
Keyboard.keyPress(KEYS.ARROW_DOWN);
}
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
};
addBatch(3);
addBatch(2);
const children = h.elements.filter(
(el) => el.type === "rectangle" && el.id !== parent.id,
);
expect(children.length).toBe(5);
const overlaps = (a: typeof children[0], b: typeof children[0]) =>
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
for (let i = 0; i < children.length; i++) {
for (let j = i + 1; j < children.length; j++) {
expect(overlaps(children[i], children[j])).toBe(false);
}
}
});
// regression for #8518: a new child must also clear nodes that aren't the
// start node's direct siblings but sit where it would land — e.g. a sibling
// reached through a shared parent
it("does not overlap a sibling reached through a shared parent", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([parent]);
// two right-children stack into a column to the right of the parent
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
const rightChildren = h.elements
.filter((el) => el.type === "rectangle" && el.id !== parent.id)
.sort((a, b) => a.y - b.y);
expect(rightChildren.length).toBe(2);
const [upper, lower] = rightChildren;
// add a child below the upper sibling; it must not land on the lower one
// that sits directly beneath it
API.setSelectedElements([upper] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
const newChild = h.elements.filter(
(el) =>
el.type === "rectangle" &&
el.id !== parent.id &&
el.id !== upper.id &&
el.id !== lower.id,
)[0];
expect(newChild).toBeTruthy();
const overlaps = (a: typeof newChild, b: typeof lower) =>
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
expect(overlaps(newChild, lower)).toBe(false);
});
});
describe("flow chart band-search placement", () => {
const addChild = (parent: NonDeletedExcalidrawElement, key: string) => {
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(key);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
};
const children = (parent: NonDeletedExcalidrawElement) =>
h.elements.filter((el) => el.type === "rectangle" && el.id !== parent.id);
const pendingRects = () =>
(h.app.flowchart.pendingNodes ?? [])
.filter((el) => el.type === "rectangle")
.map((el) => ({ x: el.x, y: el.y }));
it("places the first child exactly one offset away in every direction", () => {
const cases = [
{ key: KEYS.ARROW_RIGHT, x: 300, y: 0 },
{ key: KEYS.ARROW_LEFT, x: -300, y: 0 },
{ key: KEYS.ARROW_DOWN, x: 0, y: 200 },
{ key: KEYS.ARROW_UP, x: 0, y: -200 },
];
for (const { key, x, y } of cases) {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([parent]);
addChild(parent, key);
const [child] = children(parent);
expect(child).toBeTruthy();
expect(child.x).toBe(x);
expect(child.y).toBe(y);
}
});
it("slides into the nearest free gap between obstacles without moving them", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([parent]);
addChild(parent, KEYS.ARROW_DOWN);
addChild(parent, KEYS.ARROW_DOWN);
const [c1, c2] = children(parent).sort((a, b) => a.x - b.x);
expect([c1.x, c2.x]).toEqual([0, 300]);
// widen the gap between the siblings so a third child fits in between
API.updateElement(c2, { x: 600 });
addChild(parent, KEYS.ARROW_DOWN);
const c3 = children(parent).find(
(el) => el.id !== c1.id && el.id !== c2.id,
)!;
expect({ x: c3.x, y: c3.y }).toEqual({ x: 300, y: 200 });
// the obstacles were not moved
expect({ x: c1.x, y: c1.y }).toEqual({ x: 0, y: 200 });
expect({ x: c2.x, y: c2.y }).toEqual({ x: 600, y: 200 });
});
it("keeps pending nodes in place while the cluster grows", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([parent]);
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(pendingRects()).toEqual([{ x: 300, y: 0 }]);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(pendingRects()).toEqual([
{ x: 300, y: 0 },
{ x: 300, y: 200 },
]);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(pendingRects()).toEqual([
{ x: 300, y: -200 },
{ x: 300, y: 0 },
{ x: 300, y: 200 },
]);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
});
it("repositions the whole pending cluster when it no longer fits", () => {
API.clearSelection();
const parent = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([parent]);
// box in the space right of the parent, leaving room for a single node
addChild(parent, KEYS.ARROW_RIGHT);
const [c1] = children(parent);
API.updateElement(c1, { y: -300 });
addChild(parent, KEYS.ARROW_RIGHT);
const c2 = children(parent).find((el) => el.id !== c1.id)!;
expect({ x: c2.x, y: c2.y }).toEqual({ x: 300, y: 0 });
API.updateElement(c2, { y: 300 });
API.setSelectedElements([parent]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(pendingRects()).toEqual([{ x: 300, y: 0 }]);
// growing cannot extend at either end, so the cluster moves as a whole
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(pendingRects()).toEqual([
{ x: 300, y: -100 },
{ x: 300, y: 100 },
]);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
// the obstacles were not moved
expect({ x: c1.x, y: c1.y }).toEqual({ x: 300, y: -300 });
expect({ x: c2.x, y: c2.y }).toEqual({ x: 300, y: 300 });
});
});
describe("flow chart navigation", () => {
it("single node at each level", () => {
/**
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
// all the way to the left, gets us to the first node
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
// all the way to the right, gets us to the last node
const rightMostNode = h.elements[h.elements.length - 2];
expect(rightMostNode);
expect(rightMostNode.type).toBe("rectangle");
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
});
it("multiple nodes at each level", () => {
/**
* from the perspective of the first node, there're four layers, and
* there are four nodes at the second layer
*
* -> ▨
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
* -> ▨
* -> ▨
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
const secondNode = h.elements[1];
const rightMostNode = h.elements[h.elements.length - 2];
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
API.setSelectedElements([rectangle]);
// because of same level cycling,
// going right five times should take us back to the second node again
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
// from the second node, going right three times should take us to the rightmost node
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
});
it("take the most obvious link when possible", () => {
/**
* ▨ → ▨ ▨ → ▨
* ↓ ↑
* ▨ → ▨
*/
API.clearSelection();
const rectangle = API.createElement({
type: "rectangle",
width: 200,
height: 100,
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
// last node should be the one that's selected
const rightMostNode = h.elements[h.elements.length - 2];
expect(rightMostNode.type).toBe("rectangle");
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
// going any direction takes us to the predecessor as well
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
expect(predecessorToRightMostNode.type).toBe("rectangle");
API.setSelectedElements([rightMostNode] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
API.setSelectedElements([rightMostNode] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
API.setSelectedElements([rightMostNode] as NonDeletedExcalidrawElement[]);
Keyboard.withModifierKeys({ alt: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
Keyboard.keyUp(KEYS.ALT);
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
true,
);
});
});

View File

@@ -0,0 +1,877 @@
/* eslint-disable no-lone-blocks */
import { arrayToMap } from "@excalidraw/common";
import {
InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import {
generateKeyBetween,
validateOrderKey,
} from "@excalidraw/fractional-indexing";
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
describe("fractional index format validation", () => {
it("should reject malformed base62 order keys", () => {
expect(() => validateOrderKey("a!")).toThrow();
expect(() => validateOrderKey("a_")).toThrow();
expect(() => validateOrderKey("a1!")).toThrow();
expect(() => validateOrderKey("a1_")).toThrow();
expect(() => validateOrderKey("zd0032")).toThrow();
});
it("should accept valid base62 order keys", () => {
expect(() => validateOrderKey("Zz")).not.toThrow();
expect(() => validateOrderKey("a0")).not.toThrow();
expect(() => validateOrderKey("a1")).not.toThrow();
expect(() => validateOrderKey("a1V")).not.toThrow();
expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
});
});
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
testMovedIndicesSync({
elements: [],
movedElements: [],
expect: {
unchangedElements: [],
validInput: true,
},
});
testInvalidIndicesSync({
elements: [],
expect: {
unchangedElements: [],
validInput: true,
},
});
});
describe("should NOT sync when index is well defined", () => {
testMovedIndicesSync({
elements: [{ id: "A", index: "a1" }],
movedElements: [],
expect: {
unchangedElements: ["A"],
validInput: true,
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a1" }],
expect: {
unchangedElements: ["A"],
validInput: true,
},
});
});
describe("should NOT sync when indices are well defined", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "C", index: "a3" },
],
movedElements: [],
expect: {
unchangedElements: ["A", "B", "C"],
validInput: true,
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "B", "C"],
validInput: true,
},
});
});
describe("should sync when fractional index is not defined", () => {
testMovedIndicesSync({
elements: [{ id: "A" }],
movedElements: ["A"],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [{ id: "A" }],
expect: {
unchangedElements: [],
},
});
});
describe("should sync when fractional index is malformed", () => {
// "zd0032" has head "z" which requires length 28 per getIntegerLength,
// but the string is far too short, so validateOrderKey throws for it
testInvalidIndicesSync({
elements: [{ id: "A", index: "zd0032" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "zd0032" },
{ id: "C", index: "a3" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [{ id: "A", index: "a!" }],
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a!" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a1" },
],
expect: {
unchangedElements: ["A"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a1" },
],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should sync when a fractional index is out of order", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a1" },
],
movedElements: ["B"],
expect: {
unchangedElements: ["A"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a1" },
],
movedElements: ["A"],
expect: {
unchangedElements: ["B"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a1" },
],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should sync when fractional indices are out of order", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a3" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
],
movedElements: ["B", "C"],
expect: {
unchangedElements: ["A"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a3" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should sync when incorrect fractional index is in between correct ones ", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["B"],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
expect: {
unchangedElements: ["A", "C"],
},
});
});
describe("should sync when incorrect fractional index is on top and duplicated below", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
],
movedElements: ["C"],
expect: {
unchangedElements: ["A", "B"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
],
expect: {
unchangedElements: ["A", "B"],
},
});
});
describe("should sync when given a mix of duplicate / invalid indices", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a0" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
{ id: "D", index: "a1" },
{ id: "E", index: "a2" },
],
movedElements: ["C", "D", "E"],
expect: {
unchangedElements: ["A", "B"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a0" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
{ id: "D", index: "a1" },
{ id: "E", index: "a2" },
],
expect: {
unchangedElements: ["A", "B"],
},
});
});
describe("should sync when given a mix of undefined / invalid indices", () => {
testMovedIndicesSync({
elements: [
{ id: "A" },
{ id: "B" },
{ id: "C", index: "a0" },
{ id: "D", index: "a2" },
{ id: "E" },
{ id: "F", index: "a3" },
{ id: "G" },
{ id: "H", index: "a1" },
{ id: "I", index: "a2" },
{ id: "J" },
],
movedElements: ["A", "B", "E", "G", "H", "I", "J"],
expect: {
unchangedElements: ["C", "D", "F"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A" },
{ id: "B" },
{ id: "C", index: "a0" },
{ id: "D", index: "a2" },
{ id: "E" },
{ id: "F", index: "a3" },
{ id: "G" },
{ id: "H", index: "a1" },
{ id: "I", index: "a2" },
{ id: "J" },
],
expect: {
unchangedElements: ["C", "D", "F"],
},
});
});
describe("should sync all moved elements regardless of their validity", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["A"],
expect: {
validInput: true,
unchangedElements: ["B"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B", index: "a4" },
],
movedElements: ["B"],
expect: {
validInput: true,
unchangedElements: ["A"],
},
});
testMovedIndicesSync({
elements: [
{ id: "C", index: "a2" },
{ id: "D", index: "a3" },
{ id: "A", index: "a0" },
{ id: "B", index: "a1" },
],
movedElements: ["C", "D"],
expect: {
unchangedElements: ["A", "B"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "D", index: "a4" },
{ id: "C", index: "a3" },
{ id: "F", index: "a6" },
{ id: "E", index: "a5" },
{ id: "H", index: "a8" },
{ id: "G", index: "a7" },
{ id: "I", index: "a9" },
],
movedElements: ["D", "F", "H"],
expect: {
unchangedElements: ["A", "B", "C", "E", "G", "I"],
},
});
{
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["B", "C"],
expect: {
unchangedElements: ["A"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a0" },
{ id: "C", index: "a2" },
],
movedElements: ["A", "B"],
expect: {
unchangedElements: ["C"],
},
});
}
testMovedIndicesSync({
elements: [
{ id: "A", index: "a0" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
{ id: "D", index: "a1" },
{ id: "E", index: "a2" },
],
movedElements: ["B", "D", "E"],
expect: {
unchangedElements: ["A", "C"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A" },
{ id: "B" },
{ id: "C", index: "a0" },
{ id: "D", index: "a2" },
{ id: "E" },
{ id: "F", index: "a3" },
{ id: "G" },
{ id: "H", index: "a1" },
{ id: "I", index: "a2" },
{ id: "J" },
],
movedElements: ["A", "B", "D", "E", "F", "G", "J"],
expect: {
unchangedElements: ["C", "H", "I"],
},
});
});
describe("should generate fractions for explicitly moved elements", () => {
describe("should generate a fraction between 'A' and 'C'", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
// doing actual fractions, without jitter 'a1' becomes 'a1V'
// as V is taken as the charset's middle-right value
{ id: "B", index: "a1" },
{ id: "C", index: "a2" },
],
movedElements: ["B"],
expect: {
unchangedElements: ["A", "C"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a1" },
{ id: "C", index: "a2" },
],
expect: {
// as above, B will become fractional
unchangedElements: ["A", "C"],
},
});
});
describe("should generate fractions given duplicated indices", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a01" },
{ id: "B", index: "a01" },
{ id: "C", index: "a01" },
{ id: "D", index: "a01" },
{ id: "E", index: "a02" },
{ id: "F", index: "a02" },
{ id: "G", index: "a02" },
],
movedElements: ["B", "C", "D", "E", "F"],
expect: {
unchangedElements: ["A", "G"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a01" },
{ id: "B", index: "a01" },
{ id: "C", index: "a01" },
{ id: "D", index: "a01" },
{ id: "E", index: "a02" },
{ id: "F", index: "a02" },
{ id: "G", index: "a02" },
],
movedElements: ["A", "C", "D", "E", "G"],
expect: {
unchangedElements: ["B", "F"],
},
});
testMovedIndicesSync({
elements: [
{ id: "A", index: "a01" },
{ id: "B", index: "a01" },
{ id: "C", index: "a01" },
{ id: "D", index: "a01" },
{ id: "E", index: "a02" },
{ id: "F", index: "a02" },
{ id: "G", index: "a02" },
],
movedElements: ["B", "C", "D", "F", "G"],
expect: {
unchangedElements: ["A", "E"],
},
});
testInvalidIndicesSync({
elements: [
{ id: "A", index: "a01" },
{ id: "B", index: "a01" },
{ id: "C", index: "a01" },
{ id: "D", index: "a01" },
{ id: "E", index: "a02" },
{ id: "F", index: "a02" },
{ id: "G", index: "a02" },
],
expect: {
// notice fallback considers first item (E) as a valid one
unchangedElements: ["A", "E"],
},
});
});
});
describe("should be able to sync 20K invalid indices", () => {
const length = 20_000;
describe("should sync all empty indices", () => {
const elements = Array.from({ length }).map((_, index) => ({
id: `A_${index}`,
}));
testMovedIndicesSync({
// elements without fractional index
elements,
movedElements: Array.from({ length }).map((_, index) => `A_${index}`),
expect: {
unchangedElements: [],
},
});
testInvalidIndicesSync({
// elements without fractional index
elements,
expect: {
unchangedElements: [],
},
});
});
describe("should sync all but last index given a growing array of indices", () => {
let lastIndex: string | null = null;
const elements = Array.from({ length }).map((_, index) => {
// going up from 'a0'
lastIndex = generateKeyBetween(lastIndex, null);
return {
id: `A_${index}`,
// assigning the last generated index, so sync can go down from there
// without jitter lastIndex is 'c4BZ' for 20000th element
index: index === length - 1 ? lastIndex : undefined,
};
});
const movedElements = Array.from({ length }).map(
(_, index) => `A_${index}`,
);
// remove last element
movedElements.pop();
testMovedIndicesSync({
elements,
movedElements,
expect: {
unchangedElements: [`A_${length - 1}`],
},
});
testInvalidIndicesSync({
elements,
expect: {
unchangedElements: [`A_${length - 1}`],
},
});
});
describe("should sync all but first index given a declining array of indices", () => {
let lastIndex: string | null = null;
const elements = Array.from({ length }).map((_, index) => {
// going down from 'a0'
lastIndex = generateKeyBetween(null, lastIndex);
return {
id: `A_${index}`,
// without jitter lastIndex is 'XvoR' for 20000th element
index: lastIndex,
};
});
const movedElements = Array.from({ length }).map(
(_, index) => `A_${index}`,
);
// remove first element
movedElements.shift();
testMovedIndicesSync({
elements,
movedElements,
expect: {
unchangedElements: [`A_0`],
},
});
testInvalidIndicesSync({
elements,
expect: {
unchangedElements: [`A_0`],
},
});
});
});
describe("should automatically fallback to fixing all invalid indices", () => {
describe("should fallback to syncing duplicated indices when moved elements are empty", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a1" },
{ id: "C", index: "a1" },
],
// the validation will throw as nothing was synced
// therefore it will lead to triggering the fallback and fixing all invalid indices
movedElements: [],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should fallback to syncing undefined / invalid indices when moved elements are empty", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B" },
{ id: "C", index: "a0" },
],
// since elements are invalid, this will fail the validation
// leading to fallback fixing "B" and "C"
movedElements: [],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should fallback to syncing unordered indices when moved element is invalid", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a1" },
{ id: "B", index: "a2" },
{ id: "C", index: "a1" },
],
movedElements: ["A"],
expect: {
unchangedElements: ["A", "B"],
},
});
});
describe("should fallback when trying to generate an index in between unordered elements", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a2" },
{ id: "B" },
{ id: "C", index: "a1" },
],
// 'B' is invalid, but so is 'C', which was not marked as moved
// therefore it will try to generate a key between 'a2' and 'a1'
// which it cannot do, thus will throw during generation and automatically fallback
movedElements: ["B"],
expect: {
unchangedElements: ["A"],
},
});
});
describe("should fallback when trying to generate an index in between duplicate indices", () => {
testMovedIndicesSync({
elements: [
{ id: "A", index: "a01" },
{ id: "B" },
{ id: "C" },
{ id: "D", index: "a01" },
{ id: "E", index: "a01" },
{ id: "F", index: "a01" },
{ id: "G" },
{ id: "I", index: "a03" },
{ id: "H" },
],
// missed "E" therefore upper bound for 'B' is a01, while lower bound is 'a02'
// therefore, similarly to above, it will fail during key generation and lead to fallback
movedElements: ["B", "C", "D", "F", "G", "H"],
expect: {
unchangedElements: ["A", "I"],
},
});
});
});
});
function testMovedIndicesSync(args: {
elements: { id: string; index?: string }[];
movedElements: string[];
expect: {
unchangedElements: string[];
validInput?: true;
};
}) {
const [elements, movedElements] = prepareArguments(
args.elements,
args.movedElements,
);
const expectUnchangedElements = arrayToMap(
args.expect.unchangedElements.map((x) => ({ id: x })),
);
test(
"should sync invalid indices of moved elements or fallback",
elements,
movedElements,
expectUnchangedElements,
args.expect.validInput,
);
}
function testInvalidIndicesSync(args: {
elements: { id: string; index?: string }[];
expect: {
unchangedElements: string[];
validInput?: true;
};
}) {
const [elements] = prepareArguments(args.elements);
const expectUnchangedElements = arrayToMap(
args.expect.unchangedElements.map((x) => ({ id: x })),
);
test(
"should sync invalid indices of all elements",
elements,
undefined,
expectUnchangedElements,
args.expect.validInput,
);
}
function prepareArguments(
elementsLike: { id: string; index?: string }[],
movedElementsIds?: string[],
): [ExcalidrawElement[], ElementsMap | undefined] {
const elements = elementsLike.map((x) =>
API.createElement({ id: x.id, index: x.index as FractionalIndex }),
);
const movedMap = arrayToMap(movedElementsIds || []);
const movedElements = movedElementsIds
? arrayToMap(elements.filter((x) => movedMap.has(x.id)))
: undefined;
return [elements, movedElements];
}
function test(
name: string,
elements: ExcalidrawElement[],
movedElements: ElementsMap | undefined,
expectUnchangedElements: Map<string, { id: string }>,
expectValidInput?: boolean,
) {
it(name, () => {
// ensure the input is invalid (unless the flag is on)
if (!expectValidInput) {
expect(() =>
validateFractionalIndices(elements, {
shouldThrow: true,
includeBoundTextValidation: true,
ignoreLogs: true,
}),
).toThrowError(InvalidFractionalIndexError);
}
// clone due to mutation
const clonedElements = elements.map((x) => deepCopyElement(x));
// act
const syncedElements = movedElements
? syncMovedIndices(clonedElements, movedElements)
: syncInvalidIndices(clonedElements);
expect(syncedElements.length).toBe(elements.length);
expect(() =>
validateFractionalIndices(syncedElements, {
shouldThrow: true,
includeBoundTextValidation: true,
ignoreLogs: true,
}),
).not.toThrowError(InvalidFractionalIndexError);
syncedElements.forEach((synced, index) => {
const element = elements[index];
// ensure the order hasn't changed
expect(synced.id).toBe(element.id);
if (expectUnchangedElements.has(synced.id)) {
// ensure we didn't mutate where we didn't want to mutate
expect(synced.index).toBe(elements[index].index);
expect(synced.version).toBe(elements[index].version);
} else {
expect(synced.index).not.toBe(elements[index].index);
// ensure we mutated just once, even with fallback triggered
expect(synced.version).toBe(elements[index].version + 1);
}
});
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import * as common from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { newElement, newLinearElement } from "../src/newElement";
import { mutateElement, newElementWith } from "../src/mutateElement";
describe("element creation time", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
"rectangle",
"diamond",
"ellipse",
"text",
"line",
"arrow",
"freedraw",
"image",
"frame",
"magicframe",
"embeddable",
"iframe",
] as const)("initializes a new %s before any capture", (type) => {
vi.spyOn(common, "getUpdatedTimestamp").mockReturnValue(123);
const element = API.createElement({ type });
expect(element.created).toBe(123);
vi.mocked(common.getUpdatedTimestamp).mockReturnValue(456);
const edited = newElementWith(element, { x: 50 });
mutateElement(edited, new Map(), { y: 50 });
expect(edited).toMatchObject({ created: 123, updated: 456 });
});
it.each([123, 0, null])(
"preserves explicit created=%s when reconstructing the same element",
(created) => {
const original = newElement({
type: "rectangle",
x: 0,
y: 0,
created,
});
const converted = newLinearElement({ ...original, type: "line" });
expect(converted).toMatchObject({ id: original.id, created });
},
);
});

View File

@@ -0,0 +1,327 @@
import type { GlobalPoint } from "@excalidraw/math";
import { recognizeShape } from "../src/convertToShape";
// Deterministic jitter, so a run that passes keeps passing.
let seed = 1;
const random = () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648 - 0.5;
};
// Draw the shape somewhere else, at another size, turned by `angle`, with a
// wobbly hand.
const sketch = (
points: readonly [number, number][],
{ angle = 0, scale = 1, noise = 0 } = {},
): GlobalPoint[] =>
points.map(([x, y]) => {
const nx = x * scale + random() * noise;
const ny = y * scale + random() * noise;
return [
nx * Math.cos(angle) - ny * Math.sin(angle) + 600,
nx * Math.sin(angle) + ny * Math.cos(angle) + 600,
] as GlobalPoint;
});
const edge = (
from: readonly [number, number],
to: readonly [number, number],
steps: number,
): [number, number][] =>
Array.from({ length: steps }, (_, i) => [
from[0] + ((to[0] - from[0]) * i) / steps,
from[1] + ((to[1] - from[1]) * i) / steps,
]);
const outline = (corners: readonly [number, number][]): [number, number][] => [
...corners.flatMap((corner, i) => edge(corner, corners[(i + 1) % 4], 18)),
corners[0],
];
const rectangle = (w: number, h: number) =>
outline([
[-w / 2, -h / 2],
[w / 2, -h / 2],
[w / 2, h / 2],
[-w / 2, h / 2],
]);
const diamond = (w: number, h: number) =>
outline([
[0, -h / 2],
[w / 2, 0],
[0, h / 2],
[-w / 2, 0],
]);
const ellipse = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
points.push([Math.cos(a) * rx, Math.sin(a) * ry]);
}
return points;
};
// Moving-average smoothing: rounds every corner the way a relaxed pen does.
const smooth = (
pts: readonly [number, number][],
window: number,
): [number, number][] =>
pts.map((_, i) => {
let sx = 0;
let sy = 0;
for (let k = -window; k <= window; k++) {
const [x, y] = pts[(i + k + pts.length) % pts.length];
sx += x;
sy += y;
}
return [sx / (2 * window + 1), sy / (2 * window + 1)];
});
// A sloppy hand-drawn "rectangle" that tapers, bucket-like: wider at the top
// than the bottom. Its hull fill ratio, (topW + bottomW) / (2 * topW), lands
// right at an ellipse's PI/4 — only its sharp corners tell the two apart.
const taperedRectangle = (
topW: number,
bottomW: number,
h: number,
): [number, number][] =>
outline([
[-topW / 2, -h / 2],
[topW / 2, -h / 2],
[bottomW / 2, h / 2],
[-bottomW / 2, h / 2],
]);
// A rectangle drawn with broad, pen-like rounded corners. Its outline is a
// superellipse midway between an ellipse (exponent 2) and a sharp rectangle.
const roundedRectangle = (rx: number, ry: number): [number, number][] => {
const points: [number, number][] = [];
for (let a = 0; a <= Math.PI * 2 + 0.05; a += 0.1) {
const cos = Math.cos(a);
const sin = Math.sin(a);
points.push([
Math.sign(cos) * Math.sqrt(Math.abs(cos)) * rx,
Math.sign(sin) * Math.sqrt(Math.abs(sin)) * ry,
]);
}
return points;
};
const line = (length: number) => edge([-length / 2, 0], [length / 2, 0], 40);
/** A shaft, then a V head traced back from the tip and over itself. */
const arrow = (length: number, headRatio: number): [number, number][] => {
const tip: [number, number] = [length / 2, 0];
const headLength = length * headRatio;
const arm = (side: number): [number, number] => [
tip[0] - headLength * Math.cos(Math.PI / 6),
tip[1] - side * headLength * Math.sin(Math.PI / 6),
];
return [
...edge([-length / 2, 0], tip, 24),
...edge(tip, arm(1), 8),
...edge(arm(1), tip, 8),
...edge(tip, arm(-1), 8),
];
};
const SCALES = [0.5, 1, 2, 4];
describe("recognizeShape", () => {
describe("is invariant to where, how big and how rotated a shape is drawn", () => {
// A rectangle turned 45° *is* a diamond, so these two are only swept over
// the right angles that map them onto themselves. Every other class is
// swept over arbitrary angles.
const RIGHT_ANGLES = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
// 4.7 rad is within a degree of vertical — the size gate compares the
// larger bounding-box dimension, so thin (near-)axis-aligned strokes are
// recognized like any other.
const ANY_ANGLES = [0, 0.3, Math.PI / 4, 1.2, Math.PI / 2, 2.5, 4.7];
const cases: [string, [number, number][], number[], string][] = [
["a square", rectangle(200, 200), RIGHT_ANGLES, "rectangle"],
["a wide rectangle", rectangle(300, 150), RIGHT_ANGLES, "rectangle"],
["a thin rectangle", rectangle(500, 100), RIGHT_ANGLES, "rectangle"],
[
"a tapered rectangle",
taperedRectangle(170, 100, 200),
RIGHT_ANGLES,
"rectangle",
],
["a diamond", diamond(200, 200), RIGHT_ANGLES, "diamond"],
["a wide diamond", diamond(300, 150), RIGHT_ANGLES, "diamond"],
["a circle", ellipse(100, 100), ANY_ANGLES, "ellipse"],
["an ellipse", ellipse(150, 75), ANY_ANGLES, "ellipse"],
["a line", line(300), ANY_ANGLES, "line"],
["an arrow with a small head", arrow(300, 0.15), ANY_ANGLES, "arrow"],
["an arrow with a large head", arrow(300, 0.45), ANY_ANGLES, "arrow"],
];
it.each(cases)("recognizes %s", (_name, points, angles, expected) => {
for (const angle of angles) {
for (const scale of SCALES) {
for (const noise of [0, 3]) {
expect({
angle,
scale,
noise,
type: recognizeShape(
sketch(points, { angle, scale, noise }),
null,
).type,
}).toEqual({ angle, scale, noise, type: expected });
}
}
}
});
});
it("tells an arrow from a line by the points its head piles up", () => {
const options = { angle: 0.3 };
expect(recognizeShape(sketch(line(300), options), null).type).toBe("line");
expect(recognizeShape(sketch(arrow(300, 0.3), options), null).type).toBe(
"arrow",
);
});
it("recognizes an outline the user didn't quite close", () => {
const gapped = rectangle(200, 200).slice(0, -6);
expect(recognizeShape(sketch(gapped), null).type).toBe("rectangle");
});
it("recognizes a pen-drawn outline with rounded corners as a rectangle", () => {
expect(recognizeShape(sketch(roundedRectangle(100, 70)), null).type).toBe(
"rectangle",
);
// ...while a true ellipse of the same proportions remains an ellipse
expect(recognizeShape(sketch(ellipse(100, 70)), null).type).toBe("ellipse");
});
it("tells a tapered, bucket-like rectangle from an ellipse by its corners", () => {
// proportions traced from real sketches that used to recognize as
// ellipses: hull fill ≈ 0.8 ≈ PI/4, so corner sharpness must decide
const buckets: [number, number, number][] = [
[110, 65, 220],
[110, 65, 130],
[75, 50, 115],
[120, 60, 250],
];
for (const [topW, bottomW, h] of buckets) {
// lightly rounded corners + wobble, like the real strokes
const points = sketch(smooth(taperedRectangle(topW, bottomW, h), 2), {
noise: 2,
});
expect({
bucket: [topW, bottomW, h],
type: recognizeShape(points, null).type,
}).toEqual({ bucket: [topW, bottomW, h], type: "rectangle" });
}
});
it("does not read sharply bent open strokes as lines or arrows", () => {
const semicircle: [number, number][] = Array.from(
{ length: 40 },
(_, i) => {
const a = (Math.PI * i) / 39;
return [Math.cos(a) * 150, Math.sin(a) * 150];
},
);
const sawtooth: [number, number][] = Array.from({ length: 31 }, (_, i) => [
i * 10,
(i % 2) * 60,
]);
const bent: [string, [number, number][]][] = [
[
"a right-angle elbow",
[...edge([0, 0], [110, 110], 20), ...edge([110, 110], [220, 0], 20)],
],
[
"an L shape",
[...edge([0, 0], [0, 150], 20), ...edge([0, 150], [150, 150], 20)],
],
[
"a checkmark",
[...edge([0, 60], [60, 120], 12), ...edge([60, 120], [200, 0], 24)],
],
["a semicircular arc", semicircle],
["a sawtooth", sawtooth],
];
for (const [name, points] of bent) {
expect({
name,
type: recognizeShape(sketch(points, { noise: 2 }), null).type,
}).toEqual({ name, type: "freedraw" });
}
});
it("still reads a lazily bowed or squiggly stroke as a line", () => {
const bowed: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI) * 24];
});
const squiggly: [number, number][] = Array.from({ length: 40 }, (_, i) => {
const t = i / 39;
return [t * 300, Math.sin(t * Math.PI * 4) * 12];
});
expect(recognizeShape(sketch(bowed, { noise: 2 }), null).type).toBe("line");
expect(recognizeShape(sketch(squiggly, { noise: 2 }), null).type).toBe(
"line",
);
});
it("leaves a stroke that resembles no known shape as freedraw", () => {
const squiggle: [number, number][] = Array.from({ length: 60 }, (_, i) => [
i * 4 - 120,
Math.sin(i / 2) * 70 + random() * 20,
]);
expect(recognizeShape(sketch(squiggle), null).type).toBe("freedraw");
});
it("only extends an arrow with another arrow", () => {
const previous = { type: "arrow" } as Parameters<typeof recognizeShape>[1];
expect(
recognizeShape(sketch(arrow(300, 0.3), { angle: 0.3 }), previous).type,
).toBe("arrow");
expect(recognizeShape(sketch(rectangle(200, 200)), previous).type).toBe(
"freedraw",
);
});
it("ignores a stroke too small to be a shape", () => {
const tiny: [number, number][] = [
[0, 0],
[1, 1],
[2, 0],
[1, -1],
];
expect(recognizeShape(sketch(tiny), null).type).toBe("freedraw");
});
it("gates on apparent (screen) size, so zoom decides what is too small", () => {
const smallCircle = ellipse(8, 8); // 16 scene units across
expect(recognizeShape(sketch(smallCircle), null).type).toBe("freedraw");
expect(recognizeShape(sketch(smallCircle), null, 10).type).toBe("ellipse");
// 300 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(3000), { angle: 0.3 }), null, 0.1).type,
).toBe("line");
// 10 apparent px at 0.1 zoom
expect(
recognizeShape(sketch(line(100), { angle: 0.3 }), null, 0.1).type,
).toBe("freedraw");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import { makeNextSelectedElementIds } from "../src/selection";
describe("makeNextSelectedElementIds", () => {
const _makeNextSelectedElementIds = (
selectedElementIds: { [id: string]: true },
prevSelectedElementIds: { [id: string]: true },
expectUpdated: boolean,
) => {
const ret = makeNextSelectedElementIds(selectedElementIds, {
selectedElementIds: prevSelectedElementIds,
});
expect(ret === selectedElementIds).toBe(expectUpdated);
};
it("should return prevState selectedElementIds if no change", () => {
_makeNextSelectedElementIds({}, {}, false);
_makeNextSelectedElementIds({ 1: true }, { 1: true }, false);
_makeNextSelectedElementIds(
{ 1: true, 2: true },
{ 1: true, 2: true },
false,
);
});
it("should return new selectedElementIds if changed", () => {
// _makeNextSelectedElementIds({ 1: true }, { 1: false }, true);
_makeNextSelectedElementIds({ 1: true }, {}, true);
_makeNextSelectedElementIds({}, { 1: true }, true);
_makeNextSelectedElementIds({ 1: true }, { 2: true }, true);
_makeNextSelectedElementIds({ 1: true }, { 1: true, 2: true }, true);
_makeNextSelectedElementIds(
{ 1: true, 2: true },
{ 1: true, 3: true },
true,
);
});
});

View File

@@ -0,0 +1,58 @@
import { vi } from "vitest";
import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3;
// Needed so that we can mock the value of constants which is done in
// below tests. In Jest this wasn't needed as global override was possible
// but vite doesn't allow that hence we need to mock
vi.mock(
"@excalidraw/common",
//@ts-ignore
async (importOriginal) => {
const module: any = await importOriginal();
return { ...module };
},
);
describe("getPerfectElementSize", () => {
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("line", 149, 10);
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
});
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("line", 10, 140);
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
});
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
const { height, width } = getPerfectElementSize("arrow", 200, 20);
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
});
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
const { height, width } = getPerfectElementSize("arrow", 10, 100);
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
});
it("should return adjust height to be width * tan(locked angle)", () => {
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
});
it("should return height equals to width if locked angle is 45 deg", () => {
const { height, width } = getPerfectElementSize("arrow", 135, 145);
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
});
it("should return height:0 and width:0 when width and height are 0", () => {
const { height, width } = getPerfectElementSize("arrow", 0, 0);
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
});
});

View File

@@ -0,0 +1,446 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "@excalidraw/element";
import { normalizeElementOrder } from "../src/sortElements";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const assertOrder = (
elements: readonly ExcalidrawElement[],
expectedOrder: string[],
) => {
const actualOrder = elements.map((element) => element.id);
expect(actualOrder).toEqual(expectedOrder);
};
describe("normalizeElementsOrder", () => {
it("sort bound-text elements", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
const otherElement = API.createElement({
id: "otherElement",
type: "rectangle",
boundElements: [],
});
const otherElement2 = API.createElement({
id: "otherElement2",
type: "rectangle",
boundElements: [],
});
mutateElement(container, new Map(), {
boundElements: [{ type: "text", id: boundText.id }],
});
assertOrder(normalizeElementOrder([container, boundText]), [
"container",
"boundText",
]);
assertOrder(normalizeElementOrder([boundText, container]), [
"container",
"boundText",
]);
assertOrder(
normalizeElementOrder([
boundText,
container,
otherElement,
otherElement2,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
"container",
"boundText",
"otherElement",
]);
assertOrder(
normalizeElementOrder([
container,
otherElement,
otherElement2,
boundText,
]),
["container", "boundText", "otherElement", "otherElement2"],
);
assertOrder(
normalizeElementOrder([
boundText,
otherElement,
container,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// noop
assertOrder(
normalizeElementOrder([
otherElement,
container,
boundText,
otherElement2,
]),
["otherElement", "container", "boundText", "otherElement2"],
);
// text has existing containerId, but container doesn't list is
// as a boundElement
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
API.createElement({
id: "container",
type: "rectangle",
}),
]),
["boundText", "container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "boundText",
type: "text",
containerId: "container",
}),
]),
["boundText"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "x", type: "text" }],
}),
]),
["container"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "arrow",
type: "arrow",
}),
API.createElement({
id: "container",
type: "rectangle",
boundElements: [{ id: "arrow", type: "arrow" }],
}),
]),
["arrow", "container"],
);
});
it("normalize group order", () => {
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "rect3",
type: "rectangle",
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "rect2",
type: "rectangle",
}),
API.createElement({
id: "B_rect3",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "A_rect4",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "B_rect5",
type: "rectangle",
groupIds: ["B"],
}),
API.createElement({
id: "rect6",
type: "rectangle",
}),
API.createElement({
id: "A_rect7",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
);
// nested groups
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect2",
type: "rectangle",
groupIds: ["B", "A"],
}),
]),
["A_rect1", "BA_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
]),
["BA_rect1", "A_rect2"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "BA_rect1",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "A_rect2",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect3",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "rect4",
type: "rectangle",
}),
API.createElement({
id: "A_rect5",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "BA_rect5",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "BA_rect6",
type: "rectangle",
groupIds: ["B", "A"],
}),
API.createElement({
id: "CBA_rect7",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "X_rect8",
type: "rectangle",
groupIds: ["X"],
}),
API.createElement({
id: "rect9",
type: "rectangle",
}),
API.createElement({
id: "YX_rect10",
type: "rectangle",
groupIds: ["Y", "X"],
}),
API.createElement({
id: "X_rect11",
type: "rectangle",
groupIds: ["X"],
}),
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"rect4",
"X_rect8",
"YX_rect10",
"X_rect11",
"rect9",
],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
});
// TODO
it.skip("normalize boundElements array", () => {
const container = API.createElement({
id: "container",
type: "rectangle",
boundElements: [],
});
const boundText = API.createElement({
id: "boundText",
type: "text",
containerId: container.id,
});
h.app.scene.mutateElement(container, {
boundElements: [
{ type: "text", id: boundText.id },
{ type: "text", id: "xxx" },
],
});
expect(normalizeElementOrder([container, boundText])).toEqual([
expect.objectContaining({
id: container.id,
}),
expect.objectContaining({ id: boundText.id }),
]);
});
// should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
it.skip("normalizeElementsOrder() perf", () => {
const makeElements = (iterations: number) => {
const elements: ExcalidrawElement[] = [];
while (iterations--) {
const container = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["B", "A"],
});
const boundText = API.createElement({
type: "text",
containerId: container.id,
groupIds: ["A"],
});
const otherElement = API.createElement({
type: "rectangle",
boundElements: [],
groupIds: ["C", "A"],
});
h.app.scene.mutateElement(container, {
boundElements: [{ type: "text", id: boundText.id }],
});
elements.push(boundText, otherElement, container);
}
return elements;
};
const elements = makeElements(10000);
const t0 = Date.now();
normalizeElementOrder(elements);
console.info(`${Date.now() - t0}ms`);
});
});

View File

@@ -0,0 +1,379 @@
import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
import type { ExcalidrawTextElementWithContainer } from "../src/types";
describe("Test measureText", () => {
describe("Test getContainerCoords", () => {
const params = { width: 200, height: 100, x: 10, y: 20 };
it("should compute coords correctly when ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 44.2893218813452455,
y: 39.64466094067262,
});
});
it("should compute coords correctly when rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 15,
y: 25,
});
});
it("should compute coords correctly when diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(getContainerCoords(element)).toEqual({
x: 65,
y: 50,
});
});
});
describe("Test computeContainerDimensionForBoundText", () => {
const params = {
width: 178,
height: 194,
};
it("should compute container height correctly for rectangle", () => {
const element = API.createElement({
type: "rectangle",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
160,
);
});
it("should compute container height correctly for ellipse", () => {
const element = API.createElement({
type: "ellipse",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
226,
);
});
it("should compute container height correctly for diamond", () => {
const element = API.createElement({
type: "diamond",
...params,
});
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
320,
);
});
});
describe("Test getBoundTextMaxWidth", () => {
const params = {
width: 178,
height: 194,
};
it("should return max width when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getBoundTextMaxWidth(container, null)).toBe(168);
});
it("should return max width when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getBoundTextMaxWidth(container, null)).toBe(116);
});
it("should return max width when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getBoundTextMaxWidth(container, null)).toBe(79);
});
});
describe("Test getBoundTextMaxHeight", () => {
const params = {
width: 178,
height: 194,
id: '"container-id',
};
const boundTextElement = API.createElement({
type: "text",
id: "text-id",
x: 560.51171875,
y: 202.033203125,
width: 154,
height: 175,
fontSize: 20,
fontFamily: 1,
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
textAlign: "center",
verticalAlign: "middle",
containerId: params.id,
}) as ExcalidrawTextElementWithContainer;
it("should return max height when container is rectangle", () => {
const container = API.createElement({ type: "rectangle", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
});
it("should return max height when container is ellipse", () => {
const container = API.createElement({ type: "ellipse", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
});
it("should return max height when container is diamond", () => {
const container = API.createElement({ type: "diamond", ...params });
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
});
it("should return max height when container is arrow", () => {
const container = API.createElement({
type: "arrow",
...params,
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
});
it("should return max height when container is arrow and height is less than threshold", () => {
const container = API.createElement({
type: "arrow",
...params,
height: 70,
boundElements: [{ type: "text", id: "text-id" }],
});
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
boundTextElement.height,
);
});
});
});
const textElement = API.createElement({
type: "text",
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
fontSize: 20,
fontFamily: 1,
height: 175,
});
describe("Test detectLineHeight", () => {
it("should return correct line height", () => {
expect(detectLineHeight(textElement)).toBe(1.25);
});
});
describe("Test getLineHeightInPx", () => {
it("should return correct line height", () => {
expect(
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
).toBe(25);
});
});
describe("Test getDefaultLineHeight", () => {
it("should return line height using default font family when not passed", () => {
//@ts-ignore
expect(getLineHeight()).toBe(1.25);
});
it("should return line height using default font family for unknown font", () => {
const UNKNOWN_FONT = 5;
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
});
it("should return correct line height", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});

View File

@@ -0,0 +1,703 @@
import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types";
describe("Test wrapText", () => {
// font is irrelevant as jsdom does not support FontFace API
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
it("should wrap the text correctly when word length is exactly equal to max width", () => {
const text = "Hello Excalidraw";
// Length of "Excalidraw" is 100 and exacty equal to max width
const res = wrapText(text, font, 100);
expect(res).toEqual(`Hello\nExcalidraw`);
});
it("should return the text as is if max width is invalid", () => {
const text = "Hello Excalidraw";
expect(wrapText(text, font, NaN)).toEqual(text);
expect(wrapText(text, font, -1)).toEqual(text);
expect(wrapText(text, font, Infinity)).toEqual(text);
});
it("should show the text correctly when max width reached", () => {
const text = "Hello😀";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("H\ne\nl\nl\no\n😀");
});
it("should not wrap number when wrapping line", () => {
const text = "don't wrap this number 99,100.99";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("don't wrap this number\n99,100.99");
});
it("should trim all trailing whitespaces", () => {
const text = "Hello ";
const maxWidth = 50;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello");
});
it("should trim all but one trailing whitespaces", () => {
const text = "Hello ";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello ");
});
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
const text = " Hello World";
const maxWidth = 90;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" Hello\nWorld");
});
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
const text = " Hello World ";
const maxWidth = 90;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" Hello\nWorld ");
});
it("should trim keep those whitespace that fit in the trailing line", () => {
const text = "Hello Wo rl d ";
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello Wo\nrl d ");
});
it("should support multiple (multi-codepoint) emojis", () => {
const text = "😀🗺🔥👩🏽‍🦰👨‍👩‍👧‍👦🇨🇿";
const maxWidth = 1;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("😀\n🗺\n🔥\n👩🏽‍🦰\n👨‍👩‍👧‍👦\n🇨🇿");
});
it("should wrap the text correctly when text contains hyphen", () => {
let text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
const res = wrapText(text, font, 110);
expect(res).toBe(
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
);
text = "Hello thereusing-now";
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
});
it("should support wrapping nested lists", () => {
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
const maxWidth = 100;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 10;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
);
});
it("should break CJK text into longer segments when width is larger", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
const text = "안녕하세요こんにちは世界コンニチハ你好";
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
// measureText is mocked, so it's not precisely what would happen in prod
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
});
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
const maxWidth = 150;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
const maxWidth3 = 30;
const res3 = wrapText(text, font, maxWidth3);
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
});
it("should break before and after a regular CJK character", () => {
const text = "HelloたWorld";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("Hello\nた\nWorld");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("Helloた\nWorld");
});
it("should break before and after certain CJK symbols", () => {
const text = "こんにちは〃世界";
const maxWidth1 = 50;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe("こんにちは\n〃世界");
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe("こんにちは〃\n世界");
});
it("should break after, not before for certain CJK pairs", () => {
const text = "Hello た。";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\nた。");
});
it("should break before, not after for certain CJK pairs", () => {
const text = "Hello「たWorld」";
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hello\n「た\nWorld」");
});
it("should break after, not before for certain CJK character pairs", () => {
const text = "「Helloた」World";
const maxWidth = 70;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("「Hello\nた」World");
});
it("should break Chinese sentences", () => {
const text = `中国你好!这是一个测试。
我们来看看:人民币¥1234「很贵」
(括号)、逗号,句号。空格 换行 全角符号…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`中国你好!这是一\n个测试。
我们来看看:人民\n币¥1234「很\n贵」
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`中国你好!\n这是一个测\n试。
我们来看\n看:人民币\n¥1234\n「很贵」
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
});
it("should break Japanese sentences", () => {
const text = `日本こんにちは!これはテストです。
見てみましょう:円¥1234「高い」
(括弧)、読点、句点。
空白 改行 全角記号…ー`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
見てみましょ\nう:円¥1234\n「高い」
(括弧)、読\n点、句点。
空白 改行\n全角記号…ー`);
const maxWidth2 = 50;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
見てみ\nましょう:\n円\n¥1234\n「高い」
(括\n弧)、読\n点、句点。
空白\n改行 全角\n記号…ー`);
});
it("should break Korean sentences", () => {
const text = `한국 안녕하세요! 이것은 테스트입니다.
우리 보자: 원화₩1234「비싸다」
(괄호), 쉼표, 마침표.
공백 줄바꿈 전각기호…—`;
const maxWidth1 = 80;
const res1 = wrapText(text, font, maxWidth1);
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
우리 보자: 원\n화₩1234「비\n싸다」
(괄호), 쉼\n표, 마침표.
공백 줄바꿈 전\n각기호…—`);
const maxWidth2 = 60;
const res2 = wrapText(text, font, maxWidth2);
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
우리 보자:\n원화\n₩1234\n「비싸다」
(괄호),\n쉼표, 마침\n표.
공백 줄바꿈\n전각기호…—`);
});
});
describe("When text contains leading whitespaces", () => {
const text = " \t Hello world";
it("should preserve leading whitespaces", () => {
const maxWidth = 120;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(" \t Hello\nworld");
});
it("should break and collapse leading whitespaces when line breaks", () => {
const maxWidth = 60;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHello\nworld");
});
it("should break and collapse leading whitespaces whe words break", () => {
const maxWidth = 30;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("\nHel\nlo\nwor\nld");
});
});
describe("When text contains trailing whitespaces", () => {
it("shouldn't add new lines for trailing spaces", () => {
const text = "Hello whats up ";
const maxWidth = 190;
const res = wrapText(text, font, maxWidth);
expect(res).toBe(text);
});
it("should ignore trailing whitespaces when line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 400;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
});
it("should not ignore trailing whitespaces when word breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 300;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
});
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
const text = "Hippopotomonstrosesquippedaliophobia ??????";
const maxWidth = 180;
const res = wrapText(text, font, maxWidth);
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
});
});
describe("When text doesn't contain new lines", () => {
const text = "Hello whats up";
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\nwhats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 130,
res: `Hello whats\nup`,
},
{
desc: "fit the container",
width: 240,
res: "Hello whats up",
},
{
desc: "push the word if its equal to max width",
width: 50,
res: `Hello\nwhats\nup`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text contain new lines", () => {
const text = `Hello\n whats up`;
[
{
desc: "break all words when width of each word is less than container width",
width: 70,
res: `Hello\n whats\nup`,
},
{
desc: "break all characters when width of each character is less than container width",
width: 15,
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
},
{
desc: "break words as per the width",
width: 140,
res: `Hello\n whats up`,
},
].forEach((data) => {
it(`should respect new lines and ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("When text is long", () => {
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
[
{
desc: "fit characters of long string as per container width",
width: 160,
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
},
{
desc: "fit characters of long string as per container width and break words as per the width",
width: 120,
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
},
{
desc: "fit the long text when container width is greater than text length and move the rest to next line",
width: 590,
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
},
].forEach((data) => {
it(`should ${data.desc}`, () => {
const res = wrapText(text, font, data.width);
expect(res).toEqual(data.res);
});
});
});
describe("Test parseTokens", () => {
it("should tokenize latin", () => {
let text = "Excalidraw is a virtual collaborative whiteboard";
expect(parseTokens(text)).toEqual([
"Excalidraw",
" ",
"is",
" ",
"a",
" ",
"virtual",
" ",
"collaborative",
" ",
"whiteboard",
]);
text =
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
expect(parseTokens(text)).toEqual([
"Wikipedia",
" ",
"is",
" ",
"hosted",
" ",
"by",
" ",
"Wikimedia-",
" ",
"Foundation,",
" ",
"a",
" ",
"non-",
"profit",
" ",
"organization",
" ",
"that",
" ",
"also",
" ",
"hosts",
" ",
"a",
" ",
"range-",
"of",
" ",
"other",
" ",
"projects",
]);
});
it("should not tokenize number", () => {
const text = "99,100.99";
const tokens = parseTokens(text);
expect(tokens).toEqual(["99,100.99"]);
});
it("should tokenize joined emojis", () => {
const text = `😬🌍🗺🔥☂️👩🏽‍🦰👨‍👩‍👧‍👦👩🏾‍🔬🏳️‍🌈🧔‍♀️🧑‍🤝‍🧑🙅🏽‍♂️✅0️⃣🇨🇿🦅`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"🌍",
"🗺",
"🔥",
"☂️",
"👩🏽‍🦰",
"👨‍👩‍👧‍👦",
"👩🏾‍🔬",
"🏳️‍🌈",
"🧔‍♀️",
"🧑‍🤝‍🧑",
"🙅🏽‍♂️",
"✅",
"0️⃣",
"🇨🇿",
"🦅",
]);
});
it("should tokenize emojis mixed with mixed text", () => {
const text = `😬a🌍b🗺c🔥d☂️《👩🏽‍🦰》👨‍👩‍👧‍👦德👩🏾‍🔬こ🏳️‍🌈안🧔‍♀️g🧑‍🤝‍🧑h🙅🏽‍♂️e✅f0️⃣g🇨🇿10🦅#hash`;
const tokens = parseTokens(text);
expect(tokens).toEqual([
"😬",
"a",
"🌍",
"b",
"🗺",
"c",
"🔥",
"d",
"☂️",
"《",
"👩🏽‍🦰",
"》",
"👨‍👩‍👧‍👦",
"德",
"👩🏾‍🔬",
"こ",
"🏳️‍🌈",
"안",
"🧔‍♀️",
"g",
"🧑‍🤝‍🧑",
"h",
"🙅🏽‍♂️",
"e",
"✅",
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
"🇨🇿",
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
"🦅",
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
]);
});
it("should tokenize decomposed chars into their composed variants", () => {
// each input character is in a decomposed form
const text = "čでäぴέ다й한";
expect(text.normalize("NFC").length).toEqual(8);
expect(text).toEqual(text.normalize("NFD"));
const tokens = parseTokens(text);
expect(tokens.length).toEqual(8);
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
});
it("should tokenize artificial CJK", () => {
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
// [
// '《道', '德', '經》', '醫-',
// '醫', 'こ', 'ん', 'に',
// 'ち', 'は', '世', '界!',
// '안', '녕', '하', '세',
// '요', '세', '계;', '요』,',
// '다.', '다...', '원/', '달',
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
// 'た…', '[Hello]', ' ', '\t',
// ' ', 'World?', 'ニ', 'ュ',
// 'ー', 'ヨ', 'ー', 'ク・',
// '¥3700.55', 'す。', '090-', '1234-',
// '5678', '¥1,000〜', '$5,000', '「素',
// '晴', 'ら', 'し', 'い!」',
// '〔重', '要〕', '#', '1:',
// 'Taro', '君', '30%', 'は、',
// '(た', 'な', 'ば', 'た)',
// '〰', '¥110±', '¥570', 'で',
// '20℃〜', '9:30〜', '10:00', '【一',
// '番】'
// ]
const tokens = parseTokens(text);
// Latin
expect(tokens).toContain("[[1]]");
expect(tokens).toContain("[Hello]");
expect(tokens).toContain("World?");
expect(tokens).toContain("Taro");
// Chinese
expect(tokens).toContain("《道");
expect(tokens).toContain("德");
expect(tokens).toContain("經》");
expect(tokens).toContain("醫-");
expect(tokens).toContain("醫");
// Japanese
expect(tokens).toContain("こ");
expect(tokens).toContain("ん");
expect(tokens).toContain("に");
expect(tokens).toContain("ち");
expect(tokens).toContain("は");
expect(tokens).toContain("世");
expect(tokens).toContain("ク・");
expect(tokens).toContain("界!");
expect(tokens).toContain("た…");
expect(tokens).toContain("す。");
expect(tokens).toContain("ュ");
expect(tokens).toContain("「素");
expect(tokens).toContain("晴");
expect(tokens).toContain("ら");
expect(tokens).toContain("し");
expect(tokens).toContain("い!」");
expect(tokens).toContain("君");
expect(tokens).toContain("は、");
expect(tokens).toContain("(た");
expect(tokens).toContain("な");
expect(tokens).toContain("ば");
expect(tokens).toContain("た)");
expect(tokens).toContain("で");
expect(tokens).toContain("【一");
expect(tokens).toContain("番】");
// Check for Korean
expect(tokens).toContain("안");
expect(tokens).toContain("녕");
expect(tokens).toContain("하");
expect(tokens).toContain("세");
expect(tokens).toContain("요");
expect(tokens).toContain("세");
expect(tokens).toContain("계;");
expect(tokens).toContain("요』,");
expect(tokens).toContain("다.");
expect(tokens).toContain("다...");
expect(tokens).toContain("원/");
expect(tokens).toContain("달");
expect(tokens).toContain("(((다)))");
expect(tokens).toContain("〚({((한))>)〛");
expect(tokens).toContain("(「た」)");
// Numbers and units
expect(tokens).toContain("¥3700.55");
expect(tokens).toContain("090-");
expect(tokens).toContain("1234-");
expect(tokens).toContain("5678");
expect(tokens).toContain("¥1,000〜");
expect(tokens).toContain("$5,000");
expect(tokens).toContain("1:");
expect(tokens).toContain("30%");
expect(tokens).toContain("¥110±");
expect(tokens).toContain("20℃〜");
expect(tokens).toContain("9:30〜");
expect(tokens).toContain("10:00");
// Punctuation and symbols
expect(tokens).toContain(" ");
expect(tokens).toContain("\t");
expect(tokens).toContain(" ");
expect(tokens).toContain("ニ");
expect(tokens).toContain("ー");
expect(tokens).toContain("ヨ");
expect(tokens).toContain("〰");
expect(tokens).toContain("#");
});
});
});

View File

@@ -0,0 +1,115 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { newElementWith } from "../src/mutateElement";
import {
hasBoundTextElement,
isLinearElement,
isTextElement,
} from "../src/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "../src/types";
describe("Test TypeChecks", () => {
describe("Test hasBoundTextElement", () => {
it("should return true for text bindable containers with bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "rectangle",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "ellipse",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
expect(
hasBoundTextElement(
API.createElement({
type: "arrow",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeTruthy();
});
it("should return false for text bindable containers without bound text", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "arrow", id: "arrow-id" }],
}),
),
).toBeFalsy();
});
it("should return false for non text bindable containers", () => {
expect(
hasBoundTextElement(
API.createElement({
type: "freedraw",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
expect(
hasBoundTextElement(
API.createElement({
type: "image",
boundElements: [{ type: "text", id: "text-id" }],
}),
),
).toBeFalsy();
});
});
describe("Test NonDeleted type", () => {
it("should only allow `isDeleted: false` elements", () => {
const element = API.createElement({ type: "rectangle" });
const deletedElement = newElementWith(element as ExcalidrawElement, {
isDeleted: true,
});
// @ts-expect-error deleted elements are not assignable to NonDeleted
const nonDeleted: NonDeletedExcalidrawElement = deletedElement;
// runtime narrowing is still required to treat an element as non-deleted
// @ts-expect-error generic elements are not assignable to NonDeleted
const nonDeletedGeneric: NonDeletedExcalidrawElement =
deletedElement as ExcalidrawElement;
expect(nonDeleted.isDeleted).toBe(true);
expect(nonDeletedGeneric.isDeleted).toBe(true);
});
it("should be preserved by type guards", () => {
const elements: NonDeletedExcalidrawElement[] = [
API.createElement({ type: "text", text: "text" }),
API.createElement({ type: "arrow" }),
];
const textElements: NonDeleted<ExcalidrawTextElement>[] =
elements.filter(isTextElement);
const linearElements: NonDeleted<ExcalidrawLinearElement>[] =
elements.filter(isLinearElement);
expect(textElements.length).toBe(1);
expect(linearElements.length).toBe(1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}