Compare commits

..

4 Commits

Author SHA1 Message Date
Márk Tolmács
f55ecb96cc
fix: Mobile arrow point drag broken (#9998)
* fix: Mobile bound arrow point drag broken

* fix:Check real point
2025-09-19 19:41:03 +02:00
David Luzar
a6a32b9b29
fix: align MQ breakpoints and always use editor dimensions (#9991)
* fix: align MQ breakpoints and always use editor dimensions

* naming

* update snapshots
2025-09-17 07:57:10 +00:00
Márk Tolmács
ac0d3059dc
fix: Use the right polygon enclosure test (#9979) 2025-09-15 10:07:37 +02:00
Christopher Tangonan
1161f1b8ba
fix: eraser can handle dots without regressing prior performance improvements (#9946)
Co-authored-by: Márk Tolmács <mark@lazycat.hu>
2025-09-14 11:33:43 +00:00
92 changed files with 4772 additions and 6124 deletions

View File

@ -662,8 +662,8 @@ const ExcalidrawWrapper = () => {
debugRenderer( debugRenderer(
debugCanvasRef.current, debugCanvasRef.current,
appState, appState,
elements,
window.devicePixelRatio, window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
); );
} }
}; };

View File

@ -8,15 +8,9 @@ import {
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common"; import { throttleRAF } from "@excalidraw/common";
import { useCallback } from "react"; import { useCallback } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
type GlobalPoint, type GlobalPoint,
@ -27,14 +21,8 @@ import { isCurve } from "@excalidraw/math/curve";
import React from "react"; import React from "react";
import type { Curve } from "@excalidraw/math"; import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/common";
import type { import type { DebugElement } from "@excalidraw/utils/visualdebug";
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
@ -87,176 +75,6 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save(); context.save();
}; };
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom - width,
y * zoom - height,
x * zoom - width,
y * zoom + height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const _renderBindableBinding = (
binding: FixedPointBinding,
context: CanvasRenderingContext2D,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
const bindable = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
if (!binding.fixedPoint) {
console.warn("Binding must have a fixedPoint");
return;
}
const [x, y] = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
bindable,
elementsMap,
);
context.save();
context.strokeStyle = color;
context.lineWidth = 1;
context.beginPath();
context.moveTo(x * zoom, y * zoom);
context.bezierCurveTo(
x * zoom + width,
y * zoom + height,
x * zoom + width,
y * zoom - height,
x * zoom,
y * zoom,
);
context.stroke();
context.restore();
};
const renderBindings = (
context: CanvasRenderingContext2D,
elements: readonly OrderedExcalidrawElement[],
zoom: number,
) => {
const elementsMap = arrayToMap(elements);
const dim = 16;
elements.forEach((element) => {
if (element.isDeleted) {
return;
}
if (isArrowElement(element)) {
if (element.startBinding) {
if (
!elementsMap
.get(element.startBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.startBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
if (element.endBinding) {
if (
!elementsMap
.get(element.endBinding.elementId)
?.boundElements?.find((e) => e.id === element.id)
) {
return;
}
_renderBinding(
context,
element.endBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
}
if (isBindableElement(element) && element.boundElements?.length) {
element.boundElements.forEach((boundElement) => {
if (boundElement.type !== "arrow") {
return;
}
const arrow = elementsMap.get(
boundElement.id,
) as ExcalidrawArrowElement;
if (arrow && arrow.startBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.startBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
if (arrow && arrow.endBinding?.elementId === element.id) {
_renderBindableBinding(
arrow.endBinding,
context,
elementsMap,
zoom,
dim,
dim,
"green",
);
}
});
}
});
};
const render = ( const render = (
frame: DebugElement[], frame: DebugElement[],
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@ -289,8 +107,8 @@ const render = (
const _debugRenderer = ( const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
@ -313,7 +131,6 @@ const _debugRenderer = (
); );
renderOrigin(context, appState.zoom.value); renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if ( if (
window.visualDebug?.currentFrame && window.visualDebug?.currentFrame &&
@ -365,10 +182,10 @@ export const debugRenderer = throttleRAF(
( (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number, scale: number,
refresh: () => void,
) => { ) => {
_debugRenderer(canvas, appState, elements, scale); _debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );

View File

@ -16,6 +16,7 @@ import {
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
debounce, debounce,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import { import {
createStore, createStore,
entries, entries,
@ -80,7 +81,7 @@ const saveDataStateToLocalStorage = (
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(elements), JSON.stringify(clearElementsForLocalStorage(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,

View File

@ -2,6 +2,7 @@ import {
clearAppStateForLocalStorage, clearAppStateForLocalStorage,
getDefaultAppState, getDefaultAppState,
} from "@excalidraw/excalidraw/appState"; } from "@excalidraw/excalidraw/appState";
import { clearElementsForLocalStorage } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@ -49,7 +50,7 @@ export const importFromLocalStorage = () => {
let elements: ExcalidrawElement[] = []; let elements: ExcalidrawElement[] = [];
if (savedElements) { if (savedElements) {
try { try {
elements = JSON.parse(savedElements); elements = clearElementsForLocalStorage(JSON.parse(savedElements));
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
// Do nothing because elements array is already empty // Do nothing because elements array is already empty

View File

@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
}, },
"isTouchScreen": false, "isTouchScreen": false,
"viewport": { "viewport": {
"isLandscape": false, "isLandscape": true,
"isMobile": true, "isMobile": true,
}, },
} }

View File

@ -347,15 +347,12 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints // breakpoints
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// md screen
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// mobile: up to 699px // mobile: up to 699px
export const MQ_MAX_WIDTH_MOBILE = 699; export const MQ_MAX_MOBILE = 599;
// tablets // tablets
export const MQ_MIN_TABLET = 600; // lower bound (excludes phones) export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop // desktop/laptop
@ -539,5 +536,3 @@ export enum UserIdleState {
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35; export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms

View File

@ -10,4 +10,3 @@ export * from "./random";
export * from "./url"; export * from "./url";
export * from "./utils"; export * from "./utils";
export * from "./emitter"; export * from "./emitter";
export * from "./visualdebug";

View File

@ -1,6 +1,10 @@
import { average } from "@excalidraw/math"; import { average } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "@excalidraw/element/types";
import type { import type {
ActiveTool, ActiveTool,
@ -564,6 +568,9 @@ export const isTransparent = (color: string) => {
); );
}; };
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & { export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void ? (value?: MaybePromise<Awaited<T>>) => void

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { invariant, isTransparent } from "@excalidraw/common"; import { isTransparent } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@ -34,13 +34,10 @@ import {
elementCenterPoint, elementCenterPoint,
getCenterForBounds, getCenterForBounds,
getCubicBezierCurveBound, getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds, getElementBounds,
} from "./bounds"; } from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
@ -61,17 +58,12 @@ import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
@ -102,7 +94,6 @@ export type HitTestArgs = {
threshold: number; threshold: number;
elementsMap: ElementsMap; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
overrideShouldTestInside?: boolean;
}; };
export const hitElementItself = ({ export const hitElementItself = ({
@ -111,7 +102,6 @@ export const hitElementItself = ({
threshold, threshold,
elementsMap, elementsMap,
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
@ -144,9 +134,7 @@ export const hitElementItself = ({
} }
// Do the precise (and relatively costly) hit test // Do the precise (and relatively costly) hit test
const hitElement = ( const hitElement = shouldTestInside(element)
overrideShouldTestInside ? true : shouldTestInside(element)
)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) || isPointInElement(point, element, elementsMap) ||
@ -205,102 +193,6 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap); 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;
}
// 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,
toleranceFn?: (element: ExcalidrawBindableElement) => 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, toleranceFn?.(element))
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
break;
}
}
}
return candidateElements;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
);
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>;
};
/** /**
* Intersect a line with an element for binding test * Intersect a line with an element for binding test
* *
@ -662,61 +554,3 @@ export const isPointInElement = (
return intersections.length % 2 === 1; return intersections.length % 2 === 1;
}; };
export const isBindableElementInsideOtherBindable = (
innerElement: ExcalidrawBindableElement,
outerElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): boolean => {
// Get corner points of the inner element based on its type
const getCornerPoints = (
element: ExcalidrawElement,
offset: number,
): GlobalPoint[] => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
// Diamond has 4 corner points at the middle of each side
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const corners: GlobalPoint[] = [
pointFrom(x + topX, y + topY - offset), // top
pointFrom(x + rightX + offset, y + rightY), // right
pointFrom(x + bottomX, y + bottomY + offset), // bottom
pointFrom(x + leftX - offset, y + leftY), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
if (element.type === "ellipse") {
// For ellipse, test points at the extremes (top, right, bottom, left)
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
const corners: GlobalPoint[] = [
pointFrom(cx, cy - ry - offset), // top
pointFrom(cx + rx + offset, cy), // right
pointFrom(cx, cy + ry + offset), // bottom
pointFrom(cx - rx - offset, cy), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
// Rectangle and other rectangular shapes (image, text, etc.)
const corners: GlobalPoint[] = [
pointFrom(x - offset, y - offset), // top-left
pointFrom(x + width + offset, y - offset), // top-right
pointFrom(x + width + offset, y + height + offset), // bottom-right
pointFrom(x - offset, y + height + offset), // bottom-left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
};
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
const innerCorners = getCornerPoints(innerElement, offset);
// Check if all corner points of the inner element are inside the outer element
return innerCorners.every((corner) =>
isPointInElement(corner, outerElement, elementsMap),
);
};

View File

@ -2,7 +2,6 @@ import {
TEXT_AUTOWRAP_THRESHOLD, TEXT_AUTOWRAP_THRESHOLD,
getGridPoint, getGridPoint,
getFontString, getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@ -14,7 +13,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { unbindBindingElement, updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@ -103,26 +102,9 @@ export const dragSelectedElements = (
gridSize, gridSize,
); );
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => { elementsToUpdate.forEach((element) => {
const isArrow = !isArrowElement(element); updateElementCoords(pointerDownState, element, scene, adjustedOffset);
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)) { if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
element, element,
@ -139,33 +121,6 @@ export const dragSelectedElements = (
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate), 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);
}
}
} }
}); });
}; };

View File

@ -17,6 +17,7 @@ import {
BinaryHeap, BinaryHeap,
invariant, invariant,
isAnyTrue, isAnyTrue,
tupleToCoors,
getSizeFromPoints, getSizeFromPoints,
isDevEnv, isDevEnv,
arrayToMap, arrayToMap,
@ -29,7 +30,7 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getFixedBindingDistance, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
@ -50,8 +51,8 @@ import {
type ExcalidrawElbowArrowElement, type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap, type NonDeletedSceneElementsMap,
} from "./types"; } from "./types";
import { aabbForElement, pointInsideBounds } from "./bounds"; import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
import type { Heading } from "./heading"; import type { Heading } from "./heading";
@ -62,7 +63,6 @@ import type {
FixedPointBinding, FixedPointBinding,
FixedSegment, FixedSegment,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Ordered,
} from "./types"; } from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" }; type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -1217,9 +1217,19 @@ const getElbowArrowData = (
if (options?.isDragging) { if (options?.isDragging) {
const elements = Array.from(elementsMap.values()); const elements = Array.from(elementsMap.values());
hoveredStartElement = hoveredStartElement =
getHoveredElement(origStartGlobalPoint, elementsMap, elements) || null; getHoveredElement(
origStartGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
hoveredEndElement = hoveredEndElement =
getHoveredElement(origEndGlobalPoint, elementsMap, elements) || null; getHoveredElement(
origEndGlobalPoint,
elementsMap,
elements,
options?.zoom,
) || null;
} else { } else {
hoveredStartElement = arrow.startBinding hoveredStartElement = arrow.startBinding
? getBindableElementForId(arrow.startBinding.elementId, elementsMap) || ? getBindableElementForId(arrow.startBinding.elementId, elementsMap) ||
@ -1291,8 +1301,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
? getFixedBindingDistance(hoveredStartElement) * 6 ? FIXED_BINDING_DISTANCE * 6
: getFixedBindingDistance(hoveredStartElement) * 2, : FIXED_BINDING_DISTANCE * 2,
1, 1,
), ),
) )
@ -1304,8 +1314,8 @@ const getElbowArrowData = (
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
? getFixedBindingDistance(hoveredEndElement) * 6 ? FIXED_BINDING_DISTANCE * 6
: getFixedBindingDistance(hoveredEndElement) * 2, : FIXED_BINDING_DISTANCE * 2,
1, 1,
), ),
) )
@ -2252,13 +2262,16 @@ const getBindPointHeading = (
const getHoveredElement = ( const getHoveredElement = (
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
elements: readonly Ordered<NonDeletedExcalidrawElement>[], elements: readonly NonDeletedExcalidrawElement[],
zoom?: AppState["zoom"],
) => { ) => {
return getHoveredElementForBinding( return getHoveredElementForBinding(
origPoint, tupleToCoors(origPoint),
elements, elements,
elementsMap, elementsMap,
(element) => getFixedBindingDistance(element) + 1, zoom,
true,
true,
); );
}; };

View File

@ -7,7 +7,7 @@ import type {
PendingExcalidrawElements, PendingExcalidrawElements,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { bindBindingElement } from "./binding"; import { bindLinearElement } from "./binding";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import { import {
HEADING_DOWN, HEADING_DOWN,
@ -446,14 +446,8 @@ const createBindingArrow = (
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
bindBindingElement( bindLinearElement(bindingArrow, startBindingElement, "start", scene);
bindingArrow, bindLinearElement(bindingArrow, endBindingElement, "end", scene);
startBindingElement,
"orbit",
"start",
scene,
);
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
const changedElements = new Map<string, OrderedExcalidrawElement>(); const changedElements = new Map<string, OrderedExcalidrawElement>();
changedElements.set( changedElements.set(

View File

@ -1,6 +1,7 @@
import { toIterable } from "@excalidraw/common"; import { toIterable } from "@excalidraw/common";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@ -51,6 +52,27 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,
): element is NonDeleted<T> => !element.isDeleted; ): element is NonDeleted<T> => !element.isDeleted;
const _clearElements = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] =>
getNonDeletedElements(elements).map((element) =>
isLinearElementType(element.type)
? { ...element, lastCommittedPoint: null }
: element,
);
export const clearElementsForDatabase = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForExport = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export * from "./align"; export * from "./align";
export * from "./binding"; export * from "./binding";
export * from "./bounds"; export * from "./bounds";

File diff suppressed because it is too large Load Diff

View File

@ -46,13 +46,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator // casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732) // (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId } = updates as any; const { points, fixedSegments, startBinding, endBinding, fileId } =
updates as any;
if ( if (
isElbowArrow(element) && isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case (Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) { ) {
updates = { updates = {
...updates, ...updates,

View File

@ -452,6 +452,7 @@ export const newFreeDrawElement = (
points: opts.points || [], points: opts.points || [],
pressures: opts.pressures || [], pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure, simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
}; };
}; };
@ -465,7 +466,7 @@ export const newLinearElement = (
const element = { const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: null, startArrowhead: null,
@ -500,6 +501,7 @@ export const newArrowElement = <T extends boolean>(
return { return {
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,
@ -514,6 +516,7 @@ export const newArrowElement = <T extends boolean>(
return { return {
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts), ..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead || null, startArrowhead: opts.startArrowhead || null,

View File

@ -1,7 +1,14 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand"; import { getStroke } from "perfect-freehand";
import { isRightAngleRads } from "@excalidraw/math"; import {
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { import {
BOUND_TEXT_PADDING, BOUND_TEXT_PADDING,
@ -14,6 +21,7 @@ import {
getFontString, getFontString,
isRTL, isRTL,
getVerticalOffset, getVerticalOffset,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@ -32,7 +40,7 @@ import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types"; } from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords } from "./bounds"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { getUncroppedImageElement } from "./cropElement"; import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { import {
@ -90,7 +98,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = ( const shouldResetImageFilter = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
return ( return (
appState.theme === THEME.DARK && appState.theme === THEME.DARK &&
@ -217,7 +225,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
): ExcalidrawElementWithCanvas | null => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@ -269,7 +277,7 @@ const generateElementCanvas = (
context.filter = IMAGE_INVERT_FILTER; context.filter = IMAGE_INVERT_FILTER;
} }
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore(); context.restore();
@ -404,6 +412,7 @@ const drawElementOnCanvas = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => { ) => {
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
@ -549,7 +558,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const zoom: Zoom = renderConfig const zoom: Zoom = renderConfig
? appState.zoom ? appState.zoom
@ -606,7 +615,7 @@ const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas, elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap, allElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
const element = elementWithCanvas.element; const element = elementWithCanvas.element;
@ -724,7 +733,7 @@ export const renderElement = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState | InteractiveCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const reduceAlphaForSelection = const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" && appState.openDialog?.name === "elementLinkSelector" &&
@ -794,7 +803,7 @@ export const renderElement = (
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(element.angle); context.rotate(element.angle);
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig, appState);
context.restore(); context.restore();
} else { } else {
const elementWithCanvas = generateElementWithCanvas( const elementWithCanvas = generateElementWithCanvas(
@ -887,7 +896,13 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY); tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig); drawElementOnCanvas(
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY); tempCanvasContext.translate(shiftX, shiftY);
@ -926,7 +941,7 @@ export const renderElement = (
} }
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig); drawElementOnCanvas(element, rc, context, renderConfig, appState);
} }
context.restore(); context.restore();
@ -1032,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
} }
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot // If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure const inputPoints = element.simulatePressure
? element.points ? element.points
@ -1047,10 +1122,10 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
smoothing: 0.5, smoothing: 0.5,
streamline: 0.5, streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true, last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
}; };
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); return getStroke(inputPoints as number[][], options) as [number, number][];
} }
function med(A: number[], B: number[]) { function med(A: number[], B: number[]) {

View File

@ -20,11 +20,7 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import { import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
getArrowLocalFixedPoints,
unbindBindingElement,
updateBoundElements,
} from "./binding";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
getCommonBounds, getCommonBounds,
@ -50,7 +46,6 @@ import {
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { import {
isArrowElement, isArrowElement,
isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
@ -79,9 +74,7 @@ import type {
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
} from "./types"; } from "./types";
import type { ElementUpdate } from "./mutateElement";
// Returns true when transform (resizing/rotation) happened // Returns true when transform (resizing/rotation) happened
export const transformElements = ( export const transformElements = (
@ -227,25 +220,7 @@ const rotateSingleElement = (
} }
const boundTextElementId = getBoundTextElementId(element); const boundTextElementId = getBoundTextElementId(element);
let update: ElementUpdate<NonDeletedExcalidrawElement> = { scene.mutateElement(element, { angle });
angle,
};
if (isBindingElement(element)) {
update = {
...update,
} as ElementUpdate<ExcalidrawArrowElement>;
if (element.startBinding) {
unbindBindingElement(element, "start", scene);
}
if (element.endBinding) {
unbindBindingElement(element, "end", scene);
}
}
scene.mutateElement(element, update);
if (boundTextElementId) { if (boundTextElementId) {
const textElement = const textElement =
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId); scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
@ -419,11 +394,6 @@ const rotateMultipleElements = (
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE; centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
} }
const rotatedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elements.map((element) => [element.id, element]));
for (const element of elements) { for (const element of elements) {
if (!isFrameLikeElement(element)) { if (!isFrameLikeElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@ -454,19 +424,6 @@ const rotateMultipleElements = (
simultaneouslyUpdated: elements, simultaneouslyUpdated: elements,
}); });
if (isBindingElement(element)) {
if (element.startBinding) {
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundText = getBoundTextElement(element, elementsMap); const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) { if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition( const { x, y } = computeBoundTextPosition(
@ -878,32 +835,13 @@ export const resizeSingleElement = (
Number.isFinite(newOrigin.x) && Number.isFinite(newOrigin.x) &&
Number.isFinite(newOrigin.y) Number.isFinite(newOrigin.y)
) { ) {
let updates: ElementUpdate<ExcalidrawElement> = { const updates = {
...newOrigin, ...newOrigin,
width: Math.abs(nextWidth), width: Math.abs(nextWidth),
height: Math.abs(nextHeight), height: Math.abs(nextHeight),
...rescaledPoints, ...rescaledPoints,
}; };
if (isBindingElement(latestElement)) {
if (latestElement.startBinding) {
updates = {
...updates,
} as ElementUpdate<ExcalidrawArrowElement>;
if (latestElement.startBinding) {
unbindBindingElement(latestElement, "start", scene);
}
}
if (latestElement.endBinding) {
updates = {
...updates,
endBinding: null,
} as ElementUpdate<ExcalidrawArrowElement>;
}
}
scene.mutateElement(latestElement, updates, { scene.mutateElement(latestElement, updates, {
informMutation: shouldInformMutation, informMutation: shouldInformMutation,
isDragging: false, isDragging: false,
@ -921,7 +859,10 @@ export const resizeSingleElement = (
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
); );
updateBoundElements(latestElement, scene); updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
} }
}; };
@ -1455,36 +1396,20 @@ export const resizeMultipleElements = (
} }
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
const resizedElementsMap = new Map<
ExcalidrawElement["id"],
NonDeletedExcalidrawElement
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
for (const { for (const {
element, element,
update: { boundTextFontSize, ...update }, update: { boundTextFontSize, ...update },
} of elementsAndUpdates) { } of elementsAndUpdates) {
const { angle } = update; const { width, height, angle } = update;
scene.mutateElement(element, update); scene.mutateElement(element, update);
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
}); });
if (isBindingElement(element)) {
if (element.startBinding) {
if (!resizedElementsMap.has(element.startBinding.elementId)) {
unbindBindingElement(element, "start", scene);
}
}
if (element.endBinding) {
if (!resizedElementsMap.has(element.endBinding.elementId)) {
unbindBindingElement(element, "end", scene);
}
}
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement && boundTextFontSize) { if (boundTextElement && boundTextFontSize) {
scene.mutateElement(boundTextElement, { scene.mutateElement(boundTextElement, {

View File

@ -28,6 +28,8 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement, ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType, ExcalidrawLinearElementSubType,
} from "./types"; } from "./types";
@ -161,7 +163,7 @@ export const isLinearElementType = (
export const isBindingElement = ( export const isBindingElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
includeLocked = true, includeLocked = true,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawLinearElement => {
return ( return (
element != null && element != null &&
(!element.locked || includeLocked === true) && (!element.locked || includeLocked === true) &&
@ -356,6 +358,15 @@ export const getDefaultRoundnessTypeForElement = (
return null; return null;
}; };
export const isFixedPointBinding = (
binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => {
return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
};
// TODO: Move this to @excalidraw/math // TODO: Move this to @excalidraw/math
export const isBounds = (box: unknown): box is Bounds => export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) && Array.isArray(box) &&

View File

@ -279,23 +279,24 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number]; export type FixedPoint = [number, number];
export type BindMode = "inside" | "orbit" | "skip"; export type PointBinding = {
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number;
// Represents the fixed point binding information in form of a vertical and gap: number;
// 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;
}; };
export type FixedPointBinding = Merge<
PointBinding,
{
// 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;
}
>;
type Index = number; type Index = number;
export type PointsPositionUpdates = Map< export type PointsPositionUpdates = Map<
@ -321,8 +322,9 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "line" | "arrow"; type: "line" | "arrow";
points: readonly LocalPoint[]; points: readonly LocalPoint[];
startBinding: FixedPointBinding | null; lastCommittedPoint: LocalPoint | null;
endBinding: FixedPointBinding | null; startBinding: PointBinding | null;
endBinding: PointBinding | null;
startArrowhead: Arrowhead | null; startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
@ -349,9 +351,9 @@ export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement, ExcalidrawArrowElement,
{ {
elbowed: true; elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: FixedPointBinding | null; startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null; endBinding: FixedPointBinding | null;
fixedSegments: readonly FixedSegment[] | null;
/** /**
* Marks that the 3rd point should be used as the 2nd point of the arrow in * 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 * order to temporarily hide the first segment of the arrow without losing
@ -377,6 +379,7 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
points: readonly LocalPoint[]; points: readonly LocalPoint[];
pressures: readonly number[]; pressures: readonly number[];
simulatePressure: boolean; simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null;
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };

View File

@ -1,25 +1,18 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
import { isFrameLikeElement, isTextElement } from "./typeChecks"; import { isFrameLikeElement } from "./typeChecks";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex"; import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type {
ExcalidrawArrowElement, import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
OrderedExcalidrawElement,
} from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId; return element.frameId === frameId || element.id === frameId;
@ -146,51 +139,6 @@ const getContiguousFrameRangeElements = (
return allElements.slice(rangeStart, rangeEnd + 1); 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 Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
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 * 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). * is a non-deleted element, and not inside a group (unless we're editing it).

File diff suppressed because it is too large Load Diff

View File

@ -144,8 +144,9 @@ describe("duplicating multiple elements", () => {
id: "arrow1", id: "arrow1",
startBinding: { startBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });
@ -154,8 +155,9 @@ describe("duplicating multiple elements", () => {
id: "arrow2", id: "arrow2",
endBinding: { endBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
boundElements: [{ id: "text2", type: "text" }], boundElements: [{ id: "text2", type: "text" }],
}); });
@ -274,8 +276,9 @@ describe("duplicating multiple elements", () => {
id: "arrow1", id: "arrow1",
startBinding: { startBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });
@ -290,13 +293,15 @@ describe("duplicating multiple elements", () => {
id: "arrow2", id: "arrow2",
startBinding: { startBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
endBinding: { endBinding: {
elementId: "rectangle-not-exists", elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });
@ -305,13 +310,15 @@ describe("duplicating multiple elements", () => {
id: "arrow3", id: "arrow3",
startBinding: { startBinding: {
elementId: "rectangle-not-exists", elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
endBinding: { endBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });
@ -814,7 +821,7 @@ describe("duplication z-order", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -100, x: -100,
y: 50, y: 50,
width: 115, width: 95,
height: 0, height: 0,
}); });

View File

@ -1,10 +1,13 @@
import { ARROW_TYPE } from "@excalidraw/common"; import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { actionSelectAll } from "@excalidraw/excalidraw/actions"; import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection"; import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import { import {
act, act,
fireEvent, fireEvent,
@ -12,11 +15,13 @@ import {
queryByTestId, queryByTestId,
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import { Scene } from "../src/Scene"; import { Scene } from "../src/Scene";
import type { import type {
@ -131,11 +136,6 @@ describe("elbow arrow segment move", () => {
}); });
describe("elbow arrow routing", () => { describe("elbow arrow routing", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("can properly generate orthogonal arrow points", () => { it("can properly generate orthogonal arrow points", () => {
const scene = new Scene(); const scene = new Scene();
const arrow = API.createElement({ const arrow = API.createElement({
@ -160,8 +160,8 @@ describe("elbow arrow routing", () => {
expect(arrow.width).toEqual(90); expect(arrow.width).toEqual(90);
expect(arrow.height).toEqual(200); expect(arrow.height).toEqual(200);
}); });
it("can generate proper points for bound elbow arrow", () => { it("can generate proper points for bound elbow arrow", () => {
const scene = new Scene();
const rectangle1 = API.createElement({ const rectangle1 = API.createElement({
type: "rectangle", type: "rectangle",
x: -150, x: -150,
@ -185,23 +185,25 @@ describe("elbow arrow routing", () => {
height: 200, height: 200,
points: [pointFrom(0, 0), pointFrom(90, 200)], points: [pointFrom(0, 0), pointFrom(90, 200)],
}) as ExcalidrawElbowArrowElement; }) as ExcalidrawElbowArrowElement;
API.setElements([rectangle1, rectangle2, arrow]); scene.insertElement(rectangle1);
scene.insertElement(rectangle2);
scene.insertElement(arrow);
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene); bindLinearElement(arrow, rectangle1, "start", scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene); bindLinearElement(arrow, rectangle2, "end", scene);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
h.scene.mutateElement(arrow, { h.app.scene.mutateElement(arrow, {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)], points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
}); });
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[44, 0], [45, 0],
[44, 200], [45, 200],
[88, 200], [90, 200],
]); ]);
}); });
}); });
@ -240,9 +242,9 @@ describe("elbow arrow ui", () => {
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow); expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
mouse.reset(); mouse.reset();
mouse.moveTo(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@ -253,9 +255,9 @@ describe("elbow arrow ui", () => {
expect(arrow.elbowed).toBe(true); expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[44, 0], [45, 0],
[44, 200], [45, 200],
[88, 200], [90, 200],
]); ]);
}); });
@ -277,9 +279,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow"); UI.clickOnTestId("elbow-arrow");
mouse.reset(); mouse.reset();
mouse.moveTo(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@ -295,11 +297,9 @@ describe("elbow arrow ui", () => {
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([ expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
[0, 0], [0, 0],
[36, 0], [35, 0],
[36, 90], [35, 165],
[28, 90], [103, 165],
[28, 164],
[101, 164],
]); ]);
}); });
@ -321,9 +321,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow"); UI.clickOnTestId("elbow-arrow");
mouse.reset(); mouse.reset();
mouse.moveTo(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@ -353,9 +353,9 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.elbowed).toBe(true); expect(duplicatedArrow.elbowed).toBe(true);
expect(duplicatedArrow.points).toEqual([ expect(duplicatedArrow.points).toEqual([
[0, 0], [0, 0],
[44, 0], [45, 0],
[44, 200], [45, 200],
[88, 200], [90, 200],
]); ]);
expect(arrow.startBinding).not.toBe(null); expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null);
@ -379,9 +379,9 @@ describe("elbow arrow ui", () => {
UI.clickOnTestId("elbow-arrow"); UI.clickOnTestId("elbow-arrow");
mouse.reset(); mouse.reset();
mouse.moveTo(-53, -99); mouse.moveTo(-43, -99);
mouse.click(); mouse.click();
mouse.moveTo(53, 99); mouse.moveTo(43, 99);
mouse.click(); mouse.click();
const arrow = h.scene.getSelectedElements( const arrow = h.scene.getSelectedElements(
@ -408,8 +408,8 @@ describe("elbow arrow ui", () => {
expect(duplicatedArrow.points).toEqual([ expect(duplicatedArrow.points).toEqual([
[0, 0], [0, 0],
[0, 100], [0, 100],
[88, 100], [90, 100],
[88, 200], [90, 200],
]); ]);
}); });
}); });

View File

@ -217,7 +217,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint // drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
@ -329,7 +329,7 @@ describe("Test Linear Elements", () => {
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
mouse.doubleClick(); mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement).toBe(null);
await getTextEditor(); await getTextEditor();
}); });
@ -357,7 +357,6 @@ describe("Test Linear Elements", () => {
const originalY = line.y; const originalY = line.y;
enterLineEditingMode(line); enterLineEditingMode(line);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(line.points.length).toEqual(2); expect(line.points.length).toEqual(2);
mouse.clickAt(midpoint[0], midpoint[1]); mouse.clickAt(midpoint[0], midpoint[1]);
@ -380,7 +379,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
@ -550,7 +549,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@ -601,7 +600,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -642,7 +641,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -690,7 +689,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
line, line,
@ -748,7 +747,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`, `14`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points) expect((h.elements[0] as ExcalidrawLinearElement).points)
@ -846,7 +845,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`, `11`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@ -1304,7 +1303,7 @@ describe("Test Linear Elements", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -10, x: -10,
y: 250, y: 250,
width: 410, width: 400,
height: 1, height: 1,
}); });
@ -1317,7 +1316,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(arrow.endBinding?.elementId).toBe(rect.id); expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(404); expect(arrow.width).toBe(400);
expect(rect.x).toBe(400); expect(rect.x).toBe(400);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect( expect(
@ -1336,7 +1335,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204); expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

View File

@ -174,29 +174,29 @@ describe("generic element", () => {
expect(rectangle.angle).toBeCloseTo(0); expect(rectangle.angle).toBeCloseTo(0);
}); });
// it("resizes with bound arrow", async () => { it("resizes with bound arrow", async () => {
// const rectangle = UI.createElement("rectangle", { const rectangle = UI.createElement("rectangle", {
// width: 200, width: 200,
// height: 100, height: 100,
// }); });
// const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
// x: -30, x: -30,
// y: 50, y: 50,
// width: 28, width: 28,
// height: 5, height: 5,
// }); });
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
// UI.resize(rectangle, "e", [40, 0]); UI.resize(rectangle, "e", [40, 0]);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
// UI.resize(rectangle, "w", [50, 0]); UI.resize(rectangle, "w", [50, 0]);
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
// }); });
it("resizes with a label", async () => { it("resizes with a label", async () => {
const rectangle = UI.createElement("rectangle", { const rectangle = UI.createElement("rectangle", {
@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@ -595,31 +595,31 @@ describe("text element", () => {
expect(text.fontSize).toBeCloseTo(fontSize * scale); expect(text.fontSize).toBeCloseTo(fontSize * scale);
}); });
// it("resizes with bound arrow", async () => { it("resizes with bound arrow", async () => {
// const text = UI.createElement("text"); const text = UI.createElement("text");
// await UI.editText(text, "hello\nworld"); await UI.editText(text, "hello\nworld");
// const boundArrow = UI.createElement("arrow", { const boundArrow = UI.createElement("arrow", {
// x: -30, x: -30,
// y: 25, y: 25,
// width: 28, width: 28,
// height: 5, height: 5,
// }); });
// expect(boundArrow.endBinding?.elementId).toEqual(text.id); expect(boundArrow.endBinding?.elementId).toEqual(text.id);
// UI.resize(text, "ne", [40, 0]); UI.resize(text, "ne", [40, 0]);
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30); expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
// const textWidth = text.width; const textWidth = text.width;
// const scale = 20 / text.height; const scale = 20 / text.height;
// UI.resize(text, "nw", [50, 20]); UI.resize(text, "nw", [50, 20]);
// expect(boundArrow.endBinding?.elementId).toEqual(text.id); expect(boundArrow.endBinding?.elementId).toEqual(text.id);
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo( expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
// 30 + textWidth * scale, 30 + textWidth * scale,
// ); );
// }); });
it("updates font size via keyboard", async () => { it("updates font size via keyboard", async () => {
const text = UI.createElement("text"); const text = UI.createElement("text");
@ -801,36 +801,36 @@ describe("image element", () => {
expect(image.scale).toEqual([1, 1]); expect(image.scale).toEqual([1, 1]);
}); });
// it("resizes with bound arrow", async () => { it("resizes with bound arrow", async () => {
// const image = API.createElement({ const image = API.createElement({
// type: "image", type: "image",
// width: 100, width: 100,
// height: 100, height: 100,
// }); });
// API.setElements([image]); API.setElements([image]);
// const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
// x: -30, x: -30,
// y: 50, y: 50,
// width: 28, width: 28,
// height: 5, height: 5,
// }); });
// expect(arrow.endBinding?.elementId).toEqual(image.id); expect(arrow.endBinding?.elementId).toEqual(image.id);
// UI.resize(image, "ne", [40, 0]); UI.resize(image, "ne", [40, 0]);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
// const imageWidth = image.width; const imageWidth = image.width;
// const scale = 20 / image.height; const scale = 20 / image.height;
// UI.resize(image, "nw", [50, 20]); UI.resize(image, "nw", [50, 20]);
// expect(arrow.endBinding?.elementId).toEqual(image.id); expect(arrow.endBinding?.elementId).toEqual(image.id);
// expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
// 30 + imageWidth * scale, 30 + imageWidth * scale,
// 0, 0,
// ); );
// }); });
}); });
describe("multiple selection", () => { describe("multiple selection", () => {
@ -997,80 +997,68 @@ describe("multiple selection", () => {
expect(diagLine.angle).toEqual(0); expect(diagLine.angle).toEqual(0);
}); });
// it("resizes with bound arrows", async () => { it("resizes with bound arrows", async () => {
// const rectangle = UI.createElement("rectangle", { const rectangle = UI.createElement("rectangle", {
// position: 0, position: 0,
// size: 100, size: 100,
// }); });
// const leftBoundArrow = UI.createElement("arrow", { const leftBoundArrow = UI.createElement("arrow", {
// x: -110, x: -110,
// y: 50, y: 50,
// width: 100, width: 100,
// height: 0, height: 0,
// }); });
// const rightBoundArrow = UI.createElement("arrow", { const rightBoundArrow = UI.createElement("arrow", {
// x: 210, x: 210,
// y: 50, y: 50,
// width: -100, width: -100,
// height: 0, height: 0,
// }); });
// const selectionWidth = 210; const selectionWidth = 210;
// const selectionHeight = 100; const selectionHeight = 100;
// const move = [40, 40] as [number, number]; const move = [40, 40] as [number, number];
// const scale = Math.max( const scale = Math.max(
// 1 - move[0] / selectionWidth, 1 - move[0] / selectionWidth,
// 1 - move[1] / selectionHeight, 1 - move[1] / selectionHeight,
// ); );
// const leftArrowBinding: { const leftArrowBinding = { ...leftBoundArrow.endBinding };
// elementId: string; const rightArrowBinding = { ...rightBoundArrow.endBinding };
// gap?: number; delete rightArrowBinding.gap;
// focus?: number;
// } = {
// ...leftBoundArrow.endBinding,
// } as PointBinding;
// const rightArrowBinding: {
// elementId: string;
// gap?: number;
// focus?: number;
// } = {
// ...rightBoundArrow.endBinding,
// } as PointBinding;
// delete rightArrowBinding.gap;
// UI.resize([rectangle, rightBoundArrow], "nw", move, { UI.resize([rectangle, rightBoundArrow], "nw", move, {
// shift: true, shift: true,
// }); });
// expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
// expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
// expect(leftBoundArrow.width).toBeCloseTo(140, 0); expect(leftBoundArrow.width).toBeCloseTo(140, 0);
// expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
// expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
// expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
// expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10); expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
// expect(leftBoundArrow.endBinding?.elementId).toBe( expect(leftBoundArrow.endBinding?.elementId).toBe(
// leftArrowBinding.elementId, leftArrowBinding.elementId,
// ); );
// expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
// expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.x).toBeCloseTo(210);
// expect(rightBoundArrow.y).toBeCloseTo( expect(rightBoundArrow.y).toBeCloseTo(
// (selectionHeight - 50) * (1 - scale) + 50, (selectionHeight - 50) * (1 - scale) + 50,
// ); );
// expect(rightBoundArrow.width).toBeCloseTo(100 * scale); expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
// expect(rightBoundArrow.height).toBeCloseTo(0); expect(rightBoundArrow.height).toBeCloseTo(0);
// expect(rightBoundArrow.angle).toEqual(0); expect(rightBoundArrow.angle).toEqual(0);
// expect(rightBoundArrow.startBinding).toBeNull(); expect(rightBoundArrow.startBinding).toBeNull();
// expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952); expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
// expect(rightBoundArrow.endBinding?.elementId).toBe( expect(rightBoundArrow.endBinding?.elementId).toBe(
// rightArrowBinding.elementId, rightArrowBinding.elementId,
// ); );
// expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
// rightArrowBinding.focus!, rightArrowBinding.focus!,
// ); );
// }); });
it("resizes with labeled arrows", async () => { it("resizes with labeled arrows", async () => {
const topArrow = UI.createElement("arrow", { const topArrow = UI.createElement("arrow", {
@ -1350,8 +1338,8 @@ describe("multiple selection", () => {
expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.x).toBeCloseTo(380 * scaleX);
expect(boundArrow.y).toBeCloseTo(240 * scaleY); expect(boundArrow.y).toBeCloseTo(240 * scaleY);
expect(boundArrow.points[1][0]).toBeCloseTo(66.3157); expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
expect(boundArrow.points[1][1]).toBeCloseTo(-88.421); expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
boundArrow.x + boundArrow.points[1][0] / 2, boundArrow.x + boundArrow.points[1][0] / 2,

View File

@ -51,7 +51,7 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types"; import type { AppState, Offsets } from "../types";
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
label: "labels.canvasBackground", label: "labels.canvasBackground",
trackEvent: false, trackEvent: false,
@ -64,7 +64,7 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
captureUpdate: !!value?.viewBackgroundColor captureUpdate: !!value.viewBackgroundColor
? CaptureUpdateAction.IMMEDIATELY ? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
@ -464,7 +464,7 @@ export const actionZoomToFit = register({
!event[KEYS.CTRL_OR_CMD], !event[KEYS.CTRL_OR_CMD],
}); });
export const actionToggleTheme = register<AppState["theme"]>({ export const actionToggleTheme = register({
name: "toggleTheme", name: "toggleTheme",
label: (_, appState) => { label: (_, appState) => {
return appState.theme === THEME.DARK return appState.theme === THEME.DARK
@ -472,8 +472,7 @@ export const actionToggleTheme = register<AppState["theme"]>({
: "buttons.darkMode"; : "buttons.darkMode";
}, },
keywords: ["toggle", "dark", "light", "mode", "theme"], keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState, elements) => icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_, appState, value) => { perform: (_, appState, value) => {

View File

@ -20,12 +20,12 @@ import { t } from "../i18n";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { register } from "./register"; import { register } from "./register";
export const actionCopy = register<ClipboardEvent | null>({ export const actionCopy = register({
name: "copy", name: "copy",
label: "labels.copy", label: "labels.copy",
icon: DuplicateIcon, icon: DuplicateIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: async (elements, appState, event, app) => { perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({ const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
@ -109,12 +109,12 @@ export const actionPaste = register({
keyTest: undefined, keyTest: undefined,
}); });
export const actionCut = register<ClipboardEvent | null>({ export const actionCut = register({
name: "cut", name: "cut",
label: "labels.cut", label: "labels.cut",
icon: cutIcon, icon: cutIcon,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, event, app) => { perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app); actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState, null, app); return actionDeleteSelected.perform(elements, appState, null, app);
}, },

View File

@ -206,8 +206,12 @@ export const actionDeleteSelected = register({
trackEvent: { category: "element", action: "delete" }, trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
const { elementId, selectedPointsIndices } = const {
appState.selectedLinearElement; elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement( const linearElement = LinearElementEditor.getElement(
elementId, elementId,
@ -244,6 +248,19 @@ export const actionDeleteSelected = register({
}; };
} }
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement: selectedPointsIndices?.includes(0)
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
linearElement.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(
linearElement, linearElement,
app, app,
@ -256,6 +273,7 @@ export const actionDeleteSelected = register({
...appState, ...appState,
selectedLinearElement: { selectedLinearElement: {
...appState.selectedLinearElement, ...appState.selectedLinearElement,
...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1] ? [selectedPointsIndices[0] - 1]
@ -284,7 +302,6 @@ export const actionDeleteSelected = register({
type: app.defaultSelectionTool, type: app.defaultSelectionTool,
}), }),
multiElement: null, multiElement: null,
newElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
}, },

View File

@ -31,9 +31,7 @@ import "../components/ToolIcon.scss";
import { register } from "./register"; import { register } from "./register";
import type { AppState } from "../types"; export const actionChangeProjectName = register({
export const actionChangeProjectName = register<AppState["name"]>({
name: "changeProjectName", name: "changeProjectName",
label: "labels.fileTitle", label: "labels.fileTitle",
trackEvent: false, trackEvent: false,
@ -53,7 +51,7 @@ export const actionChangeProjectName = register<AppState["name"]>({
), ),
}); });
export const actionChangeExportScale = register<AppState["exportScale"]>({ export const actionChangeExportScale = register({
name: "changeExportScale", name: "changeExportScale",
label: "imageExportDialog.scale", label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" }, trackEvent: { category: "export", action: "scale" },
@ -103,9 +101,7 @@ export const actionChangeExportScale = register<AppState["exportScale"]>({
}, },
}); });
export const actionChangeExportBackground = register< export const actionChangeExportBackground = register({
AppState["exportBackground"]
>({
name: "changeExportBackground", name: "changeExportBackground",
label: "imageExportDialog.label.withBackground", label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" }, trackEvent: { category: "export", action: "toggleBackground" },
@ -125,9 +121,7 @@ export const actionChangeExportBackground = register<
), ),
}); });
export const actionChangeExportEmbedScene = register< export const actionChangeExportEmbedScene = register({
AppState["exportEmbedScene"]
>({
name: "changeExportEmbedScene", name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene", label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" }, trackEvent: { category: "export", action: "embedScene" },
@ -294,9 +288,7 @@ export const actionLoadScene = register({
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
}); });
export const actionExportWithDarkMode = register< export const actionExportWithDarkMode = register({
AppState["exportWithDarkMode"]
>({
name: "exportWithDarkMode", name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode", label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" }, trackEvent: { category: "export", action: "toggleTheme" },

View File

@ -1,6 +1,10 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding"; import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { import {
isValidPolygon, isValidPolygon,
LinearElementEditor, LinearElementEditor,
@ -17,7 +21,7 @@ import {
import { import {
KEYS, KEYS,
arrayToMap, arrayToMap,
invariant, tupleToCoors,
updateActiveTool, updateActiveTool,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
@ -26,12 +30,11 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math"; import type { LocalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
PointsPositionUpdates,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@ -43,37 +46,20 @@ import { register } from "./register";
import type { AppState } from "../types"; import type { AppState } from "../types";
type FormData = { export const actionFinalize = register({
event: PointerEvent;
sceneCoords: { x: number; y: number };
};
export const actionFinalize = register<FormData>({
name: "finalize", name: "finalize",
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, data, app) => { perform: (elements, appState, data, app) => {
let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
event?: PointerEvent;
sceneCoords?: { x: number; y: number };
}) ?? {};
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
if (data && appState.selectedLinearElement) { if (event && appState.selectedLinearElement) {
const { event, sceneCoords } = data;
const element = LinearElementEditor.getElement(
appState.selectedLinearElement.elementId,
elementsMap,
);
invariant(
element,
"Arrow element should exist if selectedLinearElement is set",
);
invariant(
sceneCoords,
"sceneCoords should be defined if actionFinalize is called with event",
);
const linearElementEditor = LinearElementEditor.handlePointerUp( const linearElementEditor = LinearElementEditor.handlePointerUp(
event, event,
appState.selectedLinearElement, appState.selectedLinearElement,
@ -81,47 +67,19 @@ export const actionFinalize = register<FormData>({
app.scene, app.scene,
); );
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) { if (isBindingElement(element)) {
const newArrow = !!appState.newElement; bindOrUnbindLinearElement(
element,
const selectedPointsIndices = startBindingElement,
newArrow || !appState.selectedLinearElement.selectedPointsIndices endBindingElement,
? [element.points.length - 1] // New arrow creation app.scene,
: appState.selectedLinearElement.selectedPointsIndices; );
const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => {
map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords(
element,
pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y),
elementsMap,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(element, draggedPoints, scene, appState, {
newArrow,
});
} else if (isLineElement(element)) {
if (
appState.selectedLinearElement?.isEditing &&
!appState.newElement &&
!isValidPolygon(element.points)
) {
scene.mutateElement(element, {
polygon: false,
});
}
} }
if (linearElementEditor !== appState.selectedLinearElement) { if (linearElementEditor !== appState.selectedLinearElement) {
// `handlePointerUp()` updated the linear element instance, let newElements = elements;
// so filter out this element if it is too small,
// but do an update to all new elements anyway for undo/redo purposes.
if (element && isInvisiblySmallElement(element)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.map((el) => { newElements = newElements.map((el) => {
@ -133,8 +91,39 @@ export const actionFinalize = register<FormData>({
return el; return el;
}); });
} }
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
const activeToolLocked = appState.activeTool?.locked; if (appState.selectedLinearElement?.isEditing) {
const { elementId, startBindingElement, endBindingElement } =
appState.selectedLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
scene,
);
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return { return {
elements: elements:
@ -145,31 +134,23 @@ export const actionFinalize = register<FormData>({
} }
return el; return el;
}) })
: newElements, : undefined,
appState: { appState: {
...appState, ...appState,
cursorButton: "up", cursorButton: "up",
selectedLinearElement: activeToolLocked selectedLinearElement: new LinearElementEditor(
? null element,
: { arrayToMap(elementsMap),
...linearElementEditor, false, // exit editing mode
selectedPointsIndices: null, ),
isEditing: false,
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: -1,
},
},
selectionElement: null,
suggestedBinding: null,
newElement: null,
multiElement: null,
}, },
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
} }
} }
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
@ -193,14 +174,8 @@ export const actionFinalize = register<FormData>({
if (element) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (appState.multiElement && element.type !== "freedraw") {
appState.selectedLinearElement && const { points, lastCommittedPoint } = element;
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points } = element;
const { lastCommittedPoint } = appState.selectedLinearElement;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
@ -252,6 +227,25 @@ export const actionFinalize = register<FormData>({
polygon: false, polygon: false,
}); });
} }
if (
isBindingElement(element) &&
!isLoop &&
element.points.length > 1 &&
isBindingEnabled(appState)
) {
const coords =
sceneCoords ??
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
}
} }
} }
@ -277,25 +271,6 @@ export const actionFinalize = register<FormData>({
}); });
} }
let selectedLinearElement =
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements)) // To select the linear element when user has finished mutipoint editing
: appState.selectedLinearElement;
selectedLinearElement = selectedLinearElement
? {
...selectedLinearElement,
isEditing: appState.newElement
? false
: selectedLinearElement.isEditing,
initialState: {
...selectedLinearElement.initialState,
lastClickedPoint: -1,
origin: null,
},
}
: selectedLinearElement;
return { return {
elements: newElements, elements: newElements,
appState: { appState: {
@ -313,7 +288,7 @@ export const actionFinalize = register<FormData>({
multiElement: null, multiElement: null,
editingTextElement: null, editingTextElement: null,
startBoundElement: null, startBoundElement: null,
suggestedBinding: null, suggestedBindings: [],
selectedElementIds: selectedElementIds:
element && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
@ -323,8 +298,11 @@ export const actionFinalize = register<FormData>({
[element.id]: true, [element.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing
selectedLinearElement, selectedLinearElement:
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

@ -38,13 +38,15 @@ describe("flipping re-centers selection", () => {
height: 239.9, height: 239.9,
startBinding: { startBinding: {
elementId: "rec1", elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05], fixedPoint: [0.49, -0.05],
mode: "orbit",
}, },
endBinding: { endBinding: {
elementId: "rec2", elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49], fixedPoint: [-0.05, 0.49],
mode: "orbit",
}, },
startArrowhead: null, startArrowhead: null,
endArrowhead: "arrow", endArrowhead: "arrow",
@ -72,11 +74,11 @@ describe("flipping re-centers selection", () => {
const rec1 = h.elements.find((el) => el.id === "rec1")!; const rec1 = h.elements.find((el) => el.id === "rec1")!;
expect(rec1.x).toBeCloseTo(100, 0); expect(rec1.x).toBeCloseTo(100, 0);
expect(rec1.y).toBeCloseTo(101, 0); expect(rec1.y).toBeCloseTo(100, 0);
const rec2 = h.elements.find((el) => el.id === "rec2")!; const rec2 = h.elements.find((el) => el.id === "rec2")!;
expect(rec2.x).toBeCloseTo(220, 0); expect(rec2.x).toBeCloseTo(220, 0);
expect(rec2.y).toBeCloseTo(251, 0); expect(rec2.y).toBeCloseTo(250, 0);
}); });
}); });
@ -97,8 +99,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null, endArrowhead: null,
endBinding: { endBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });
@ -137,13 +139,13 @@ describe("flipping arrowheads", () => {
endArrowhead: "circle", endArrowhead: "circle",
startBinding: { startBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
endBinding: { endBinding: {
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });
@ -193,8 +195,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null, endArrowhead: null,
endBinding: { endBinding: {
elementId: rect.id, elementId: rect.id,
fixedPoint: [0.5, 0.5], focus: 0.5,
mode: "orbit", gap: 5,
}, },
}); });

View File

@ -1,10 +1,17 @@
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
import { bindOrUnbindBindingElements } from "@excalidraw/element"; import {
bindOrUnbindLinearElements,
isBindingEnabled,
} from "@excalidraw/element";
import { getCommonBoundingBox } from "@excalidraw/element"; import { getCommonBoundingBox } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element";
import { resizeMultipleElements } from "@excalidraw/element"; import { resizeMultipleElements } from "@excalidraw/element";
import { isArrowElement, isElbowArrow } from "@excalidraw/element"; import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "@excalidraw/element";
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element"; import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
import { CODES, KEYS, arrayToMap } from "@excalidraw/common"; import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
@ -96,6 +103,7 @@ const flipSelectedElements = (
const updatedElements = flipElements( const updatedElements = flipElements(
selectedElements, selectedElements,
elementsMap, elementsMap,
appState,
flipDirection, flipDirection,
app, app,
); );
@ -110,6 +118,7 @@ const flipSelectedElements = (
const flipElements = ( const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
@ -149,10 +158,12 @@ const flipElements = (
}, },
); );
bindOrUnbindBindingElements( bindOrUnbindLinearElements(
selectedElements.filter(isArrowElement), selectedElements.filter(isLinearElement),
isBindingEnabled(appState),
[],
app.scene, app.scene,
app.state, appState.zoom,
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -2,8 +2,6 @@ import clsx from "clsx";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import { invariant } from "@excalidraw/common";
import { getClientColor } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { import {
@ -18,17 +16,12 @@ import { register } from "./register";
import type { GoToCollaboratorComponentProps } from "../components/UserList"; import type { GoToCollaboratorComponentProps } from "../components/UserList";
import type { Collaborator } from "../types"; import type { Collaborator } from "../types";
export const actionGoToCollaborator = register<Collaborator>({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
label: "Go to a collaborator", label: "Go to a collaborator",
viewMode: true, viewMode: true,
trackEvent: { category: "collab" }, trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator) => { perform: (_elements, appState, collaborator: Collaborator) => {
invariant(
collaborator,
"actionGoToCollaborator: collaborator should be defined when actionGoToCollaborator is called",
);
if ( if (
!collaborator.socketId || !collaborator.socketId ||
appState.userToFollow?.socketId === collaborator.socketId || appState.userToFollow?.socketId === collaborator.socketId ||

View File

@ -1,5 +1,4 @@
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
@ -22,13 +21,12 @@ import {
getLineHeight, getLineHeight,
isTransparent, isTransparent,
reduceToCommonValue, reduceToCommonValue,
invariant,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import { import {
bindBindingElement, bindLinearElement,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
@ -299,15 +297,13 @@ const changeFontSize = (
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register< export const actionChangeStrokeColor = register({
Pick<AppState, "currentItemStrokeColor">
>({
name: "changeStrokeColor", name: "changeStrokeColor",
label: "labels.stroke", label: "labels.stroke",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { return {
...(value?.currentItemStrokeColor && { ...(value.currentItemStrokeColor && {
elements: changeProperty( elements: changeProperty(
elements, elements,
appState, appState,
@ -325,7 +321,7 @@ export const actionChangeStrokeColor = register<
...appState, ...appState,
...value, ...value,
}, },
captureUpdate: !!value?.currentItemStrokeColor captureUpdate: !!value.currentItemStrokeColor
? CaptureUpdateAction.IMMEDIATELY ? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY, : CaptureUpdateAction.EVENTUALLY,
}; };
@ -358,14 +354,12 @@ export const actionChangeStrokeColor = register<
), ),
}); });
export const actionChangeBackgroundColor = register< export const actionChangeBackgroundColor = register({
Pick<AppState, "currentItemBackgroundColor" | "viewBackgroundColor">
>({
name: "changeBackgroundColor", name: "changeBackgroundColor",
label: "labels.changeBackground", label: "labels.changeBackground",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value, app) => { perform: (elements, appState, value, app) => {
if (!value?.currentItemBackgroundColor) { if (!value.currentItemBackgroundColor) {
return { return {
appState: { appState: {
...appState, ...appState,
@ -440,7 +434,7 @@ export const actionChangeBackgroundColor = register<
), ),
}); });
export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({ export const actionChangeFillStyle = register({
name: "changeFillStyle", name: "changeFillStyle",
label: "labels.fill", label: "labels.fill",
trackEvent: false, trackEvent: false,
@ -520,9 +514,7 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
}, },
}); });
export const actionChangeStrokeWidth = register< export const actionChangeStrokeWidth = register({
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth", name: "changeStrokeWidth",
label: "labels.strokeWidth", label: "labels.strokeWidth",
trackEvent: false, trackEvent: false,
@ -580,7 +572,7 @@ export const actionChangeStrokeWidth = register<
), ),
}); });
export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({ export const actionChangeSloppiness = register({
name: "changeSloppiness", name: "changeSloppiness",
label: "labels.sloppiness", label: "labels.sloppiness",
trackEvent: false, trackEvent: false,
@ -636,9 +628,7 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
), ),
}); });
export const actionChangeStrokeStyle = register< export const actionChangeStrokeStyle = register({
ExcalidrawElement["strokeStyle"]
>({
name: "changeStrokeStyle", name: "changeStrokeStyle",
label: "labels.strokeStyle", label: "labels.strokeStyle",
trackEvent: false, trackEvent: false,
@ -693,7 +683,7 @@ export const actionChangeStrokeStyle = register<
), ),
}); });
export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({ export const actionChangeOpacity = register({
name: "changeOpacity", name: "changeOpacity",
label: "labels.opacity", label: "labels.opacity",
trackEvent: false, trackEvent: false,
@ -717,89 +707,85 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
), ),
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register({
{ name: "changeFontSize",
name: "changeFontSize", label: "labels.fontSize",
label: "labels.fontSize", trackEvent: false,
trackEvent: false, perform: (elements, appState, value, app) => {
perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, value);
return changeFontSize(
elements,
appState,
app,
() => {
invariant(value, "actionChangeFontSize: Expected a font size value");
return value;
},
value,
);
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
}, },
); PanelComponent: ({ elements, appState, updateData, app, data }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
<RadioSelection
group="font-size"
options={[
{
value: 16,
text: t("labels.small"),
icon: FontSizeSmallIcon,
testId: "fontSize-small",
},
{
value: 20,
text: t("labels.medium"),
icon: FontSizeMediumIcon,
testId: "fontSize-medium",
},
{
value: 28,
text: t("labels.large"),
icon: FontSizeLargeIcon,
testId: "fontSize-large",
},
{
value: 36,
text: t("labels.veryLarge"),
icon: FontSizeExtraLargeIcon,
testId: "fontSize-veryLarge",
},
]}
value={getFormValue(
elements,
app,
(element) => {
if (isTextElement(element)) {
return element.fontSize;
}
const boundTextElement = getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
);
if (boundTextElement) {
return boundTextElement.fontSize;
}
return null;
},
(element) =>
isTextElement(element) ||
getBoundTextElement(
element,
app.scene.getNonDeletedElementsMap(),
) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
/>
</div>
</fieldset>
),
});
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
@ -859,10 +845,7 @@ type ChangeFontFamilyData = Partial<
resetContainers?: true; resetContainers?: true;
}; };
export const actionChangeFontFamily = register<{ export const actionChangeFontFamily = register({
currentItemFontFamily: any;
currentHoveredFontFamily: any;
}>({
name: "changeFontFamily", name: "changeFontFamily",
label: "labels.fontFamily", label: "labels.fontFamily",
trackEvent: false, trackEvent: false,
@ -899,8 +882,6 @@ export const actionChangeFontFamily = register<{
}; };
} }
invariant(value, "actionChangeFontFamily: value must be defined");
const { currentItemFontFamily, currentHoveredFontFamily } = value; const { currentItemFontFamily, currentHoveredFontFamily } = value;
let nextCaptureUpdateAction: CaptureUpdateActionType = let nextCaptureUpdateAction: CaptureUpdateActionType =
@ -1245,7 +1226,7 @@ export const actionChangeFontFamily = register<{
}, },
}); });
export const actionChangeTextAlign = register<TextAlign>({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
label: "Change text alignment", label: "Change text alignment",
trackEvent: false, trackEvent: false,
@ -1345,7 +1326,7 @@ export const actionChangeTextAlign = register<TextAlign>({
}, },
}); });
export const actionChangeVerticalAlign = register<VerticalAlign>({ export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign", name: "changeVerticalAlign",
label: "Change vertical alignment", label: "Change vertical alignment",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
@ -1444,7 +1425,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
}, },
}); });
export const actionChangeRoundness = register<"sharp" | "round">({ export const actionChangeRoundness = register({
name: "changeRoundness", name: "changeRoundness",
label: "Change edge roundness", label: "Change edge roundness",
trackEvent: false, trackEvent: false,
@ -1601,16 +1582,15 @@ const getArrowheadOptions = (flip: boolean) => {
] as const; ] as const;
}; };
export const actionChangeArrowhead = register<{ export const actionChangeArrowhead = register({
position: "start" | "end";
type: Arrowhead;
}>({
name: "changeArrowhead", name: "changeArrowhead",
label: "Change arrowheads", label: "Change arrowheads",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (
invariant(value, "actionChangeArrowhead: value must be defined"); elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return { return {
elements: changeProperty(elements, appState, (el) => { elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) { if (isLinearElement(el)) {
@ -1705,7 +1685,7 @@ export const actionChangeArrowProperties = register({
}, },
}); });
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({ export const actionChangeArrowType = register({
name: "changeArrowType", name: "changeArrowType",
label: "Change arrow types", label: "Change arrow types",
trackEvent: false, trackEvent: false,
@ -1806,13 +1786,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.startBinding.elementId, newElement.startBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (startElement) { if (startElement) {
bindBindingElement( bindLinearElement(newElement, startElement, "start", app.scene);
newElement,
startElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
);
} }
} }
if (newElement.endBinding) { if (newElement.endBinding) {
@ -1820,13 +1794,7 @@ export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
newElement.endBinding.elementId, newElement.endBinding.elementId,
) as ExcalidrawBindableElement; ) as ExcalidrawBindableElement;
if (endElement) { if (endElement) {
bindBindingElement( bindLinearElement(newElement, endElement, "end", app.scene);
newElement,
endElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
);
} }
} }
} }

View File

@ -2,12 +2,7 @@ import type { Action } from "./types";
export let actions: readonly Action[] = []; export let actions: readonly Action[] = [];
export const register = < export const register = <T extends Action>(action: T) => {
TData extends any,
T extends Action<TData> = Action<TData>,
>(
action: T,
) => {
actions = actions.concat(action); actions = actions.concat(action);
return action as T & { return action as T & {
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"]; keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];

View File

@ -32,10 +32,10 @@ export type ActionResult =
} }
| false; | false;
type ActionFn<TData = any> = ( type ActionFn = (
elements: readonly OrderedExcalidrawElement[], elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>, appState: Readonly<AppState>,
formData: TData | undefined, formData: any,
app: AppClassProperties, app: AppClassProperties,
) => ActionResult | Promise<ActionResult>; ) => ActionResult | Promise<ActionResult>;
@ -159,7 +159,7 @@ export type PanelComponentProps = {
) => React.JSX.Element | null; ) => React.JSX.Element | null;
}; };
export interface Action<TData = any> { export interface Action {
name: ActionName; name: ActionName;
label: label:
| string | string
@ -176,7 +176,7 @@ export interface Action<TData = any> {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
) => React.ReactNode); ) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>; PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn<TData>; perform: ActionFn;
keyPriority?: number; keyPriority?: number;
keyTest?: ( keyTest?: (
event: React.KeyboardEvent | KeyboardEvent, event: React.KeyboardEvent | KeyboardEvent,

View File

@ -96,7 +96,7 @@ export const getDefaultAppState = (): Omit<
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties, panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
}, },
startBoundElement: null, startBoundElement: null,
suggestedBinding: null, suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null, frameToHighlight: null,
editingFrame: null, editingFrame: null,
@ -123,7 +123,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "orbit",
stylesPanelMode: "full", stylesPanelMode: "full",
}; };
}; };
@ -226,7 +225,7 @@ const APP_STATE_STORAGE_CONF = (<
shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false }, stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false }, frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false },
editingFrame: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false },
@ -249,7 +248,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true }, lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false }, activeLockedId: { browser: false, export: false, server: false },
bindMode: { browser: true, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false }, stylesPanelMode: { browser: true, export: false, server: false },
}); });

File diff suppressed because it is too large Load Diff

View File

@ -961,7 +961,7 @@ const CommandItem = ({
<InlineIcon <InlineIcon
icon={ icon={
typeof command.icon === "function" typeof command.icon === "function"
? command.icon(appState, []) ? command.icon(appState)
: command.icon : command.icon
} }
/> />

View File

@ -1,5 +1,6 @@
import type { ActionManager } from "../../actions/manager"; import type { ActionManager } from "../../actions/manager";
import type { Action } from "../../actions/types"; import type { Action } from "../../actions/types";
import type { UIAppState } from "../../types";
export type CommandPaletteItem = { export type CommandPaletteItem = {
label: string; label: string;
@ -11,7 +12,7 @@ export type CommandPaletteItem = {
* (deburred name + keywords) * (deburred name + keywords)
*/ */
haystack?: string; haystack?: string;
icon?: Action["icon"]; icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
category: string; category: string;
order?: number; order?: number;
predicate?: boolean | Action["predicate"]; predicate?: boolean | Action["predicate"];

View File

@ -844,7 +844,7 @@ const convertElementType = <
}), }),
) as typeof element; ) as typeof element;
updateBindings(nextElement, app.scene, app.state); updateBindings(nextElement, app.scene);
return nextElement; return nextElement;
} }

View File

@ -1,7 +1,6 @@
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common"; import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common";
import { import {
isArrowElement,
isFlowchartNodeElement, isFlowchartNodeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@ -38,13 +37,6 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (
appState.selectedLinearElement?.isDragging ||
isArrowElement(appState.newElement)
) {
return t("hints.arrowBindModifiers");
}
if ( if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name && appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.openSidebar.tab === CANVAS_SEARCH_TAB &&

View File

@ -646,7 +646,7 @@ const LayerUI = ({
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { const {
suggestedBinding, suggestedBindings,
startBoundElement, startBoundElement,
cursorButton, cursorButton,
scrollX, scrollX,

View File

@ -34,7 +34,6 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
shouldChangeByStepSize, shouldChangeByStepSize,
nextValue, nextValue,
scene, scene,
app,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0]; const origElement = originalElements[0];
@ -49,7 +48,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene.mutateElement(latestElement, { scene.mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, scene, app.state); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {
@ -75,7 +74,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
scene.mutateElement(latestElement, { scene.mutateElement(latestElement, {
angle: nextAngle, angle: nextAngle,
}); });
updateBindings(latestElement, scene, app.state); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement(latestElement, elementsMap); const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) { if (boundTextElement && !isArrowElement(latestElement)) {

View File

@ -94,7 +94,9 @@ const resizeElementInGroup = (
); );
if (boundTextElement) { if (boundTextElement) {
const newFontSize = boundTextElement.fontSize * scale; const newFontSize = boundTextElement.fontSize * scale;
updateBoundElements(latestElement, scene); updateBoundElements(latestElement, scene, {
newSize: { width: updates.width, height: updates.height },
});
const latestBoundTextElement = elementsMap.get(boundTextElement.id); const latestBoundTextElement = elementsMap.get(boundTextElement.id);
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
scene.mutateElement(latestBoundTextElement, { scene.mutateElement(latestBoundTextElement, {

View File

@ -38,7 +38,6 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[], originalElements: readonly ExcalidrawElement[],
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
appState: AppState,
) => { ) => {
for (let i = 0; i < originalElements.length; i++) { for (let i = 0; i < originalElements.length; i++) {
const origElement = originalElements[i]; const origElement = originalElements[i];
@ -64,7 +63,6 @@ const moveElements = (
newTopLeftY, newTopLeftY,
origElement, origElement,
scene, scene,
appState,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -77,7 +75,6 @@ const moveGroupTo = (
originalElements: ExcalidrawElement[], originalElements: ExcalidrawElement[],
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
scene: Scene, scene: Scene,
appState: AppState,
) => { ) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const [x1, y1, ,] = getCommonBounds(originalElements); const [x1, y1, ,] = getCommonBounds(originalElements);
@ -110,7 +107,6 @@ const moveGroupTo = (
topLeftY + offsetY, topLeftY + offsetY,
origElement, origElement,
scene, scene,
appState,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -129,7 +125,6 @@ const handlePositionChange: DragInputCallbackType<
property, property,
scene, scene,
originalAppState, originalAppState,
app,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
@ -157,7 +152,6 @@ const handlePositionChange: DragInputCallbackType<
elementsInUnit.map((el) => el.original), elementsInUnit.map((el) => el.original),
originalElementsMap, originalElementsMap,
scene, scene,
app.state,
); );
} else { } else {
const origElement = elementsInUnit[0]?.original; const origElement = elementsInUnit[0]?.original;
@ -184,7 +178,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY, newTopLeftY,
origElement, origElement,
scene, scene,
app.state,
originalElementsMap, originalElementsMap,
false, false,
); );
@ -210,7 +203,6 @@ const handlePositionChange: DragInputCallbackType<
originalElements, originalElements,
originalElementsMap, originalElementsMap,
scene, scene,
app.state,
); );
scene.triggerUpdate(); scene.triggerUpdate();

View File

@ -34,7 +34,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
property, property,
scene, scene,
originalAppState, originalAppState,
app,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0]; const origElement = originalElements[0];
@ -132,7 +131,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
scene, scene,
app.state,
originalElementsMap, originalElementsMap,
); );
return; return;
@ -164,7 +162,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY, newTopLeftY,
origElement, origElement,
scene, scene,
app.state,
originalElementsMap, originalElementsMap,
); );
}; };

View File

@ -4,9 +4,9 @@ import throttle from "lodash.throttle";
import { useEffect, useMemo, useState, memo } from "react"; import { useEffect, useMemo, useState, memo } from "react";
import { STATS_PANELS } from "@excalidraw/common"; import { STATS_PANELS } from "@excalidraw/common";
import { getCommonBounds, isBindingElement } from "@excalidraw/element"; import { getCommonBounds } from "@excalidraw/element";
import { getUncroppedWidthAndHeight } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element"; import { isElbowArrow, isImageElement } from "@excalidraw/element";
import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; import { frameAndChildrenSelectedTogether } from "@excalidraw/element";
@ -333,7 +333,7 @@ export const StatsInner = memo(
appState={appState} appState={appState}
/> />
</StatsRow> </StatsRow>
{!isBindingElement(singleElement) && ( {!isElbowArrow(singleElement) && (
<StatsRow> <StatsRow>
<Angle <Angle
property="angle" property="angle"

View File

@ -114,7 +114,7 @@ describe("binding with linear elements", () => {
mouse.up(200, 100); mouse.up(200, 100);
UI.clickTool("arrow"); UI.clickTool("arrow");
mouse.down(-5, 0); mouse.down(5, 0);
mouse.up(300, 50); mouse.up(300, 50);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@ -135,7 +135,18 @@ describe("binding with linear elements", () => {
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("186")); UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
}); });
@ -150,6 +161,17 @@ describe("binding with linear elements", () => {
UI.updateInput(inputX, String("254")); UI.updateInput(inputX, String("254"));
expect(linear.startBinding).toBe(null); expect(linear.startBinding).toBe(null);
}); });
it("should remain bound to linear element on small angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("45"));
expect(linear.startBinding).toBe(null);
});
}); });
// single element // single element

View File

@ -1,10 +1,6 @@
import { pointFrom, pointRotateRads } from "@excalidraw/math"; import { pointFrom, pointRotateRads } from "@excalidraw/math";
import { import { getBoundTextElement } from "@excalidraw/element";
getBoundTextElement,
isBindingElement,
unbindBindingElement,
} from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element";
import { import {
@ -16,7 +12,6 @@ import {
import { getFrameChildren } from "@excalidraw/element"; import { getFrameChildren } from "@excalidraw/element";
import { updateBindings } from "@excalidraw/element"; import { updateBindings } from "@excalidraw/element";
import { DRAGGING_THRESHOLD } from "@excalidraw/common";
import type { Radians } from "@excalidraw/math"; import type { Radians } from "@excalidraw/math";
@ -115,25 +110,9 @@ export const moveElement = (
newTopLeftY: number, newTopLeftY: number,
originalElement: ExcalidrawElement, originalElement: ExcalidrawElement,
scene: Scene, scene: Scene,
appState: AppState,
originalElementsMap: ElementsMap, originalElementsMap: ElementsMap,
shouldInformMutation = true, shouldInformMutation = true,
) => { ) => {
if (
isBindingElement(originalElement) &&
(originalElement.startBinding || originalElement.endBinding)
) {
if (
Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD &&
Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD
) {
return;
}
unbindBindingElement(originalElement, "start", scene);
unbindBindingElement(originalElement, "end", scene);
}
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const latestElement = elementsMap.get(originalElement.id); const latestElement = elementsMap.get(originalElement.id);
if (!latestElement) { if (!latestElement) {
@ -166,7 +145,7 @@ export const moveElement = (
}, },
{ informMutation: shouldInformMutation, isDragging: false }, { informMutation: shouldInformMutation, isDragging: false },
); );
updateBindings(latestElement, scene, appState); updateBindings(latestElement, scene);
const boundTextElement = getBoundTextElement( const boundTextElement = getBoundTextElement(
originalElement, originalElement,
@ -224,7 +203,7 @@ export const moveElement = (
}, },
{ informMutation: shouldInformMutation, isDragging: false }, { informMutation: shouldInformMutation, isDragging: false },
); );
updateBindings(latestChildElement, scene, appState, { updateBindings(latestChildElement, scene, {
simultaneouslyUpdated: originalChildren, simultaneouslyUpdated: originalChildren,
}); });
}); });

View File

@ -5,7 +5,6 @@ import {
isShallowEqual, isShallowEqual,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type { import type {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@ -13,21 +12,15 @@ import type {
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene"; import { renderInteractiveScene } from "../../renderer/interactiveScene";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
InteractiveSceneRenderAnimationState,
InteractiveSceneRenderConfig,
RenderableElementsMap, RenderableElementsMap,
RenderInteractiveSceneCallback, RenderInteractiveSceneCallback,
} from "../../scene/types"; } from "../../scene/types";
import type { import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
AppClassProperties,
AppState,
Device,
InteractiveCanvasAppState,
} from "../../types";
import type { DOMAttributes } from "react"; import type { DOMAttributes } from "react";
type InteractiveCanvasProps = { type InteractiveCanvasProps = {
@ -43,7 +36,6 @@ type InteractiveCanvasProps = {
appState: InteractiveCanvasAppState; appState: InteractiveCanvasAppState;
renderScrollbars: boolean; renderScrollbars: boolean;
device: Device; device: Device;
app: AppClassProperties;
renderInteractiveSceneCallback: ( renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback, data: RenderInteractiveSceneCallback,
) => void; ) => void;
@ -78,11 +70,8 @@ type InteractiveCanvasProps = {
>; >;
}; };
export const INTERACTIVE_SCENE_ANIMATION_KEY = "animateInteractiveScene";
const InteractiveCanvas = (props: InteractiveCanvasProps) => { const InteractiveCanvas = (props: InteractiveCanvasProps) => {
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const rendererParams = useRef(null as InteractiveSceneRenderConfig | null);
useEffect(() => { useEffect(() => {
if (!isComponentMounted.current) { if (!isComponentMounted.current) {
@ -139,63 +128,29 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
)) || )) ||
"#6965db"; "#6965db";
rendererParams.current = { renderInteractiveScene(
app: props.app, {
canvas: props.canvas, canvas: props.canvas,
elementsMap: props.elementsMap, elementsMap: props.elementsMap,
visibleElements: props.visibleElements, visibleElements: props.visibleElements,
selectedElements: props.selectedElements, selectedElements: props.selectedElements,
allElementsMap: props.allElementsMap, allElementsMap: props.allElementsMap,
scale: window.devicePixelRatio, scale: window.devicePixelRatio,
appState: props.appState, appState: props.appState,
renderConfig: { renderConfig: {
remotePointerViewportCoords, remotePointerViewportCoords,
remotePointerButton, remotePointerButton,
remoteSelectedElementIds, remoteSelectedElementIds,
remotePointerUsernames, remotePointerUsernames,
remotePointerUserStates, remotePointerUserStates,
selectionColor, selectionColor,
renderScrollbars: props.renderScrollbars, renderScrollbars: props.renderScrollbars,
// NOTE not memoized on so we don't rerender on cursor move
lastViewportPosition: props.app.lastViewportPosition,
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
animationState: {
bindingHighlight: undefined,
},
deltaTime: 0,
};
if (!AnimationController.running(INTERACTIVE_SCENE_ANIMATION_KEY)) {
AnimationController.start<InteractiveSceneRenderAnimationState>(
INTERACTIVE_SCENE_ANIMATION_KEY,
({ deltaTime, state }) => {
const nextAnimationState = renderInteractiveScene(
{
...rendererParams.current!,
deltaTime,
animationState: state,
},
false,
).animationState;
if (nextAnimationState) {
for (const key in nextAnimationState) {
if (
nextAnimationState[
key as keyof InteractiveSceneRenderAnimationState
] !== undefined
) {
return nextAnimationState;
}
}
}
return undefined;
}, },
); device: props.device,
} callback: props.renderInteractiveSceneCallback,
},
isRenderThrottlingEnabled(),
);
}); });
return ( return (
@ -246,9 +201,8 @@ const getRelevantAppStateProps = (
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement, selectedLinearElement: appState.selectedLinearElement,
multiElement: appState.multiElement, multiElement: appState.multiElement,
newElement: appState.newElement,
isBindingEnabled: appState.isBindingEnabled, isBindingEnabled: appState.isBindingEnabled,
suggestedBinding: appState.suggestedBinding, suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating, isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight, elementsToHighlight: appState.elementsToHighlight,
collaborators: appState.collaborators, // Necessary for collab. sessions collaborators: appState.collaborators, // Necessary for collab. sessions
@ -260,10 +214,6 @@ const getRelevantAppStateProps = (
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
searchMatches: appState.searchMatches, searchMatches: appState.searchMatches,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
hoveredElementIds: appState.hoveredElementIds,
frameRendering: appState.frameRendering,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
exportScale: appState.exportScale,
}); });
const areEqual = ( const areEqual = (

View File

@ -99,7 +99,6 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
currentHoveredFontFamily: appState.currentHoveredFontFamily, currentHoveredFontFamily: appState.currentHoveredFontFamily,
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
suggestedBinding: appState.suggestedBinding,
}; };
return relevantAppStateProps; return relevantAppStateProps;

View File

@ -88,11 +88,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": [ "focus": -0.007519379844961235,
0.04, "gap": 11.562288374879595,
0.4633333333333333,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -101,6 +98,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -120,11 +118,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id49", "elementId": "id49",
"fixedPoint": [ "focus": -0.0813953488372095,
1, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#1864ab", "strokeColor": "#1864ab",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -149,11 +144,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "ellipse-1", "elementId": "ellipse-1",
"fixedPoint": [ "focus": 0.10666666666666667,
-0.01, "gap": 3.8343264684446097,
0.44666666666666666,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -162,6 +154,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"id": Any<String>, "id": Any<String>,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -181,11 +174,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"fixedPoint": [ "focus": 0,
0.9357142857142857, "gap": 4.535423522449215,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -344,11 +334,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"fixedPoint": [ "focus": 0,
-2.05, "gap": 16,
0.5001,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -357,6 +344,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -376,11 +364,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "text-1", "elementId": "text-1",
"fixedPoint": [ "focus": 0,
1, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -451,11 +436,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id42", "elementId": "id42",
"fixedPoint": [ "focus": -0,
0, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -464,6 +446,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"id": Any<String>, "id": Any<String>,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -483,11 +466,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id41", "elementId": "id41",
"fixedPoint": [ "focus": 0,
1, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -632,11 +612,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id46", "elementId": "id46",
"fixedPoint": [ "focus": -0,
0, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -645,6 +622,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"id": Any<String>, "id": Any<String>,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -664,11 +642,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id45", "elementId": "id45",
"fixedPoint": [ "focus": 0,
1, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -864,6 +839,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"id": Any<String>, "id": Any<String>,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -911,6 +887,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"id": Any<String>, "id": Any<String>,
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -957,6 +934,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1004,6 +982,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"id": Any<String>, "id": Any<String>,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1497,11 +1476,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "Alice", "elementId": "Alice",
"fixedPoint": [ "focus": -0,
-0.07542628418945944, "gap": 5.299874999999986,
0.5001,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1510,6 +1486,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>, "id": Any<String>,
"index": "a4", "index": "a4",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1531,11 +1508,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": [ "focus": 0,
1.000004978564514, "gap": 1,
0.5001,
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1565,11 +1539,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"fixedPoint": [ "focus": 0,
0.46387050630528887, "gap": 32,
0.48466257668711654,
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@ -1578,6 +1549,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"id": Any<String>, "id": Any<String>,
"index": "a5", "index": "a5",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1595,11 +1567,8 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "Bob", "elementId": "Bob",
"fixedPoint": [ "focus": 0,
0.39381496335223337, "gap": 1,
1,
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@ -1889,6 +1858,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>, "id": Any<String>,
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1941,6 +1911,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>, "id": Any<String>,
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -1993,6 +1964,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>, "id": Any<String>,
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -2045,6 +2017,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"id": Any<String>, "id": Any<String>,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

View File

@ -7,6 +7,8 @@ import {
isPromiseLike, isPromiseLike,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { clearElementsForExport } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types"; import type { ValueOf } from "@excalidraw/common/utility-types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@ -157,7 +159,7 @@ export const loadSceneOrLibraryFromBlob = async (
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
data: restore( data: restore(
{ {
elements: data.elements || [], elements: clearElementsForExport(data.elements || []),
appState: { appState: {
theme: localAppState?.theme, theme: localAppState?.theme,
fileHandle: fileHandle || blob.handle || null, fileHandle: fileHandle || blob.handle || null,

View File

@ -6,6 +6,11 @@ import {
VERSIONS, VERSIONS,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
clearElementsForDatabase,
clearElementsForExport,
} from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types"; import type { ExcalidrawElement } from "@excalidraw/element/types";
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
@ -52,7 +57,10 @@ export const serializeAsJSON = (
type: EXPORT_DATA_TYPES.excalidraw, type: EXPORT_DATA_TYPES.excalidraw,
version: VERSIONS.excalidraw, version: VERSIONS.excalidraw,
source: getExportSource(), source: getExportSource(),
elements, elements:
type === "local"
? clearElementsForExport(elements)
: clearElementsForDatabase(elements),
appState: appState:
type === "local" type === "local"
? cleanAppStateForExport(appState) ? cleanAppStateForExport(appState)

View File

@ -32,6 +32,7 @@ import {
isArrowBoundToElement, isArrowBoundToElement,
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isLinearElement, isLinearElement,
isLineElement, isLineElement,
isTextElement, isTextElement,
@ -60,6 +61,7 @@ import type {
FontFamilyValues, FontFamilyValues,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding,
StrokeRoundness, StrokeRoundness,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
@ -121,29 +123,36 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = <T extends ExcalidrawLinearElement>( const repairBinding = <T extends ExcalidrawLinearElement>(
element: T, element: T,
binding: FixedPointBinding | null, binding: PointBinding | FixedPointBinding | null,
): FixedPointBinding | null => { ): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
const focus = binding.focus || 0;
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
const fixedPointBinding: const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = { | ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
...binding, ? {
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), ...binding,
mode: binding.mode || "orbit", focus,
}; fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: null;
return fixedPointBinding; return fixedPointBinding;
} }
return { return {
elementId: binding.elementId, ...binding,
mode: binding.mode || "orbit", focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.51, 0.51]), } as T extends ExcalidrawElbowArrowElement
} as FixedPointBinding | null; ? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
}; };
const restoreElementWithProperties = < const restoreElementWithProperties = <
@ -292,6 +301,7 @@ export const restoreElement = (
case "freedraw": { case "freedraw": {
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
points: element.points, points: element.points,
lastCommittedPoint: null,
simulatePressure: element.simulatePressure, simulatePressure: element.simulatePressure,
pressures: element.pressures, pressures: element.pressures,
}); });
@ -327,6 +337,7 @@ export const restoreElement = (
: element.type, : element.type,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
points, points,
@ -359,6 +370,7 @@ export const restoreElement = (
type: element.type, type: element.type,
startBinding: repairBinding(element, element.startBinding), startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding), endBinding: repairBinding(element, element.endBinding),
lastCommittedPoint: null,
startArrowhead, startArrowhead,
endArrowhead, endArrowhead,
points, points,

View File

@ -432,9 +432,12 @@ describe("Test Transform", () => {
boundElements: [{ id: text.id, type: "text" }], boundElements: [{ id: text.id, type: "text" }],
startBinding: { startBinding: {
elementId: rectangle.id, elementId: rectangle.id,
focus: 0,
gap: 1,
}, },
endBinding: { endBinding: {
elementId: ellipse.id, elementId: ellipse.id,
focus: -0,
}, },
}); });
@ -514,9 +517,12 @@ describe("Test Transform", () => {
boundElements: [{ id: text1.id, type: "text" }], boundElements: [{ id: text1.id, type: "text" }],
startBinding: { startBinding: {
elementId: text2.id, elementId: text2.id,
focus: 0,
gap: 1,
}, },
endBinding: { endBinding: {
elementId: text3.id, elementId: text3.id,
focus: -0,
}, },
}); });
@ -774,8 +780,8 @@ describe("Test Transform", () => {
const [arrow, rect] = excalidrawElements; const [arrow, rect] = excalidrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
fixedPoint: [-2.05, 0.5001], focus: -0,
mode: "orbit", gap: 25,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {

View File

@ -16,7 +16,7 @@ import {
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { bindBindingElement } from "@excalidraw/element"; import { bindLinearElement } from "@excalidraw/element";
import { import {
newArrowElement, newArrowElement,
newElement, newElement,
@ -330,10 +330,9 @@ const bindLinearElementToElement = (
} }
} }
bindBindingElement( bindLinearElement(
linearElement, linearElement,
startBoundElement as ExcalidrawBindableElement, startBoundElement as ExcalidrawBindableElement,
"orbit",
"start", "start",
scene, scene,
); );
@ -406,10 +405,9 @@ const bindLinearElementToElement = (
} }
} }
bindBindingElement( bindLinearElement(
linearElement, linearElement,
endBoundElement as ExcalidrawBindableElement, endBoundElement as ExcalidrawBindableElement,
"orbit",
"end", "end",
scene, scene,
); );

View File

@ -1,11 +1,26 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { import {
computeBoundTextPosition, computeBoundTextPosition,
distanceToElement,
doBoundsIntersect,
getBoundTextElement, getBoundTextElement,
getElementBounds,
getFreedrawOutlineAsSegments,
getFreedrawOutlinePoints,
intersectElementWithLineSegment, intersectElementWithLineSegment,
isArrowElement,
isFreeDrawElement,
isLineElement,
isPointInElement, isPointInElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { lineSegment, pointFrom } from "@excalidraw/math"; import {
lineSegment,
lineSegmentsDistance,
pointFrom,
polygon,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
@ -13,6 +28,8 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/element";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
@ -96,6 +113,7 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -131,6 +149,7 @@ export class EraserTrail extends AnimatedTrail {
pathSegment, pathSegment,
element, element,
candidateElementsMap, candidateElementsMap,
this.app.state.zoom.value,
); );
if (intersects) { if (intersects) {
@ -180,8 +199,33 @@ const eraserTest = (
pathSegment: LineSegment<GlobalPoint>, pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: number,
): boolean => { ): boolean => {
const lastPoint = pathSegment[1]; const lastPoint = pathSegment[1];
// PERF: Do a quick bounds intersection test first because it's cheap
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
const segmentBounds = [
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
] as Bounds;
const origElementBounds = getElementBounds(element, elementsMap);
const elementBounds: Bounds = [
origElementBounds[0] - threshold,
origElementBounds[1] - threshold,
origElementBounds[2] + threshold,
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
// There are shapes where the inner area should trigger erasing
// even though the eraser path segment doesn't intersect with or
// get close to the shape's stroke
if ( if (
shouldTestInside(element) && shouldTestInside(element) &&
isPointInElement(lastPoint, element, elementsMap) isPointInElement(lastPoint, element, elementsMap)
@ -189,6 +233,50 @@ const eraserTest = (
return true; return true;
} }
// Freedraw elements are tested for erasure by measuring the distance
// of the eraser path and the freedraw shape outline lines to a tolerance
// which offers a good visual precision at various zoom levels
if (isFreeDrawElement(element)) {
const outlinePoints = getFreedrawOutlinePoints(element);
const strokeSegments = getFreedrawOutlineAsSegments(
element,
outlinePoints,
elementsMap,
);
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
for (const seg of strokeSegments) {
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
return true;
}
}
const poly = polygon(
...(outlinePoints.map(([x, y]) =>
pointFrom<GlobalPoint>(element.x + x, element.y + y),
) as GlobalPoint[]),
);
// PERF: Check only one point of the eraser segment. If the eraser segment
// start is inside the closed freedraw shape, the other point is either also
// inside or the eraser segment will intersect the shape outline anyway
if (polygonIncludesPointNonZero(pathSegment[0], poly)) {
return true;
}
return false;
} else if (
isArrowElement(element) ||
(isLineElement(element) && !element.polygon)
) {
const tolerance = Math.max(
element.strokeWidth,
(element.strokeWidth * 2) / zoom,
);
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
}
const boundTextElement = getBoundTextElement(element, elementsMap); const boundTextElement = getBoundTextElement(element, elementsMap);
return ( return (

View File

@ -101,10 +101,7 @@ declare module "image-blob-reduce" {
interface CustomMatchers { interface CustomMatchers {
toBeNonNaNNumber(): void; toBeNonNaNNumber(): void;
toCloselyEqualPoints( toCloselyEqualPoints(points: readonly [number, number][]): void;
points: readonly [number, number][],
precision?: number,
): void;
} }
declare namespace jest { declare namespace jest {

View File

@ -332,7 +332,6 @@
"dismissSearch": "Escape to dismiss search", "dismissSearch": "Escape to dismiss search",
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool", "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line", "linearElement": "Click to start multiple points, drag for single line",
"arrowBindModifiers": "Hold Alt to bind inside, or CtrlOrCmd to disable binding",
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.", "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
"freeDraw": "Click and drag, release when you're finished", "freeDraw": "Click and drag, release when you're finished",
"text": "Tip: you can also add text by double-clicking anywhere with the selection tool", "text": "Tip: you can also add text by double-clicking anywhere with the selection tool",

View File

@ -81,8 +81,8 @@
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0", "@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.3", "@excalidraw/mermaid-to-excalidraw": "1.1.3",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6", "@radix-ui/react-popover": "1.1.6",
@ -97,8 +97,8 @@
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
"jotai": "2.11.0", "jotai": "2.11.0",
"jotai-scope": "0.7.2", "jotai-scope": "0.7.2",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"lodash.debounce": "4.0.8",
"nanoid": "3.3.3", "nanoid": "3.3.3",
"open-color": "1.9.1", "open-color": "1.9.1",
"pako": "2.0.3", "pako": "2.0.3",

View File

@ -1,84 +0,0 @@
import { isRenderThrottlingEnabled } from "../reactUtils";
export type Animation<R extends object> = (params: {
deltaTime: number;
state?: R;
}) => R | null | undefined;
export class AnimationController {
private static isRunning = false;
private static animations = new Map<
string,
{
animation: Animation<any>;
lastTime: number;
state: any;
}
>();
static start<R extends object>(key: string, animation: Animation<R>) {
const initialState = animation({
deltaTime: 0,
state: undefined,
});
if (initialState) {
AnimationController.animations.set(key, {
animation,
lastTime: 0,
state: initialState,
});
if (!AnimationController.isRunning) {
AnimationController.isRunning = true;
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
}
}
}
private static tick() {
if (AnimationController.animations.size > 0) {
for (const [key, animation] of AnimationController.animations) {
const now = performance.now();
const deltaTime =
animation.lastTime === 0 ? 0 : now - animation.lastTime;
const state = animation.animation({
deltaTime,
state: animation.state,
});
if (!state) {
AnimationController.animations.delete(key);
if (AnimationController.animations.size === 0) {
AnimationController.isRunning = false;
return;
}
} else {
animation.lastTime = now;
animation.state = state;
}
}
if (isRenderThrottlingEnabled()) {
requestAnimationFrame(AnimationController.tick);
} else {
setTimeout(AnimationController.tick, 0);
}
}
}
static running(key: string) {
return AnimationController.animations.has(key);
}
static cancel(key: string) {
AnimationController.animations.delete(key);
}
}

View File

@ -1,5 +1,26 @@
import { THEME, THEME_FILTER } from "@excalidraw/common"; import { THEME, THEME_FILTER } from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
import { getDiamondPoints } from "@excalidraw/element";
import { elementCenterPoint, getCornerRadius } from "@excalidraw/element";
import {
curve,
curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom,
pointRotateRads,
} from "@excalidraw/math";
import type {
ElementsMap,
ExcalidrawDiamondElement,
ExcalidrawRectanguloidElement,
} from "@excalidraw/element/types";
import type { StaticCanvasRenderConfig } from "../scene/types"; import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types"; import type { AppState, StaticCanvasAppState } from "../types";
@ -76,6 +97,163 @@ export const bootstrapCanvas = ({
return context; return context;
}; };
function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
tension = 0.5,
) {
const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
}
}
}
function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D,
points: GlobalPoint[],
tension = 0.5,
) {
const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length; i++) {
const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
}
}
}
export const drawHighlightForRectWithRotation = (
context: CanvasRenderingContext2D,
element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
padding: number,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element, elementsMap),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
let radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
if (radius === 0) {
radius = 0.01;
}
context.beginPath();
{
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius),
pointFrom(0, 0),
pointFrom(0 + radius, 0),
padding,
);
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0),
pointFrom(element.width, 0),
pointFrom(element.width, radius),
padding,
);
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height),
padding,
);
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height),
pointFrom(0, element.height),
pointFrom(0, element.height - radius),
padding,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0),
pointFrom(0, 0),
pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE,
);
const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius),
pointFrom(element.width, 0),
pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE,
);
const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE,
);
const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius),
pointFrom(0, element.height),
pointFrom(radius, element.height),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topLeftApprox[topLeftApprox.length - 1][0],
topLeftApprox[topLeftApprox.length - 1][1],
);
context.lineTo(bottomLeftApprox[0][0], bottomLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomLeftApprox);
context.lineTo(bottomRightApprox[0][0], bottomRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, bottomRightApprox);
context.lineTo(topRightApprox[0][0], topRightApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topRightApprox);
context.lineTo(topLeftApprox[0][0], topLeftApprox[0][1]);
drawCatmullRomQuadraticApprox(context, topLeftApprox);
}
context.closePath();
context.fill();
context.restore();
};
export const strokeEllipseWithRotation = (
context: CanvasRenderingContext2D,
width: number,
height: number,
cx: number,
cy: number,
angle: number,
) => {
context.beginPath();
context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2);
context.stroke();
};
export const strokeRectWithRotation = ( export const strokeRectWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
x: number, x: number,
@ -105,3 +283,147 @@ export const strokeRectWithRotation = (
} }
context.restore(); context.restore();
}; };
export const drawHighlightForDiamondWithRotation = (
context: CanvasRenderingContext2D,
padding: number,
element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
) => {
const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element, elementsMap),
element.angle,
);
context.save();
context.translate(x, y);
context.rotate(element.angle);
{
context.beginPath();
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding,
);
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding,
);
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding,
);
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
// Counter-clockwise for the cutout in the middle. We need to have an "inverse
// mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees.
{
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const topApprox = curveOffsetPoints(
curve(
pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY),
pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const rightApprox = curveOffsetPoints(
curve(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY),
pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const bottomApprox = curveOffsetPoints(
curve(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY),
pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
const leftApprox = curveOffsetPoints(
curve(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY),
pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE,
);
context.moveTo(
topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1],
);
context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox);
}
context.closePath();
context.fill();
context.restore();
};

View File

@ -1,5 +1,4 @@
import { import {
clamp,
pointFrom, pointFrom,
pointsEqual, pointsEqual,
type GlobalPoint, type GlobalPoint,
@ -10,7 +9,6 @@ import oc from "open-color";
import { import {
arrayToMap, arrayToMap,
BIND_MODE_TIMEOUT,
DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE, FRAME_STYLE,
invariant, invariant,
@ -18,12 +16,8 @@ import {
throttleRAF, throttleRAF,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element";
deconstructDiamondElement, import { LinearElementEditor } from "@excalidraw/element";
deconstructRectanguloidElement,
elementCenterPoint,
LinearElementEditor,
} from "@excalidraw/element";
import { import {
getOmitSidesForDevice, getOmitSidesForDevice,
getTransformHandles, getTransformHandles,
@ -50,6 +44,11 @@ import {
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
import type {
SuggestedBinding,
SuggestedPointBinding,
} from "@excalidraw/element";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@ -65,7 +64,6 @@ import type {
ExcalidrawTextElement, ExcalidrawTextElement,
GroupId, GroupId,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { renderSnaps } from "../renderer/renderSnaps"; import { renderSnaps } from "../renderer/renderSnaps";
@ -75,18 +73,17 @@ import {
SCROLLBAR_COLOR, SCROLLBAR_COLOR,
SCROLLBAR_WIDTH, SCROLLBAR_WIDTH,
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { type InteractiveCanvasAppState } from "../types";
import {
type AppClassProperties,
type InteractiveCanvasAppState,
} from "../types";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import { import {
bootstrapCanvas, bootstrapCanvas,
drawHighlightForDiamondWithRotation,
drawHighlightForRectWithRotation,
fillCircle, fillCircle,
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
strokeEllipseWithRotation,
strokeRectWithRotation, strokeRectWithRotation,
} from "./helpers"; } from "./helpers";
@ -192,236 +189,82 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
}; };
const renderBindingHighlightForBindableElement = ( const renderBindingHighlightForBindableElement = (
app: AppClassProperties,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
allElementsMap: NonDeletedSceneElementsMap, elementsMap: ElementsMap,
appState: InteractiveCanvasAppState, zoom: InteractiveCanvasAppState["zoom"],
deltaTime: number,
state?: { runtime: number },
) => { ) => {
const countdownInProgress = const padding = maxBindingGap(element, element.width, element.height, zoom);
app.state.bindMode === "orbit" && app.bindModeHandler !== null;
const remainingTime = context.fillStyle = "rgba(0,0,0,.05)";
BIND_MODE_TIMEOUT -
(state?.runtime ?? (countdownInProgress ? 0 : BIND_MODE_TIMEOUT));
const opacity = clamp((1 / BIND_MODE_TIMEOUT) * remainingTime, 0.0001, 1);
const offset = element.strokeWidth / 2;
switch (element.type) { switch (element.type) {
case "magicframe": case "rectangle":
case "text":
case "image":
case "iframe":
case "embeddable":
case "frame": case "frame":
context.save(); case "magicframe":
drawHighlightForRectWithRotation(context, element, elementsMap, padding);
context.translate(
element.x + appState.scrollX,
element.y + appState.scrollY,
);
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, ${opacity})`
: `rgba(106, 189, 252, ${opacity})`;
if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath();
context.roundRect(
0,
0,
element.width,
element.height,
FRAME_STYLE.radius / appState.zoom.value,
);
context.stroke();
context.closePath();
} else {
context.strokeRect(0, 0, element.width, element.height);
}
context.restore();
break; break;
default: case "diamond":
context.save(); drawHighlightForDiamondWithRotation(
context,
const center = elementCenterPoint(element, allElementsMap); padding,
const cx = center[0] + appState.scrollX; element,
const cy = center[1] + appState.scrollY; elementsMap,
context.translate(cx, cy);
context.rotate(element.angle as Radians);
context.translate(-cx, -cy);
context.translate(
element.x + appState.scrollX - offset,
element.y + appState.scrollY - offset,
); );
context.lineWidth =
clamp(2.5, element.strokeWidth * 1.75, 4) /
Math.max(0.25, appState.zoom.value);
context.strokeStyle =
appState.theme === THEME.DARK
? `rgba(3, 93, 161, ${opacity / 2})`
: `rgba(106, 189, 252, ${opacity / 2})`;
switch (element.type) {
case "ellipse":
context.beginPath();
context.ellipse(
(element.width + offset * 2) / 2,
(element.height + offset * 2) / 2,
(element.width + offset * 2) / 2,
(element.height + offset * 2) / 2,
0,
0,
2 * Math.PI,
);
context.closePath();
context.stroke();
break;
case "diamond":
{
const [segments, curves] = deconstructDiamondElement(
element,
offset,
);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x + offset,
segment[0][1] - element.y + offset,
);
context.lineTo(
segment[1][0] - element.x + offset,
segment[1][1] - element.y + offset,
);
context.stroke();
});
// Draw each curve individually (for rounded corners)
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(
start[0] - element.x + offset,
start[1] - element.y + offset,
);
context.bezierCurveTo(
control1[0] - element.x + offset,
control1[1] - element.y + offset,
control2[0] - element.x + offset,
control2[1] - element.y + offset,
end[0] - element.x + offset,
end[1] - element.y + offset,
);
context.stroke();
});
}
break;
default:
{
const [segments, curves] = deconstructRectanguloidElement(
element,
offset,
);
// Draw each line segment individually
segments.forEach((segment) => {
context.beginPath();
context.moveTo(
segment[0][0] - element.x + offset,
segment[0][1] - element.y + offset,
);
context.lineTo(
segment[1][0] - element.x + offset,
segment[1][1] - element.y + offset,
);
context.stroke();
});
// Draw each curve individually (for rounded corners)
curves.forEach((curve) => {
const [start, control1, control2, end] = curve;
context.beginPath();
context.moveTo(
start[0] - element.x + offset,
start[1] - element.y + offset,
);
context.bezierCurveTo(
control1[0] - element.x + offset,
control1[1] - element.y + offset,
control2[0] - element.x + offset,
control2[1] - element.y + offset,
end[0] - element.x + offset,
end[1] - element.y + offset,
);
context.stroke();
});
}
break;
}
context.restore();
break; break;
case "ellipse": {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation(
context,
width + padding + FIXED_BINDING_DISTANCE,
height + padding + FIXED_BINDING_DISTANCE,
x1 + width / 2,
y1 + height / 2,
element.angle,
);
break;
}
} }
};
// Middle indicator is not rendered after it expired const renderBindingHighlightForSuggestedPointBinding = (
if (!countdownInProgress || (state?.runtime ?? 0) > BIND_MODE_TIMEOUT) { context: CanvasRenderingContext2D,
return; suggestedBinding: SuggestedPointBinding,
} elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
const radius = 0.5 * (Math.min(element.width, element.height) / 2); const threshold = maxBindingGap(
bindableElement,
// Draw center snap area bindableElement.width,
context.save(); bindableElement.height,
context.translate(element.x + appState.scrollX, element.y + appState.scrollY); zoom,
const PROGRESS_RATIO = (1 / BIND_MODE_TIMEOUT) * remainingTime;
context.strokeStyle = "rgba(0, 0, 0, 0.2)";
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]);
context.lineDashOffset = (-PROGRESS_RATIO * 10) / appState.zoom.value;
context.beginPath();
context.ellipse(
element.width / 2,
element.height / 2,
radius,
radius,
0,
0,
2 * Math.PI,
);
context.stroke();
// context.strokeStyle = "transparent";
context.fillStyle = "rgba(0, 0, 0, 0.04)";
context.beginPath();
context.ellipse(
element.width / 2,
element.height / 2,
radius * (1 - opacity),
radius * (1 - opacity),
0,
0,
2 * Math.PI,
); );
context.fill(); context.strokeStyle = "rgba(0,0,0,0)";
context.fillStyle = "rgba(0,0,0,.05)";
context.restore(); const pointIndices =
startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1];
return { pointIndices.forEach((index) => {
runtime: (state?.runtime ?? 0) + deltaTime, const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
}; element,
index,
elementsMap,
);
fillCircle(context, x, y, threshold, true);
});
}; };
type ElementSelectionBorder = { type ElementSelectionBorder = {
@ -493,6 +336,23 @@ const renderSelectionBorder = (
context.restore(); context.restore();
}; };
const renderBindingHighlight = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
suggestedBinding: SuggestedBinding,
elementsMap: ElementsMap,
) => {
const renderHighlight = Array.isArray(suggestedBinding)
? renderBindingHighlightForSuggestedPointBinding
: renderBindingHighlightForBindableElement;
context.save();
context.translate(appState.scrollX, appState.scrollY);
renderHighlight(context, suggestedBinding as any, elementsMap, appState.zoom);
context.restore();
};
const renderFrameHighlight = ( const renderFrameHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
@ -866,7 +726,6 @@ const renderTextBox = (
}; };
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
app,
canvas, canvas,
elementsMap, elementsMap,
visibleElements, visibleElements,
@ -876,14 +735,7 @@ const _renderInteractiveScene = ({
appState, appState,
renderConfig, renderConfig,
device, device,
animationState, }: InteractiveSceneRenderConfig) => {
deltaTime,
}: InteractiveSceneRenderConfig): {
scrollBars?: ReturnType<typeof getScrollBars>;
atLeastOneVisibleElement: boolean;
elementsMap: RenderableElementsMap;
animationState?: typeof animationState;
} => {
if (canvas === null) { if (canvas === null) {
return { atLeastOneVisibleElement: false, elementsMap }; return { atLeastOneVisibleElement: false, elementsMap };
} }
@ -892,7 +744,6 @@ const _renderInteractiveScene = ({
canvas, canvas,
scale, scale,
); );
let nextAnimationState = animationState;
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
@ -962,24 +813,17 @@ const _renderInteractiveScene = ({
} }
} }
if (appState.isBindingEnabled && appState.suggestedBinding) { if (appState.isBindingEnabled) {
nextAnimationState = { appState.suggestedBindings
...animationState, .filter((binding) => binding != null)
bindingHighlight: renderBindingHighlightForBindableElement( .forEach((suggestedBinding) => {
app, renderBindingHighlight(
context, context,
appState.suggestedBinding, appState,
allElementsMap, suggestedBinding!,
appState, elementsMap,
deltaTime, );
animationState?.bindingHighlight, });
),
};
} else {
nextAnimationState = {
...animationState,
bindingHighlight: undefined,
};
} }
if (appState.frameToHighlight) { if (appState.frameToHighlight) {
@ -1047,11 +891,7 @@ const _renderInteractiveScene = ({
} }
// Paint selected elements // Paint selected elements
if ( if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
!appState.multiElement &&
!appState.newElement &&
!appState.selectedLinearElement?.isEditing
) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState); const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
const isSingleLinearElementSelected = const isSingleLinearElementSelected =
@ -1351,7 +1191,6 @@ const _renderInteractiveScene = ({
scrollBars, scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0, atLeastOneVisibleElement: visibleElements.length > 0,
elementsMap, elementsMap,
animationState: nextAnimationState,
}; };
}; };

View File

@ -66,7 +66,6 @@ export type InteractiveCanvasRenderConfig = {
remotePointerUsernames: Map<SocketId, string>; remotePointerUsernames: Map<SocketId, string>;
remotePointerButton: Map<SocketId, string | undefined>; remotePointerButton: Map<SocketId, string | undefined>;
selectionColor: string; selectionColor: string;
lastViewportPosition: { x: number; y: number };
// extra options passed to the renderer // extra options passed to the renderer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
renderScrollbars?: boolean; renderScrollbars?: boolean;
@ -89,12 +88,7 @@ export type StaticSceneRenderConfig = {
renderConfig: StaticCanvasRenderConfig; renderConfig: StaticCanvasRenderConfig;
}; };
export type InteractiveSceneRenderAnimationState = {
bindingHighlight: { runtime: number } | undefined;
};
export type InteractiveSceneRenderConfig = { export type InteractiveSceneRenderConfig = {
app: AppClassProperties;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elementsMap: RenderableElementsMap; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
@ -105,8 +99,6 @@ export type InteractiveSceneRenderConfig = {
renderConfig: InteractiveCanvasRenderConfig; renderConfig: InteractiveCanvasRenderConfig;
device: Device; device: Device;
callback: (data: RenderInteractiveSceneCallback) => void; callback: (data: RenderInteractiveSceneCallback) => void;
animationState?: InteractiveSceneRenderAnimationState;
deltaTime: number;
}; };
export type NewElementSceneRenderConfig = { export type NewElementSceneRenderConfig = {

View File

@ -11,7 +11,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -983,7 +982,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -1084,7 +1083,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1176,7 +1174,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
"message": "Added to library", "message": "Added to library",
@ -1298,7 +1296,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1390,7 +1387,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -1629,7 +1626,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -1721,7 +1717,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -1960,7 +1956,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2052,7 +2047,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
"message": "Copied styles.", "message": "Copied styles.",
@ -2174,7 +2169,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2264,7 +2258,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -2415,7 +2409,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2507,7 +2500,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -2713,7 +2706,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -2810,7 +2802,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -3085,7 +3077,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3177,7 +3168,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": { "toast": {
"message": "Copied styles.", "message": "Copied styles.",
@ -3578,7 +3569,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3670,7 +3660,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -3901,7 +3891,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -3993,7 +3982,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -4224,7 +4213,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -4319,7 +4307,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -4635,7 +4623,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -5604,7 +5591,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -5852,7 +5839,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -6823,7 +6809,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -7120,7 +7106,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -7754,7 +7739,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -7787,7 +7772,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -8753,7 +8737,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,
@ -8778,7 +8762,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@ -9747,7 +9730,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,

View File

@ -18,6 +18,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -134,6 +135,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

File diff suppressed because it is too large Load Diff

View File

@ -126,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 640725609, "versionNonce": 1006504105,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@ -163,7 +163,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 1051383431, "versionNonce": 1984422985,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@ -180,22 +180,19 @@ exports[`move element > rectangles with binding arrow 7`] = `
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id3", "elementId": "id3",
"fixedPoint": [ "focus": "-0.46667",
"-0.03333", "gap": 10,
"0.43333",
],
"mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "90.03375", "height": "81.40630",
"id": "id6", "id": "id6",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"moveMidPointsWithElement": false,
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
@ -203,8 +200,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
0, 0,
], ],
[ [
89, "81.00000",
"90.03375", "81.40630",
], ],
], ],
"roughness": 1, "roughness": 1,
@ -215,21 +212,18 @@ exports[`move element > rectangles with binding arrow 7`] = `
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id0", "elementId": "id0",
"fixedPoint": [ "focus": "-0.60000",
"1.10000", "gap": 10,
"0.50010",
],
"mode": "orbit",
}, },
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 9, "version": 11,
"versionNonce": 1996028265, "versionNonce": 1573789895,
"width": 89, "width": "81.00000",
"x": 106, "x": "110.00000",
"y": "46.01049", "y": 50,
} }
`; `;

View File

@ -16,6 +16,10 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [
70,
110,
],
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -45,8 +49,8 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 5, "version": 8,
"versionNonce": 1014066025, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@ -68,6 +72,10 @@ exports[`multi point mode in linear elements > line 3`] = `
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": [
70,
110,
],
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -96,8 +104,8 @@ exports[`multi point mode in linear elements > line 3`] = `
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 5, "version": 8,
"versionNonce": 1014066025, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,

View File

@ -16,6 +16,7 @@ exports[`select single element on the scene > arrow 1`] = `
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -64,6 +65,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
"id": "id0", "id": "id0",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

View File

@ -16,6 +16,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
"id": "id-arrow01", "id": "id-arrow01",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -174,6 +175,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"id": "id-freedraw01", "id": "id-freedraw01",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -220,6 +222,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
"id": "id-line01", "id": "id-line01",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
@ -267,6 +270,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
"id": "id-draw01", "id": "id-draw01",
"index": "a1", "index": "a1",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null,
"link": null, "link": null,
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,

View File

@ -157,9 +157,9 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `5`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
@ -195,9 +195,9 @@ describe("Test dragCreate", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`6`, `5`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);

View File

@ -1021,7 +1021,7 @@ describe("history", () => {
// leave editor // leave editor
Keyboard.keyPress(KEYS.ESCAPE); Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
@ -1038,7 +1038,7 @@ describe("history", () => {
]); ]);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
@ -1058,11 +1058,11 @@ describe("history", () => {
mouse.clickAt(0, 0); mouse.clickAt(0, 0);
mouse.clickAt(10, 10); mouse.clickAt(10, 10);
mouse.clickAt(20, 20); mouse.clickAt(20, 20);
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
@ -1079,10 +1079,10 @@ describe("history", () => {
]); ]);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false); // undo `open editor` expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1095,29 +1095,29 @@ describe("history", () => {
}), }),
]); ]);
// Keyboard.undo(); Keyboard.undo();
// expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
// expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(4);
// expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
// expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
// expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize` expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
// expect(h.elements).toEqual([ expect(h.elements).toEqual([
// expect.objectContaining({ expect.objectContaining({
// isDeleted: false, isDeleted: false,
// points: [ points: [
// [0, 0], [0, 0],
// [10, 10], [10, 10],
// [20, 0], [20, 0],
// ], ],
// }), }),
// ]); ]);
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: false, isDeleted: false,
@ -1130,8 +1130,9 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(5); expect(API.getRedoStack().length).toBe(6);
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1145,10 +1146,10 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(4); expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id); expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: false, isDeleted: false,
@ -1159,25 +1160,25 @@ describe("history", () => {
}), }),
]); ]);
// Keyboard.redo();
// expect(API.getUndoStack().length).toBe(2);
// expect(API.getRedoStack().length).toBe(3);
// expect(assertSelectedElements(h.elements[0]));
// expect(h.state.selectedLinearElement?.isEditing).toBe(false);
// expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
// expect(h.elements).toEqual([
// expect.objectContaining({
// isDeleted: false,
// points: [
// [0, 0],
// [10, 10],
// [20, 0],
// ],
// }),
// ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([
expect.objectContaining({
isDeleted: false,
points: [
[0, 0],
[10, 10],
[20, 0],
],
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(3);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor` expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
@ -1194,7 +1195,7 @@ describe("history", () => {
]); ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(2);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
@ -1211,7 +1212,7 @@ describe("history", () => {
]); ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(4); expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing).toBe(true); expect(h.state.selectedLinearElement?.isEditing).toBe(true);
@ -1228,7 +1229,7 @@ describe("history", () => {
]); ]);
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(assertSelectedElements(h.elements[0])); expect(assertSelectedElements(h.elements[0]));
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
@ -1588,13 +1589,13 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5); expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(rect1.boundElements).toStrictEqual([ expect(rect1.boundElements).toStrictEqual([
{ id: text.id, type: "text" }, { id: text.id, type: "text" },
@ -1611,13 +1612,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1634,13 +1635,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1665,13 +1666,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1688,13 +1689,13 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({ expect(arrow.startBinding).toEqual({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(arrow.endBinding).toEqual({ expect(arrow.endBinding).toEqual({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}); });
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -1743,19 +1744,13 @@ describe("history", () => {
id: arrow.id, id: arrow.id,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: true, isDeleted: true,
}), }),
@ -1794,19 +1789,13 @@ describe("history", () => {
id: arrow.id, id: arrow.id,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: false, isDeleted: false,
}), }),
@ -1844,11 +1833,8 @@ describe("history", () => {
startBinding: null, startBinding: null,
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: false, isDeleted: false,
}), }),
@ -1882,19 +1868,13 @@ describe("history", () => {
id: arrow.id, id: arrow.id,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: false, isDeleted: false,
}), }),
@ -1961,19 +1941,13 @@ describe("history", () => {
id: arrow.id, id: arrow.id,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: false, isDeleted: false,
}), }),
@ -2324,13 +2298,15 @@ describe("history", () => {
], ],
startBinding: { startBinding: {
elementId: "KPrBI4g_v9qUB1XxYLgSz", elementId: "KPrBI4g_v9qUB1XxYLgSz",
focus: -0.001587301587301948,
gap: 5,
fixedPoint: [1.0318471337579618, 0.49920634920634904], fixedPoint: [1.0318471337579618, 0.49920634920634904],
mode: "orbit",
} as FixedPointBinding, } as FixedPointBinding,
endBinding: { endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5", elementId: "u2JGnnmoJ0VATV4vCNJE5",
focus: -0.0016129032258049847,
gap: 3.537079145500037,
fixedPoint: [0.4991935483870975, -0.03875193720914723], fixedPoint: [0.4991935483870975, -0.03875193720914723],
mode: "orbit",
} as FixedPointBinding, } as FixedPointBinding,
}, },
], ],
@ -2445,9 +2421,10 @@ describe("history", () => {
captureUpdate: CaptureUpdateAction.NEVER, captureUpdate: CaptureUpdateAction.NEVER,
}); });
Keyboard.undo(); // undo `actionFinalize`
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
points: [ points: [
@ -2461,7 +2438,7 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(3);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: true, isDeleted: true,
@ -2474,7 +2451,7 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(2);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
isDeleted: false, isDeleted: false,
@ -2487,6 +2464,21 @@ describe("history", () => {
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(2); expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
points: [
[0, 0],
[5, 5],
[10, 10],
[15, 15],
[20, 20],
],
}),
]);
Keyboard.redo(); // redo `actionFinalize`
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
@ -2986,7 +2978,7 @@ describe("history", () => {
// leave editor // leave editor
Keyboard.keyPress(KEYS.ESCAPE); Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedLinearElement).not.toBeNull(); expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement?.isEditing).toBe(false); expect(h.state.selectedLinearElement?.isEditing).toBe(false);
@ -3003,11 +2995,11 @@ describe("history", () => {
Keyboard.undo(); Keyboard.undo();
expect(API.getUndoStack().length).toBe(0); expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(3); expect(API.getRedoStack().length).toBe(4);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
Keyboard.redo(); Keyboard.redo();
expect(API.getUndoStack().length).toBe(3); expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0); expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedLinearElement).toBeNull(); expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
@ -4508,30 +4500,16 @@ describe("history", () => {
// create start binding // create start binding
mouse.downAt(0, 0); mouse.downAt(0, 0);
mouse.moveTo(0, 10); mouse.moveTo(0, 1);
mouse.moveTo(0, 10); mouse.moveTo(0, 0);
mouse.up(); mouse.up();
// create end binding // create end binding
mouse.downAt(100, 0); mouse.downAt(100, 0);
mouse.moveTo(100, 10); mouse.moveTo(100, 1);
mouse.moveTo(100, 10); mouse.moveTo(100, 0);
mouse.up(); mouse.up();
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding
?.fixedPoint,
).not.toEqual([1, 0.5001]);
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).startBinding?.mode,
).toBe("orbit");
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).endBinding,
).not.toEqual([1, 0.5001]);
expect(
(h.elements[2] as ExcalidrawElbowArrowElement).endBinding?.mode,
).toBe("orbit");
expect(h.elements).toEqual( expect(h.elements).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@ -4546,19 +4524,13 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
}), }),
]), ]),
@ -4571,16 +4543,12 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [{ id: arrowId, type: "arrow" }], boundElements: [],
}), }),
expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: null,
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null, endBinding: null,
}), }),
]); ]);
@ -4625,13 +4593,13 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: [1, 0.6], focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [0, 0.6], focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}), }),
}), }),
]), ]),
@ -4644,21 +4612,12 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: [],
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}), }),
expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: null,
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null, endBinding: null,
}), }),
]); ]);
@ -4677,13 +4636,13 @@ describe("history", () => {
// create start binding // create start binding
mouse.downAt(0, 0); mouse.downAt(0, 0);
mouse.moveTo(0, 10); mouse.moveTo(0, 1);
mouse.upAt(0, 10); mouse.upAt(0, 0);
// create end binding // create end binding
mouse.downAt(100, 0); mouse.downAt(100, 0);
mouse.moveTo(100, 10); mouse.moveTo(100, 1);
mouse.upAt(100, 10); mouse.upAt(100, 0);
expect(h.elements).toEqual( expect(h.elements).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@ -4699,19 +4658,13 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
}), }),
]), ]),
@ -4724,21 +4677,12 @@ describe("history", () => {
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: [],
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}), }),
expect.objectContaining({ id: rect2.id, boundElements: [] }), expect.objectContaining({ id: rect2.id, boundElements: [] }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: null,
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: null, endBinding: null,
}), }),
]); ]);
@ -4758,8 +4702,9 @@ describe("history", () => {
newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, {
endBinding: { endBinding: {
elementId: remoteContainer.id, elementId: remoteContainer.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}), }),
remoteContainer, remoteContainer,
@ -4786,14 +4731,14 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: [1, 0.6], focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}), }),
// rebound with previous rectangle // rebound with previous rectangle
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: [0, 0.6], focus: expect.toBeNonNaNNumber(),
mode: "orbit", gap: expect.toBeNonNaNNumber(),
}), }),
}), }),
expect.objectContaining({ expect.objectContaining({
@ -4811,12 +4756,7 @@ describe("history", () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: rect1.id, id: rect1.id,
boundElements: [ boundElements: [],
expect.objectContaining({
id: arrowId,
type: "arrow",
}),
],
}), }),
expect.objectContaining({ expect.objectContaining({
id: rect2.id, id: rect2.id,
@ -4824,16 +4764,16 @@ describe("history", () => {
}), }),
expect.objectContaining({ expect.objectContaining({
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: null,
elementId: rect1.id,
fixedPoint: [1, 0.5001],
mode: "inside",
}),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
// now we are back in the previous state! // now we are back in the previous state!
elementId: remoteContainer.id, elementId: remoteContainer.id,
fixedPoint: [0.5, 1], fixedPoint: [
mode: "orbit", expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
],
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
}), }),
expect.objectContaining({ expect.objectContaining({
@ -4851,13 +4791,15 @@ describe("history", () => {
type: "arrow", type: "arrow",
startBinding: { startBinding: {
elementId: rect1.id, elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5], fixedPoint: [1, 0.5],
mode: "orbit",
}, },
endBinding: { endBinding: {
elementId: rect2.id, elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });
@ -4911,7 +4853,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
// now we are back in the previous state! // now we are back in the previous state!
@ -4920,7 +4863,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
}), }),
expect.objectContaining({ expect.objectContaining({
@ -4956,13 +4900,15 @@ describe("history", () => {
newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, { newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, {
startBinding: { startBinding: {
elementId: rect1.id, elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
endBinding: { endBinding: {
elementId: rect2.id, elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5], fixedPoint: [1, 0.5],
mode: "orbit",
}, },
}), }),
newElementWith(rect1, { newElementWith(rect1, {
@ -4989,7 +4935,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
@ -4997,7 +4944,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
isDeleted: true, isDeleted: true,
}), }),
@ -5027,7 +4975,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}, },
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
@ -5035,7 +4984,8 @@ describe("history", () => {
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), expect.toBeNonNaNNumber(),
], ],
mode: "orbit", focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
}), }),
isDeleted: false, isDeleted: false,
}), }),
@ -5078,11 +5028,13 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([1, 0.5001]), focus: 0,
gap: 1,
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([0, 0.5001]), focus: -0,
gap: 1,
}), }),
isDeleted: true, isDeleted: true,
}), }),
@ -5124,19 +5076,13 @@ describe("history", () => {
id: arrowId, id: arrowId,
startBinding: expect.objectContaining({ startBinding: expect.objectContaining({
elementId: rect1.id, elementId: rect1.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
endBinding: expect.objectContaining({ endBinding: expect.objectContaining({
elementId: rect2.id, elementId: rect2.id,
fixedPoint: expect.arrayContaining([ focus: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(),
expect.toBeNonNaNNumber(),
]),
mode: "orbit",
}), }),
isDeleted: false, isDeleted: false,
}), }),

View File

@ -210,6 +210,7 @@ describe("Basic lasso selection tests", () => {
[0, 0], [0, 0],
[168.4765625, -153.38671875], [168.4765625, -153.38671875],
], ],
lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: null, startArrowhead: null,
@ -249,6 +250,7 @@ describe("Basic lasso selection tests", () => {
[0, 0], [0, 0],
[206.12890625, 35.4140625], [206.12890625, 35.4140625],
], ],
lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: null, startArrowhead: null,
@ -352,6 +354,7 @@ describe("Basic lasso selection tests", () => {
], ],
pressures: [], pressures: [],
simulatePressure: true, simulatePressure: true,
lastCommittedPoint: null,
}, },
].map( ].map(
(e) => (e) =>
@ -1226,6 +1229,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [
@ -1267,6 +1271,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [
@ -1307,6 +1312,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [
@ -1347,6 +1353,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [
@ -1685,6 +1692,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [
@ -1736,6 +1744,7 @@ describe("Special cases", () => {
locked: false, locked: false,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
lastCommittedPoint: null,
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
points: [ points: [

View File

@ -111,8 +111,9 @@ describe("library", () => {
type: "arrow", type: "arrow",
endBinding: { endBinding: {
elementId: "rectangle1", elementId: "rectangle1",
focus: -1,
gap: 0,
fixedPoint: [0.5, 1], fixedPoint: [0.5, 1],
mode: "orbit",
}, },
}); });

View File

@ -1,12 +1,16 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { bindOrUnbindLinearElement } from "@excalidraw/element";
import { KEYS, reseed } from "@excalidraw/common"; import { KEYS, reseed } from "@excalidraw/common";
import { bindBindingElement } from "@excalidraw/element";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
import type { import type {
ExcalidrawArrowElement, ExcalidrawLinearElement,
NonDeleted, NonDeleted,
ExcalidrawRectangleElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
@ -79,21 +83,12 @@ describe("move element", () => {
const rectA = UI.createElement("rectangle", { size: 100 }); const rectA = UI.createElement("rectangle", { size: 100 });
const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 });
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 }); const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
act(() => { act(() => {
// bind line to two rectangles // bind line to two rectangles
bindBindingElement( bindOrUnbindLinearElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>, arrow.get() as NonDeleted<ExcalidrawLinearElement>,
rectA.get(), rectA.get() as ExcalidrawRectangleElement,
"orbit", rectB.get() as ExcalidrawRectangleElement,
"start",
h.app.scene,
);
bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
rectB.get(),
"orbit",
"end",
h.app.scene, h.app.scene,
); );
}); });
@ -102,16 +97,16 @@ describe("move element", () => {
new Pointer("mouse").clickOn(rectB); new Pointer("mouse").clickOn(rectB);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`15`, `17`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`14`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`13`);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3); expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]); expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]], 0); expect([arrow.x, arrow.y]).toEqual([110, 50]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[80, 80]], 0); expect([arrow.width, arrow.height]).toEqual([80, 80]);
renderInteractiveScene.mockClear(); renderInteractiveScene.mockClear();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
@ -129,11 +124,8 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[106, 46]], 0); expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints( expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
[[89, 90.033]],
0,
);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });

View File

@ -118,10 +118,8 @@ describe("multi point mode in linear elements", () => {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
`11`, expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;
@ -163,10 +161,8 @@ describe("multi point mode in linear elements", () => {
fireEvent.keyDown(document, { fireEvent.keyDown(document, {
key: KEYS.ENTER, key: KEYS.ENTER,
}); });
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`7`);
`11`, expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
const element = h.elements[0] as ExcalidrawLinearElement; const element = h.elements[0] as ExcalidrawLinearElement;

View File

@ -363,6 +363,7 @@ describe("regression tests", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
Keyboard.keyPress(KEYS.Z); Keyboard.keyPress(KEYS.Z);
Keyboard.keyPress(KEYS.Z);
}); });
expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2); expect(h.elements.filter((element) => !element.isDeleted).length).toBe(2);
Keyboard.withModifierKeys({ ctrl: true }, () => { Keyboard.withModifierKeys({ ctrl: true }, () => {

View File

@ -24,7 +24,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: -80, x: -80,
y: 50, y: 50,
width: 85, width: 70,
height: 0, height: 0,
}); });
@ -35,8 +35,8 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80); expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50); expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(84.9, 1); expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(52.717, 1); expect(arrow.height).toBeCloseTo(0);
}); });
test("unselected bound arrows update when rotating their target elements", async () => { test("unselected bound arrows update when rotating their target elements", async () => {
@ -48,10 +48,9 @@ test("unselected bound arrows update when rotating their target elements", async
height: 120, height: 120,
}); });
const ellipseArrow = UI.createElement("arrow", { const ellipseArrow = UI.createElement("arrow", {
x: -10, position: 0,
y: 80, width: 40,
width: 50, height: 80,
height: 60,
}); });
const text = UI.createElement("text", { const text = UI.createElement("text", {
position: 220, position: 220,
@ -60,8 +59,8 @@ test("unselected bound arrows update when rotating their target elements", async
const textArrow = UI.createElement("arrow", { const textArrow = UI.createElement("arrow", {
x: 360, x: 360,
y: 300, y: 300,
width: -140, width: -100,
height: -60, height: -40,
}); });
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
@ -70,16 +69,16 @@ test("unselected bound arrows update when rotating their target elements", async
UI.rotate([ellipse, text], [-82, 23], { shift: true }); UI.rotate([ellipse, text], [-82, 23], { shift: true });
expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id); expect(ellipseArrow.endBinding?.elementId).toEqual(ellipse.id);
expect(ellipseArrow.x).toEqual(-10); expect(ellipseArrow.x).toEqual(0);
expect(ellipseArrow.y).toEqual(80); expect(ellipseArrow.y).toEqual(0);
expect(ellipseArrow.points[0]).toEqual([0, 0]); expect(ellipseArrow.points[0]).toEqual([0, 0]);
expect(ellipseArrow.points[1][0]).toBeCloseTo(42.318, 1); expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1);
expect(ellipseArrow.points[1][1]).toBeCloseTo(92.133, 1); expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1);
expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.endBinding?.elementId).toEqual(text.id);
expect(textArrow.x).toEqual(360); expect(textArrow.x).toEqual(360);
expect(textArrow.y).toEqual(300); expect(textArrow.y).toEqual(300);
expect(textArrow.points[0]).toEqual([0, 0]); expect(textArrow.points[0]).toEqual([0, 0]);
expect(textArrow.points[1][0]).toBeCloseTo(-98.86, 0); expect(textArrow.points[1][0]).toBeCloseTo(-94, 0);
expect(textArrow.points[1][1]).toBeCloseTo(-123.65, 0); expect(textArrow.points[1][1]).toBeCloseTo(-116.1, 0);
}); });

View File

@ -425,8 +425,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -469,8 +469,8 @@ describe("select single element on the scene", () => {
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 }); fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40 });
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(9); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@ -487,12 +487,7 @@ describe("tool locking & selection", () => {
expect(h.state.activeTool.locked).toBe(true); expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) { for (const { value } of Object.values(SHAPES)) {
if ( if (value !== "image" && value !== "selection" && value !== "eraser") {
value !== "image" &&
value !== "selection" &&
value !== "eraser" &&
value !== "arrow"
) {
const element = UI.createElement(value); const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true); expect(h.state.selectedElementIds[element.id]).not.toBe(true);
} }

View File

@ -5,6 +5,8 @@ import type {
MIME_TYPES, MIME_TYPES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { SuggestedBinding } from "@excalidraw/element";
import type { LinearElementEditor } from "@excalidraw/element"; import type { LinearElementEditor } from "@excalidraw/element";
import type { MaybeTransformHandleType } from "@excalidraw/element"; import type { MaybeTransformHandleType } from "@excalidraw/element";
@ -31,7 +33,6 @@ import type {
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@ -203,7 +204,6 @@ export type StaticCanvasAppState = Readonly<
frameRendering: AppState["frameRendering"]; frameRendering: AppState["frameRendering"];
currentHoveredFontFamily: AppState["currentHoveredFontFamily"]; currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
hoveredElementIds: AppState["hoveredElementIds"]; hoveredElementIds: AppState["hoveredElementIds"];
suggestedBinding: AppState["suggestedBinding"];
// Cropping // Cropping
croppingElementId: AppState["croppingElementId"]; croppingElementId: AppState["croppingElementId"];
} }
@ -217,9 +217,8 @@ export type InteractiveCanvasAppState = Readonly<
selectedGroupIds: AppState["selectedGroupIds"]; selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElement: AppState["selectedLinearElement"]; selectedLinearElement: AppState["selectedLinearElement"];
multiElement: AppState["multiElement"]; multiElement: AppState["multiElement"];
newElement: AppState["newElement"];
isBindingEnabled: AppState["isBindingEnabled"]; isBindingEnabled: AppState["isBindingEnabled"];
suggestedBinding: AppState["suggestedBinding"]; suggestedBindings: AppState["suggestedBindings"];
isRotating: AppState["isRotating"]; isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"]; elementsToHighlight: AppState["elementsToHighlight"];
// Collaborators // Collaborators
@ -234,11 +233,6 @@ export type InteractiveCanvasAppState = Readonly<
// Search matches // Search matches
searchMatches: AppState["searchMatches"]; searchMatches: AppState["searchMatches"];
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
// Non-used but needed in binding highlight arrow overdraw
hoveredElementIds: AppState["hoveredElementIds"];
frameRendering: AppState["frameRendering"];
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
exportScale: AppState["exportScale"];
} }
>; >;
@ -298,7 +292,7 @@ export interface AppState {
selectionElement: NonDeletedExcalidrawElement | null; selectionElement: NonDeletedExcalidrawElement | null;
isBindingEnabled: boolean; isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: NonDeleted<ExcalidrawBindableElement> | null; suggestedBindings: SuggestedBinding[];
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null; frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
frameRendering: { frameRendering: {
enabled: boolean; enabled: boolean;
@ -452,7 +446,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements // as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map // and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true }; lockedMultiSelections: { [groupId: string]: true };
bindMode: BindMode;
/** properties sidebar mode - determines whether to show compact or complete sidebar */ /** properties sidebar mode - determines whether to show compact or complete sidebar */
stylesPanelMode: "compact" | "full"; stylesPanelMode: "compact" | "full";
@ -472,7 +465,7 @@ export type SearchMatch = {
export type UIAppState = Omit< export type UIAppState = Omit<
AppState, AppState,
| "suggestedBinding" | "suggestedBindings"
| "startBoundElement" | "startBoundElement"
| "cursorButton" | "cursorButton"
| "scrollX" | "scrollX"
@ -747,8 +740,6 @@ export type AppClassProperties = {
updateEditorAtom: App["updateEditorAtom"]; updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso"; defaultSelectionTool: "selection" | "lasso";
bindModeHandler: App["bindModeHandler"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{

View File

@ -21,9 +21,20 @@ export function curve<Point extends GlobalPoint | LocalPoint>(
return [a, b, c, d] as Curve<Point>; return [a, b, c, d] as Curve<Point>;
} }
function solveWithAnalyticalJacobian<Point extends GlobalPoint | LocalPoint>( function gradient(
curve: Curve<Point>, f: (t: number, s: number) => number,
lineSegment: LineSegment<Point>, t0: number,
s0: number,
delta: number = 1e-6,
): number[] {
return [
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
];
}
function solve(
f: (t: number, s: number) => [number, number],
t0: number, t0: number,
s0: number, s0: number,
tolerance: number = 1e-3, tolerance: number = 1e-3,
@ -37,75 +48,33 @@ function solveWithAnalyticalJacobian<Point extends GlobalPoint | LocalPoint>(
return null; return null;
} }
// Compute bezier point at parameter t0 const y0 = f(t0, s0);
const bt = 1 - t0; const jacobian = [
const bt2 = bt * bt; gradient((t, s) => f(t, s)[0], t0, s0),
const bt3 = bt2 * bt; gradient((t, s) => f(t, s)[1], t0, s0),
const t0_2 = t0 * t0; ];
const t0_3 = t0_2 * t0; const b = [[-y0[0]], [-y0[1]]];
const det =
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
const bezierX = if (det === 0) {
bt3 * curve[0][0] +
3 * bt2 * t0 * curve[1][0] +
3 * bt * t0_2 * curve[2][0] +
t0_3 * curve[3][0];
const bezierY =
bt3 * curve[0][1] +
3 * bt2 * t0 * curve[1][1] +
3 * bt * t0_2 * curve[2][1] +
t0_3 * curve[3][1];
// Compute line point at parameter s0
const lineX =
lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]);
const lineY =
lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]);
// Function values
const fx = bezierX - lineX;
const fy = bezierY - lineY;
error = Math.abs(fx) + Math.abs(fy);
if (error < tolerance) {
break;
}
// Analytical derivatives
const dfx_dt =
-3 * bt2 * curve[0][0] +
3 * bt2 * curve[1][0] -
6 * bt * t0 * curve[1][0] -
3 * t0_2 * curve[2][0] +
6 * bt * t0 * curve[2][0] +
3 * t0_2 * curve[3][0];
const dfy_dt =
-3 * bt2 * curve[0][1] +
3 * bt2 * curve[1][1] -
6 * bt * t0 * curve[1][1] -
3 * t0_2 * curve[2][1] +
6 * bt * t0 * curve[2][1] +
3 * t0_2 * curve[3][1];
// Line derivatives
const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]);
const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]);
// Jacobian determinant
const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt;
if (Math.abs(det) < 1e-12) {
return null; return null;
} }
// Newton step const iJ = [
const invDet = 1 / det; [jacobian[1][1] / det, -jacobian[0][1] / det],
const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy); [-jacobian[1][0] / det, jacobian[0][0] / det],
const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy); ];
const h = [
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
];
t0 += dt; t0 = t0 + h[0][0];
s0 += ds; s0 = s0 + h[1][0];
const [tErr, sErr] = f(t0, s0);
error = Math.max(Math.abs(tErr), Math.abs(sErr));
iter += 1; iter += 1;
} }
@ -127,49 +96,63 @@ export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
t ** 3 * c[3][1], t ** 3 * c[3][1],
); );
const initial_guesses: [number, number][] = [
[0.5, 0],
[0.2, 0],
[0.8, 0],
];
const calculate = <Point extends GlobalPoint | LocalPoint>(
[t0, s0]: [number, number],
l: LineSegment<Point>,
c: Curve<Point>,
) => {
const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 3);
if (!solution) {
return null;
}
const [t, s] = solution;
if (t < 0 || t > 1 || s < 0 || s > 1) {
return null;
}
return bezierEquation(c, t);
};
/** /**
* Computes the intersection between a cubic spline and a line segment. * Computes the intersection between a cubic spline and a line segment.
*/ */
export function curveIntersectLineSegment< export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint, Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] { >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
let solution = calculate(initial_guesses[0], l, c); const line = (s: number) =>
pointFrom<Point>(
l[0][0] + s * (l[1][0] - l[0][0]),
l[0][1] + s * (l[1][1] - l[0][1]),
);
const initial_guesses: [number, number][] = [
[0.5, 0],
[0.2, 0],
[0.8, 0],
];
const calculate = ([t0, s0]: [number, number]) => {
const solution = solve(
(t: number, s: number) => {
const bezier_point = bezierEquation(c, t);
const line_point = line(s);
return [
bezier_point[0] - line_point[0],
bezier_point[1] - line_point[1],
];
},
t0,
s0,
);
if (!solution) {
return null;
}
const [t, s] = solution;
if (t < 0 || t > 1 || s < 0 || s > 1) {
return null;
}
return bezierEquation(c, t);
};
let solution = calculate(initial_guesses[0]);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate(initial_guesses[1], l, c); solution = calculate(initial_guesses[1]);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate(initial_guesses[2], l, c); solution = calculate(initial_guesses[2]);
if (solution) { if (solution) {
return [solution]; return [solution];
} }

View File

@ -177,3 +177,19 @@ export function lineSegmentIntersectionPoints<
return candidate; return candidate;
} }
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
s1: LineSegment<Point>,
s2: LineSegment<Point>,
): number {
if (lineSegmentIntersectionPoints(s1, s2)) {
return 0;
}
return Math.min(
distanceToLineSegment(s1[0], s2),
distanceToLineSegment(s1[1], s2),
distanceToLineSegment(s2[0], s1),
distanceToLineSegment(s2[1], s1),
);
}

View File

@ -46,11 +46,9 @@ describe("Math curve", () => {
pointFrom(10, 50), pointFrom(10, 50),
pointFrom(50, 50), pointFrom(50, 50),
); );
const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60)); const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]);
[9.99, 5.05],
]);
}); });
it("can be detected where the determinant is overly precise", () => { it("can be detected where the determinant is overly precise", () => {

View File

@ -6,11 +6,11 @@ expect.extend({
throw new Error("expected and received are not point arrays"); throw new Error("expected and received are not point arrays");
} }
const COMPARE = 1 / precision === 0 ? 1 : Math.pow(10, precision ?? 2); const COMPARE = 1 / Math.pow(10, precision || 2);
const pass = expected.every( const pass = expected.every(
(point, idx) => (point, idx) =>
Math.abs(received[idx][0] - point[0]) < COMPARE && Math.abs(received[idx]?.[0] - point[0]) < COMPARE &&
Math.abs(received[idx][1] - point[1]) < COMPARE, Math.abs(received[idx]?.[1] - point[1]) < COMPARE,
); );
if (!pass) { if (!pass) {

View File

@ -63,8 +63,6 @@ export const debugDrawLine = (
); );
}; };
export const testDebug = () => {};
export const debugDrawPoint = ( export const debugDrawPoint = (
p: GlobalPoint, p: GlobalPoint,
opts?: { opts?: {

View File

@ -11,7 +11,6 @@ exports[`exportToSvg > with default arguments 1`] = `
"locked": false, "locked": false,
"type": "selection", "type": "selection",
}, },
"bindMode": "orbit",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@ -102,7 +101,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"panels": 3, "panels": 3,
}, },
"stylesPanelMode": "full", "stylesPanelMode": "full",
"suggestedBinding": null, "suggestedBindings": [],
"theme": "light", "theme": "light",
"toast": null, "toast": null,
"userToFollow": null, "userToFollow": null,