snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
3
packages/element/.eslintrc.json
Normal file
3
packages/element/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
||||
19
packages/element/README.md
Normal file
19
packages/element/README.md
Normal 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
3
packages/element/global.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
70
packages/element/package.json
Normal file
70
packages/element/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
446
packages/element/src/Scene.ts
Normal file
446
packages/element/src/Scene.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2832
packages/element/src/__tests__/__snapshots__/transform.test.ts.snap
Normal file
2832
packages/element/src/__tests__/__snapshots__/transform.test.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
60
packages/element/src/__tests__/flowchart.test.ts
Normal file
60
packages/element/src/__tests__/flowchart.test.ts
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
1129
packages/element/src/__tests__/stickyNote.test.ts
Normal file
1129
packages/element/src/__tests__/stickyNote.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1190
packages/element/src/__tests__/transform.test.ts
Normal file
1190
packages/element/src/__tests__/transform.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
packages/element/src/align.ts
Normal file
82
packages/element/src/align.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
288
packages/element/src/arrowEndpointText.ts
Normal file
288
packages/element/src/arrowEndpointText.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
32
packages/element/src/arrowheads.ts
Normal file
32
packages/element/src/arrowheads.ts
Normal 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;
|
||||
};
|
||||
575
packages/element/src/arrows/focus.ts
Normal file
575
packages/element/src/arrows/focus.ts
Normal 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;
|
||||
};
|
||||
45
packages/element/src/arrows/helpers.ts
Normal file
45
packages/element/src/arrows/helpers.ts
Normal 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;
|
||||
};
|
||||
3198
packages/element/src/binding.ts
Normal file
3198
packages/element/src/binding.ts
Normal file
File diff suppressed because it is too large
Load Diff
1571
packages/element/src/bounds.ts
Normal file
1571
packages/element/src/bounds.ts
Normal file
File diff suppressed because it is too large
Load Diff
1918
packages/element/src/bucketFill.ts
Normal file
1918
packages/element/src/bucketFill.ts
Normal file
File diff suppressed because it is too large
Load Diff
868
packages/element/src/collision.ts
Normal file
868
packages/element/src/collision.ts
Normal 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),
|
||||
);
|
||||
};
|
||||
68
packages/element/src/comparisons.ts
Normal file
68
packages/element/src/comparisons.ts
Normal 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";
|
||||
33
packages/element/src/containerCache.ts
Normal file
33
packages/element/src/containerCache.ts
Normal 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;
|
||||
};
|
||||
821
packages/element/src/convertToShape.ts
Normal file
821
packages/element/src/convertToShape.ts
Normal 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;
|
||||
};
|
||||
628
packages/element/src/cropElement.ts
Normal file
628
packages/element/src/cropElement.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
2213
packages/element/src/delta.ts
Normal file
2213
packages/element/src/delta.ts
Normal file
File diff suppressed because it is too large
Load Diff
144
packages/element/src/distance.ts
Normal file
144
packages/element/src/distance.ts
Normal 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)),
|
||||
);
|
||||
};
|
||||
114
packages/element/src/distribute.ts
Normal file
114
packages/element/src/distribute.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
};
|
||||
409
packages/element/src/dragElements.ts
Normal file
409
packages/element/src/dragElements.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
};
|
||||
519
packages/element/src/duplicate.ts
Normal file
519
packages/element/src/duplicate.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
2309
packages/element/src/elbowArrow.ts
Normal file
2309
packages/element/src/elbowArrow.ts
Normal file
File diff suppressed because it is too large
Load Diff
104
packages/element/src/elementLink.ts
Normal file
104
packages/element/src/elementLink.ts
Normal 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;
|
||||
};
|
||||
535
packages/element/src/embeddable.ts
Normal file
535
packages/element/src/embeddable.ts
Normal 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);
|
||||
};
|
||||
763
packages/element/src/flowchart.ts
Normal file
763
packages/element/src/flowchart.ts
Normal 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;
|
||||
};
|
||||
473
packages/element/src/fractionalIndex.ts
Normal file
473
packages/element/src/fractionalIndex.ts
Normal 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;
|
||||
};
|
||||
1011
packages/element/src/frame.ts
Normal file
1011
packages/element/src/frame.ts
Normal file
File diff suppressed because it is too large
Load Diff
466
packages/element/src/groups.ts
Normal file
466
packages/element/src/groups.ts
Normal 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()];
|
||||
};
|
||||
287
packages/element/src/heading.ts
Normal file
287
packages/element/src/heading.ts
Normal 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;
|
||||
153
packages/element/src/image.ts
Normal file
153
packages/element/src/image.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
108
packages/element/src/index.ts
Normal file
108
packages/element/src/index.ts
Normal 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";
|
||||
2844
packages/element/src/linearElementEditor.ts
Normal file
2844
packages/element/src/linearElementEditor.ts
Normal file
File diff suppressed because it is too large
Load Diff
196
packages/element/src/mutateElement.ts
Normal file
196
packages/element/src/mutateElement.ts
Normal 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;
|
||||
};
|
||||
660
packages/element/src/newElement.ts
Normal file
660
packages/element/src/newElement.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
112
packages/element/src/positionElementsOnGrid.ts
Normal file
112
packages/element/src/positionElementsOnGrid.ts
Normal 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;
|
||||
};
|
||||
1333
packages/element/src/renderElement.ts
Normal file
1333
packages/element/src/renderElement.ts
Normal file
File diff suppressed because it is too large
Load Diff
1594
packages/element/src/resizeElements.ts
Normal file
1594
packages/element/src/resizeElements.ts
Normal file
File diff suppressed because it is too large
Load Diff
294
packages/element/src/resizeTest.ts
Normal file
294
packages/element/src/resizeTest.ts
Normal 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],
|
||||
};
|
||||
};
|
||||
308
packages/element/src/selection.ts
Normal file
308
packages/element/src/selection.ts
Normal 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;
|
||||
};
|
||||
1312
packages/element/src/shape.ts
Normal file
1312
packages/element/src/shape.ts
Normal file
File diff suppressed because it is too large
Load Diff
22
packages/element/src/showSelectedShapeActions.ts
Normal file
22
packages/element/src/showSelectedShapeActions.ts
Normal 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),
|
||||
);
|
||||
284
packages/element/src/sizeHelpers.ts
Normal file
284
packages/element/src/sizeHelpers.ts
Normal 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;
|
||||
};
|
||||
119
packages/element/src/sortElements.ts
Normal file
119
packages/element/src/sortElements.ts
Normal 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));
|
||||
};
|
||||
971
packages/element/src/stickyNote.ts
Normal file
971
packages/element/src/stickyNote.ts
Normal 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);
|
||||
};
|
||||
1037
packages/element/src/store.ts
Normal file
1037
packages/element/src/store.ts
Normal file
File diff suppressed because it is too large
Load Diff
588
packages/element/src/textElement.ts
Normal file
588
packages/element/src/textElement.ts
Normal 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;
|
||||
227
packages/element/src/textMeasurements.ts
Normal file
227
packages/element/src/textMeasurements.ts
Normal 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);
|
||||
};
|
||||
740
packages/element/src/textWrapping.ts
Normal file
740
packages/element/src/textWrapping.ts
Normal 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!");
|
||||
}
|
||||
}
|
||||
};
|
||||
895
packages/element/src/transform.ts
Normal file
895
packages/element/src/transform.ts
Normal 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();
|
||||
};
|
||||
354
packages/element/src/transformHandles.ts
Normal file
354
packages/element/src/transformHandles.ts
Normal 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;
|
||||
};
|
||||
432
packages/element/src/typeChecks.ts
Normal file
432
packages/element/src/typeChecks.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
496
packages/element/src/types.ts
Normal file
496
packages/element/src/types.ts
Normal 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;
|
||||
788
packages/element/src/utils.ts
Normal file
788
packages/element/src/utils.ts
Normal 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;
|
||||
};
|
||||
257
packages/element/src/visualdebug.ts
Normal file
257
packages/element/src/visualdebug.ts
Normal 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);
|
||||
};
|
||||
676
packages/element/src/zindex.ts
Normal file
676
packages/element/src/zindex.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
`;
|
||||
1097
packages/element/tests/align.test.tsx
Normal file
1097
packages/element/tests/align.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
817
packages/element/tests/binding.test.tsx
Normal file
817
packages/element/tests/binding.test.tsx
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
209
packages/element/tests/bounds.test.ts
Normal file
209
packages/element/tests/bounds.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
2272
packages/element/tests/bucketFill.test.ts
Normal file
2272
packages/element/tests/bucketFill.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
220
packages/element/tests/collision.test.tsx
Normal file
220
packages/element/tests/collision.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
363
packages/element/tests/cropElement.test.tsx
Normal file
363
packages/element/tests/cropElement.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
853
packages/element/tests/delta.test.tsx
Normal file
853
packages/element/tests/delta.test.tsx
Normal 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 },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
packages/element/tests/distribute.test.tsx
Normal file
128
packages/element/tests/distribute.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
865
packages/element/tests/duplicate.test.tsx
Normal file
865
packages/element/tests/duplicate.test.tsx
Normal 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 }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
416
packages/element/tests/elbowArrow.test.tsx
Normal file
416
packages/element/tests/elbowArrow.test.tsx
Normal 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
233
packages/element/tests/embeddable.test.ts
Normal file
233
packages/element/tests/embeddable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
683
packages/element/tests/flowchart.test.tsx
Normal file
683
packages/element/tests/flowchart.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
877
packages/element/tests/fractionalIndex.test.ts
Normal file
877
packages/element/tests/fractionalIndex.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
1185
packages/element/tests/frame.test.tsx
Normal file
1185
packages/element/tests/frame.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2026
packages/element/tests/linearElementEditor.test.tsx
Normal file
2026
packages/element/tests/linearElementEditor.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
52
packages/element/tests/newElement.test.ts
Normal file
52
packages/element/tests/newElement.test.ts
Normal 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 });
|
||||
},
|
||||
);
|
||||
});
|
||||
327
packages/element/tests/recognizeShape.test.ts
Normal file
327
packages/element/tests/recognizeShape.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
1560
packages/element/tests/resize.test.tsx
Normal file
1560
packages/element/tests/resize.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
35
packages/element/tests/selection.test.ts
Normal file
35
packages/element/tests/selection.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
58
packages/element/tests/sizeHelpers.test.ts
Normal file
58
packages/element/tests/sizeHelpers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
446
packages/element/tests/sortElements.test.ts
Normal file
446
packages/element/tests/sortElements.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
379
packages/element/tests/textElement.test.ts
Normal file
379
packages/element/tests/textElement.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
703
packages/element/tests/textWrapping.test.ts
Normal file
703
packages/element/tests/textWrapping.test.ts
Normal 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("#");
|
||||
});
|
||||
});
|
||||
});
|
||||
115
packages/element/tests/typeChecks.test.ts
Normal file
115
packages/element/tests/typeChecks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1698
packages/element/tests/zindex.test.tsx
Normal file
1698
packages/element/tests/zindex.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/element/tsconfig.json
Normal file
9
packages/element/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user