Compare commits

..

32 Commits

Author SHA1 Message Date
83004e2c01 Fix fixed angle orbiting 2025-07-24 17:38:57 +02:00
57e8734b3f Fix z-index so it works on hover 2025-07-24 14:57:13 +02:00
892d2f425d Bind mode on precise binding
Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow
2025-07-24 12:08:46 +02:00
1cfbc4b2ca z-index update 2025-07-24 12:08:15 +02:00
263d6805e4 Remove invariants from debug
feat: expose `applyTo` options, don't commit empty text element (#9744)

* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements

test: added test file for distribute (#9754)
2025-07-24 12:08:15 +02:00
1739fa92b1 Turn off inside binding mode after leaving a shape 2025-07-24 12:08:15 +02:00
c955b2716a Make z-index arrow reorder on bind 2025-07-24 12:08:15 +02:00
149bb3481a Include point updates after binding update
Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows
2025-07-24 12:08:15 +02:00
64e3e8a044 Completely rewritten binding 2025-07-24 12:08:15 +02:00
a8c5c15fbf Removed point binding
Binding code refactor

Added centered focus point

Binding & focus point debug

Add invariants to check binding integrity in elements

Binding fixes

Small refactors
2025-07-24 12:08:15 +02:00
3e090ebc4f Restore drag 2025-07-24 12:08:15 +02:00
41cfbf7840 Fix lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix point click array creation interaction with fixed point binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Restrict new behavior to arrows only

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Allow binding inside images

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Fix already existing fixed binding retention

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

Refactor and implement fixed point binding for unfilled elements
2025-07-24 12:08:15 +02:00
1a605a6ad0 Only transparent bindables allow binding fallthrough
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 12:06:23 +02:00
8ecf9f8607 Binding highlight fixes
Change bind mode timeout logic

Fix tests

Add Alt bindMode switch

 No dragging of arrows when bound, similar to elbow

Fix timeout not taking effect immediately

Bumop z-index for arrows when dragged

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 12:06:23 +02:00
5a3c1469d1 Update simple arrow fixed point when arrow is dragged or moved by arrow keys 2025-07-24 12:04:31 +02:00
8a2d3f7874 Apply fixes
Remove code to unbind on drag
2025-07-24 12:04:31 +02:00
cf71b215f0 Timed binding mode change for simple arrows 2025-07-24 12:04:31 +02:00
9e1e96697d Fix binding disabled use-case triggering arrow editor 2025-07-24 12:04:12 +02:00
7de5d29b79 Fixed point binding for simple arrows when the arrow doesn't point to the element 2025-07-24 12:04:12 +02:00
b0ac15381b Arrow dragging gets a little drag to avoid accidental unbinding 2025-07-24 12:04:12 +02:00
70d4dd9152 Tests added
Fix binding

Remove unneeded params

Unfinished simple arrow avoidance

Fix newly created jumping arrow when gets outside

Do not apply the jumping logic to elbow arrows for new elements

Existing arrows now jump out

Type updates to support fixed binding for simple arrows

Fix crash for elbow arrws in mutateElement()

Refactored simple arrow creation

Updating tests

No confirm threshold when inside biding range

Fix multi-point arrow grid off

Make elbow arrows respect grids

Unbind arrow if bound and moved at shaft of arrow key

Fix binding test

Fix drag unbind when the bound element is in the selection

Do not move mid point for simple arrows bound on both ends

Add test for mobing mid points for simple arrows when bound on the same element on both ends

Fix linear editor bug when both midpoint and endpoint is moved

Fix all point multipoint arrow highlight and binding
2025-07-24 12:04:12 +02:00
1a499cc2c6 Fixed point binding for simple arrows 2025-07-24 12:02:32 +02:00
416da62138 fix: multiple line editor bugs (#9760)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-07-24 09:11:04 +02:00
f38f381989 fix: Remove flushSync from alt-lasso and elbow dragging (#9734)
* Remove lasso flushSync

* Remove selectedLinearElement flushSync

* Early return
2025-07-23 23:39:16 +02:00
e5e07260c6 fix: improve line creation ux on touch screens (#9740)
* fix: awkward point adding and removing on touch device

* feat: move finalize to next to last point

* feat: on touch screen, click would create a default line/arrow

* fix: make default adaptive to zoom

* fix: increase padding to avoid cutoffs

* refactor: simplify

* fix: only use bigger padding when needed

* center arrow horizontally on pointer

* increase min drag distance before we start 2-point-arrow-drag-creating

* do not render 0-width arrow while creating

* dead code

* fix tests

* fix: remove redundant code

* do not enter line editor on creation

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-07-23 18:49:56 +10:00
8492b144b0 test: added test file for distribute (#9754) 2025-07-17 19:52:16 +02:00
e46f038132 feat: expose applyTo options, don't commit empty text element (#9744)
* Expose applyTo options, skip re-draw for empty text

* Don't commit empty text elements
2025-07-17 15:22:32 +02:00
678dff25ed fix: ellipsify MainMenu and CommandPalette items (#9743)
* fix: ellipsify MainMenu and CommandPalette items

* fix lint
2025-07-15 12:59:55 +02:00
0cfa53b764 fix: aligning and distributing elements and nested groups while editing a group (#9721) 2025-07-15 12:43:42 +02:00
cde46793f8 feat: support timestamps for youtube video emebds (#9737) 2025-07-13 19:19:10 +02:00
2d127f8c22 docs: fix broken update scene button example in docs (#9726)
fix: update scene example in docs
2025-07-08 19:29:44 +05:30
4eadb891f8 fix(toast): prevent toast from re-rendering and resetting timeout Fixes #9714 (#9715)
* Update App.tsx

* fix: lint

---------

Co-authored-by: Ryan Di <ryan.weihao.di@gmail.com>
2025-07-03 17:07:26 +10:00
75 changed files with 4837 additions and 3564 deletions

View File

@ -33,6 +33,7 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;

View File

@ -669,6 +669,7 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
elements,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);

View File

@ -8,9 +8,15 @@ import {
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { arrayToMap, invariant, throttleRAF } from "@excalidraw/common";
import { useCallback, useImperativeHandle, useRef } from "react";
import {
getGlobalFixedPointForBindableElement,
isArrowElement,
isBindableElement,
} from "@excalidraw/element";
import {
isLineSegment,
type GlobalPoint,
@ -19,8 +25,14 @@ import {
import { isCurve } from "@excalidraw/math/curve";
import type { Curve } from "@excalidraw/math";
import type { DebugElement } from "@excalidraw/utils/visualdebug";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
@ -73,6 +85,158 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
context.save();
};
const _renderBinding = (
context: CanvasRenderingContext2D,
binding: FixedPointBinding,
elementsMap: ElementsMap,
zoom: number,
width: number,
height: number,
color: string,
) => {
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;
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) {
invariant(
elementsMap
.get(element.startBinding.elementId)
?.boundElements?.find((e) => e.id === element.id),
"Missing record in boundElements for arrow",
);
_renderBinding(
context,
element.startBinding,
elementsMap,
zoom,
dim,
dim,
"red",
);
}
if (element.endBinding) {
_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 = (
frame: DebugElement[],
context: CanvasRenderingContext2D,
@ -105,6 +269,7 @@ const render = (
const _debugRenderer = (
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
@ -133,6 +298,7 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@ -184,10 +350,11 @@ export const debugRenderer = throttleRAF(
(
canvas: HTMLCanvasElement,
appState: AppState,
elements: readonly OrderedExcalidrawElement[],
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
_debugRenderer(canvas, appState, elements, scale, refresh);
},
{ trailing: true },
);

View File

@ -36,6 +36,7 @@ export const APP_NAME = "Excalidraw";
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
@ -514,3 +515,5 @@ export enum UserIdleState {
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const BIND_MODE_TIMEOUT = 1500; // ms

View File

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

View File

@ -1,6 +1,8 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups";
import { getSelectedElementsByGroup } from "./groups";
import type { Scene } from "./Scene";
@ -16,11 +18,12 @@ export const alignElements = (
selectedElements: ExcalidrawElement[],
alignment: Alignment,
scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
selectedElements,
elementsMap,
scene.getNonDeletedElementsMap(),
appState,
);
const selectionBoundingBox = getCommonBoundingBox(selectedElements);

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { isTransparent } from "@excalidraw/common";
import { invariant, isTransparent } from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
@ -25,7 +25,7 @@ import type {
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import type { AppState, FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./utils";
import {
@ -38,6 +38,8 @@ import {
} from "./bounds";
import {
hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement,
isIframeLikeElement,
isImageElement,
@ -56,14 +58,21 @@ import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import { BINDING_HIGHLIGHT_THICKNESS, FIXED_BINDING_DISTANCE } from "./binding";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => {
@ -193,6 +202,141 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap);
};
export const maxBindingDistanceFromOutline = (
element: ExcalidrawElement,
elementWidth: number,
elementHeight: number,
zoom?: AppState["zoom"],
): number => {
const zoomValue = zoom?.value && zoom.value < 1 ? zoom.value : 1;
// Aligns diamonds with rectangles
const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
return Math.max(
16,
// bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
);
};
export const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingDistanceFromOutline(
element,
element.width,
element.height,
zoom,
);
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 bounds = [
x - threshold,
y - threshold,
x + threshold,
y + threshold,
] 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 <= threshold
: intersections.length > 0 && distance <= threshold;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, zoom)
) {
candidateElements.push(element);
}
}
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
export const getHoveredElementForBindingAndIfItsPrecise = (
point: GlobalPoint,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
zoom: AppState["zoom"],
): {
hovered: NonDeleted<ExcalidrawBindableElement> | null;
hit: boolean;
} => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
zoom,
);
// TODO: Optimize this to avoid recalculating the point - element distance
const hit =
!!hoveredElement &&
hitElementItself({
element: hoveredElement,
elementsMap,
point,
threshold: 0,
});
return { hovered: hoveredElement, hit };
};
/**
* Intersect a line with an element for binding test
*

View File

@ -1,7 +1,9 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getMaximumGroups } from "./groups";
import { getSelectedElementsByGroup } from "./groups";
import type { ElementsMap, ExcalidrawElement } from "./types";
@ -14,6 +16,7 @@ export const distributeElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
@ -21,7 +24,11 @@ export const distributeElements = (
: (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements);
const groups = getMaximumGroups(selectedElements, elementsMap)
const groups = getSelectedElementsByGroup(
selectedElements,
elementsMap,
appState,
)
.map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]);

View File

@ -13,7 +13,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding";
import { unbindBindingElement, updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement";
@ -102,9 +102,26 @@ export const dragSelectedElements = (
gridSize,
);
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const isArrow = !isArrowElement(element);
const isStartBoundElementSelected =
isArrow ||
(element.startBinding
? elementsToUpdateIds.has(element.startBinding.elementId)
: false);
const isEndBoundElementSelected =
isArrow ||
(element.endBinding
? elementsToUpdateIds.has(element.endBinding.elementId)
: false);
if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement(
element,
@ -121,6 +138,28 @@ export const dragSelectedElements = (
updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate),
});
} else if (
// NOTE: Add a little initial drag to the arrow dragging to avoid
// accidentally unbinding the arrow when the user just wants to select it.
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) > 1
) {
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,7 +17,6 @@ import {
BinaryHeap,
invariant,
isAnyTrue,
tupleToCoors,
getSizeFromPoints,
isDevEnv,
arrayToMap,
@ -30,7 +29,6 @@ import {
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
getHoveredElementForBinding,
} from "./binding";
import { distanceToElement } from "./distance";
import {
@ -51,8 +49,8 @@ import {
type ExcalidrawElbowArrowElement,
type NonDeletedSceneElementsMap,
} from "./types";
import { aabbForElement, pointInsideBounds } from "./bounds";
import { getHoveredElementForBinding } from "./collision";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
@ -63,6 +61,7 @@ import type {
FixedPointBinding,
FixedSegment,
NonDeletedExcalidrawElement,
Ordered,
} from "./types";
type GridAddress = [number, number] & { _brand: "gridaddress" };
@ -2249,17 +2248,10 @@ const getBindPointHeading = (
const getHoveredElement = (
origPoint: GlobalPoint,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
zoom?: AppState["zoom"],
) => {
return getHoveredElementForBinding(
tupleToCoors(origPoint),
elements,
elementsMap,
zoom,
true,
true,
);
return getHoveredElementForBinding(origPoint, elements, elementsMap, zoom);
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>

View File

@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@ -56,6 +56,35 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
timeParam =
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
} catch (error) {
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
timeParam = timeMatch?.[1];
}
if (!timeParam) {
return 0;
}
if (/^\d+$/.test(timeParam)) {
return parseInt(timeParam, 10);
}
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!timeMatch) {
return 0;
}
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@ -113,7 +142,8 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {

View File

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

View File

@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type {
@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
return copy;
};
// given a list of selected elements, return the element grouped by their immediate group selected state
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
export const getSelectedElementsByGroup = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
appState: Readonly<AppState>,
): ExcalidrawElement[][] => {
const selectedGroupIds = getSelectedGroupIds(appState);
const unboundElements = selectedElements.filter(
(element) => !isBoundToContainer(element),
);
const groups: Map<string, ExcalidrawElement[]> = new Map();
const elements: Map<string, ExcalidrawElement[]> = new Map();
// helper function to add an element to the elements map
const addToElementsMap = (element: ExcalidrawElement) => {
// elements
const currentElementMembers = elements.get(element.id) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentElementMembers.push(boundTextElement);
}
elements.set(element.id, [...currentElementMembers, element]);
};
// helper function to add an element to the groups map
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
// groups
const currentGroupMembers = groups.get(groupId) || [];
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
currentGroupMembers.push(boundTextElement);
}
groups.set(groupId, [...currentGroupMembers, element]);
};
// helper function to handle the case where a single group is selected
// and all elements selected are within the group, it will respect group hierarchy in accordance to
// their nested grouping order
const handleSingleSelectedGroupCase = (
element: ExcalidrawElement,
selectedGroupId: GroupId,
) => {
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
const nestedGroupCount = element.groupIds.slice(
0,
indexOfSelectedGroupId,
).length;
return nestedGroupCount > 0
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
: addToElementsMap(element);
};
const isAllInSameGroup = selectedElements.every((element) =>
isSelectedViaGroup(appState, element),
);
unboundElements.forEach((element) => {
const selectedGroupId = getSelectedGroupIdForElement(
element,
appState.selectedGroupIds,
);
if (!selectedGroupId) {
addToElementsMap(element);
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
handleSingleSelectedGroupCase(element, selectedGroupId);
} else {
addToGroupsMap(element, selectedGroupId);
}
});
return Array.from(groups.values()).concat(Array.from(elements.values()));
};

View File

@ -9,6 +9,7 @@ import {
vectorFromPoint,
curveLength,
curvePointAtLength,
lineSegment,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@ -20,12 +21,15 @@ import {
getGridPoint,
invariant,
tupleToCoors,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import {
bindingBorderTest,
CaptureUpdateAction,
deconstructLinearOrFreeDrawElement,
getHoveredElementForBinding,
isPathALoop,
moveArrowAboveBindable,
type Store,
} from "@excalidraw/element";
@ -40,13 +44,11 @@ import type {
Zoom,
} from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import {
bindOrUnbindLinearElement,
getHoveredElementForBinding,
getGlobalFixedPointForBindableElement,
getOutlineAvoidingPoint,
isBindingEnabled,
maybeSuggestBindingsForLinearElementAtCoords,
maybeSuggestBindingsForBindingElementAtCoords,
} from "./binding";
import {
getElementAbsoluteCoords,
@ -56,11 +58,16 @@ import {
import { headingIsHorizontal, vectorToHeading } from "./heading";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import {
getBoundTextElement,
getContainerElement,
handleBindTextResize,
} from "./textElement";
import {
isBindingElement,
isElbowArrow,
isFixedPointBinding,
isSimpleArrow,
isTextElement,
} from "./typeChecks";
import { ShapeCache, toggleLinePolygonState } from "./shape";
@ -76,7 +83,6 @@ import type {
NonDeleted,
ExcalidrawLinearElement,
ExcalidrawElement,
PointBinding,
ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
ElementsMap,
@ -85,6 +91,8 @@ import type {
FixedSegment,
ExcalidrawElbowArrowElement,
PointsPositionUpdates,
NonDeletedExcalidrawElement,
Ordered,
} from "./types";
/**
@ -134,17 +142,13 @@ export class LinearElementEditor {
index: number | null;
added: boolean;
};
arrowOriginalStartPoint?: GlobalPoint;
}>;
/** whether you're dragging a point */
public readonly isDragging: boolean;
public readonly lastUncommittedPoint: LocalPoint | null;
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
public readonly startBindingElement:
| ExcalidrawBindableElement
| null
| "keep";
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
@ -169,8 +173,6 @@ export class LinearElementEditor {
this.lastUncommittedPoint = null;
this.isDragging = false;
this.pointerOffset = { x: 0, y: 0 };
this.startBindingElement = "keep";
this.endBindingElement = "keep";
this.pointerDownState = {
prevSelectedPointsIndices: null,
lastClickedPoint: -1,
@ -286,19 +288,22 @@ export class LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) {
return null;
}
const elbowed = isElbowArrow(element);
if (
isElbowArrow(element) &&
elbowed &&
!linearElementEditor.pointerDownState.lastClickedIsEndPoint &&
linearElementEditor.pointerDownState.lastClickedPoint !== 0
) {
return null;
}
const selectedPointsIndices = isElbowArrow(element)
const selectedPointsIndices = elbowed
? [
!!linearElementEditor.selectedPointsIndices?.includes(0)
? 0
@ -308,7 +313,7 @@ export class LinearElementEditor {
: undefined,
].filter((idx): idx is number => idx !== undefined)
: linearElementEditor.selectedPointsIndices;
const lastClickedPoint = isElbowArrow(element)
const lastClickedPoint = elbowed
? linearElementEditor.pointerDownState.lastClickedPoint > 0
? element.points.length - 1
: 0
@ -318,6 +323,8 @@ export class LinearElementEditor {
const draggingPoint = element.points[lastClickedPoint];
if (selectedPointsIndices && draggingPoint) {
const elements = app.scene.getNonDeletedElements();
if (
shouldRotateWithDiscreteAngle(event) &&
selectedPointsIndices.length === 1 &&
@ -332,7 +339,6 @@ export class LinearElementEditor {
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
elementsMap,
@ -341,22 +347,32 @@ export class LinearElementEditor {
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
);
const [x, y] = LinearElementEditor.getPointGlobalCoordinates(
element,
pointFrom<LocalPoint>(
width + referencePoint[0],
height + referencePoint[1],
),
elementsMap,
);
LinearElementEditor.movePoints(
element,
app.scene,
new Map([
[
selectedIndex,
{
point: pointFrom(
width + referencePoint[0],
height + referencePoint[1],
),
isDragging: selectedIndex === lastClickedPoint,
},
],
]),
pointDraggingUpdates(
selectedPointsIndices,
0,
0,
elementsMap,
lastClickedPoint,
element,
x,
y,
linearElementEditor,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
elements,
app,
true,
),
);
} else {
const newDraggingPointPosition = LinearElementEditor.createPointAt(
@ -366,38 +382,25 @@ export class LinearElementEditor {
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
);
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
LinearElementEditor.movePoints(
element,
app.scene,
new Map(
selectedPointsIndices.map((pointIndex) => {
const newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize(),
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
pointDraggingUpdates(
selectedPointsIndices,
deltaX,
deltaY,
elementsMap,
lastClickedPoint,
element,
scenePointerX,
scenePointerY,
linearElementEditor,
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
elements,
app,
),
);
}
@ -410,16 +413,14 @@ export class LinearElementEditor {
// suggest bindings for first and last point if selected
let suggestedBindings: ExcalidrawBindableElement[] = [];
if (isBindingElement(element, false)) {
const firstSelectedIndex = selectedPointsIndices[0] === 0;
const lastSelectedIndex =
const firstIndexIsSelected = selectedPointsIndices[0] === 0;
const lastIndexIsSelected =
selectedPointsIndices[selectedPointsIndices.length - 1] ===
element.points.length - 1;
const coords: { x: number; y: number }[] = [];
if (!firstSelectedIndex !== !lastSelectedIndex) {
coords.push({ x: scenePointerX, y: scenePointerY });
} else {
if (firstSelectedIndex) {
if (firstIndexIsSelected !== lastIndexIsSelected) {
if (firstIndexIsSelected) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
@ -431,7 +432,7 @@ export class LinearElementEditor {
);
}
if (lastSelectedIndex) {
if (lastIndexIsSelected) {
coords.push(
tupleToCoors(
LinearElementEditor.getPointGlobalCoordinates(
@ -447,9 +448,13 @@ export class LinearElementEditor {
}
if (coords.length) {
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
suggestedBindings = maybeSuggestBindingsForBindingElementAtCoords(
element,
coords,
firstIndexIsSelected && lastIndexIsSelected
? "both"
: firstIndexIsSelected
? "start"
: "end",
app.scene,
app.state.zoom,
);
@ -497,8 +502,6 @@ export class LinearElementEditor {
scene: Scene,
): LinearElementEditor {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
@ -507,15 +510,6 @@ export class LinearElementEditor {
return editingLinearElement;
}
const bindings: Mutable<
Partial<
Pick<
InstanceType<typeof LinearElementEditor>,
"startBindingElement" | "endBindingElement"
>
>
> = {};
if (isDragging && selectedPointsIndices) {
for (const selectedPoint of selectedPointsIndices) {
if (
@ -551,36 +545,12 @@ export class LinearElementEditor {
]),
);
}
const bindingElement = isBindingEnabled(appState)
? getHoveredElementForBinding(
(selectedPointsIndices?.length ?? 0) > 1
? tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
selectedPoint!,
elementsMap,
),
)
: pointerCoords,
elements,
elementsMap,
appState.zoom,
isElbowArrow(element),
isElbowArrow(element),
)
: null;
bindings[
selectedPoint === 0 ? "startBindingElement" : "endBindingElement"
] = bindingElement;
}
}
}
return {
...editingLinearElement,
...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding
@ -605,6 +575,10 @@ export class LinearElementEditor {
isDragging: false,
pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
pointerDownState: {
...editingLinearElement.pointerDownState,
arrowOriginalStartPoint: undefined,
},
};
}
@ -849,7 +823,6 @@ export class LinearElementEditor {
} {
const appState = app.state;
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
@ -867,6 +840,7 @@ export class LinearElementEditor {
if (!element) {
return ret;
}
const segmentMidpoint = LinearElementEditor.getSegmentMidpointHitCoords(
linearElementEditor,
scenePointer,
@ -874,6 +848,7 @@ export class LinearElementEditor {
elementsMap,
);
let segmentMidpointIndex = null;
if (segmentMidpoint) {
segmentMidpointIndex = LinearElementEditor.getSegmentMidPointIndex(
linearElementEditor,
@ -913,16 +888,10 @@ export class LinearElementEditor {
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(
scenePointer,
elements,
elementsMap,
app.state.zoom,
linearElementEditor.elbowed,
),
};
ret.didAddPoint = true;
return ret;
}
@ -937,21 +906,6 @@ export class LinearElementEditor {
// it would get deselected if the point is outside the hitbox area
if (clickedPointIndex >= 0 || segmentMidpoint) {
ret.hitElement = element;
} else {
// You might be wandering why we are storing the binding elements on
// LinearElementEditor and passing them in, instead of calculating them
// from the end points of the `linearElement` - this is to allow disabling
// binding (which needs to happen at the point the user finishes moving
// the point).
const { startBindingElement, endBindingElement } = linearElementEditor;
if (isBindingEnabled(appState) && isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
scene,
);
}
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@ -1040,17 +994,18 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
}
return {
...appState.editingLinearElement,
lastUncommittedPoint: null,
};
return appState.editingLinearElement.lastUncommittedPoint
? {
...appState.editingLinearElement,
lastUncommittedPoint: null,
}
: appState.editingLinearElement;
}
let newPoint: LocalPoint;
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
const lastCommittedPoint = points[points.length - 2];
const [width, height] = LinearElementEditor._getShiftLockedDelta(
element,
elementsMap,
@ -1135,7 +1090,6 @@ export class LinearElementEditor {
static getPointAtIndexGlobalCoordinates(
element: NonDeleted<ExcalidrawLinearElement>,
indexMaybeFromEnd: number, // -1 for last element
elementsMap: ElementsMap,
): GlobalPoint {
@ -1402,8 +1356,9 @@ export class LinearElementEditor {
scene: Scene,
pointUpdates: PointsPositionUpdates,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
moveMidPointsWithElement?: boolean | null;
},
) {
const { points } = element;
@ -1449,6 +1404,15 @@ export class LinearElementEditor {
: points.map((p, idx) => {
const current = pointUpdates.get(idx)?.point ?? p;
if (
otherUpdates?.moveMidPointsWithElement &&
idx !== 0 &&
idx !== points.length - 1 &&
!pointUpdates.has(idx)
) {
return pointFrom<LocalPoint>(current[0], current[1]);
}
return pointFrom<LocalPoint>(
current[0] - offsetX,
current[1] - offsetY,
@ -1571,8 +1535,8 @@ export class LinearElementEditor {
offsetX: number,
offsetY: number,
otherUpdates?: {
startBinding?: PointBinding | null;
endBinding?: PointBinding | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
options?: {
isDragging?: boolean;
@ -1587,18 +1551,10 @@ export class LinearElementEditor {
points?: LocalPoint[];
} = {};
if (otherUpdates?.startBinding !== undefined) {
updates.startBinding =
otherUpdates.startBinding !== null &&
isFixedPointBinding(otherUpdates.startBinding)
? otherUpdates.startBinding
: null;
updates.startBinding = otherUpdates.startBinding;
}
if (otherUpdates?.endBinding !== undefined) {
updates.endBinding =
otherUpdates.endBinding !== null &&
isFixedPointBinding(otherUpdates.endBinding)
? otherUpdates.endBinding
: null;
updates.endBinding = otherUpdates.endBinding;
}
updates.points = Array.from(nextPoints);
@ -1977,3 +1933,212 @@ const normalizeSelectedPoints = (
nextPoints = nextPoints.sort((a, b) => a - b);
return nextPoints.length ? nextPoints : null;
};
const pointDraggingUpdates = (
selectedPointsIndices: readonly number[],
deltaX: number,
deltaY: number,
elementsMap: NonDeletedSceneElementsMap,
lastClickedPoint: number,
element: NonDeleted<ExcalidrawLinearElement>,
scenePointerX: number,
scenePointerY: number,
linearElementEditor: LinearElementEditor,
gridSize: NullableGridSize,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
app: AppClassProperties,
angleLocked?: boolean,
): PointsPositionUpdates => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap, true);
const hasMidPoints =
selectedPointsIndices.filter(
(_, idx) => idx > 0 && idx < element.points.length - 1,
).length > 0;
const updates = new Map(
selectedPointsIndices.map((pointIndex) => {
let newPointPosition: LocalPoint =
pointIndex === lastClickedPoint
? LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - linearElementEditor.pointerOffset.x,
scenePointerY - linearElementEditor.pointerOffset.y,
gridSize,
)
: pointFrom(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
);
if (
isSimpleArrow(element) &&
!hasMidPoints &&
(pointIndex === 0 || pointIndex === element.points.length - 1)
) {
let newGlobalPointPosition = pointRotateRads(
pointFrom<GlobalPoint>(
element.x + newPointPosition[0],
element.y + newPointPosition[1],
),
pointFrom<GlobalPoint>(cx, cy),
element.angle,
);
const hoveredElement = getHoveredElementForBinding(
newGlobalPointPosition,
elements,
elementsMap,
app.state.zoom,
);
const otherGlobalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
pointIndex === 0 ? -1 : 0,
elementsMap,
);
const otherPointInsideElement =
!!hoveredElement &&
!!bindingBorderTest(
hoveredElement,
otherGlobalPoint,
elementsMap,
app.state.zoom,
);
if (
isBindingEnabled(app.state) &&
isBindingElement(element, false) &&
hoveredElement &&
app.state.bindMode === "orbit" &&
!otherPointInsideElement
) {
let customIntersector;
if (angleLocked) {
const adjacentPointIndex =
pointIndex === 0 ? 1 : element.points.length - 2;
const globalAdjacentPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
adjacentPointIndex,
elementsMap,
);
customIntersector = lineSegment<GlobalPoint>(
globalAdjacentPoint,
newGlobalPointPosition,
);
}
newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
newGlobalPointPosition,
pointIndex,
elementsMap,
customIntersector,
);
}
newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y,
null,
);
// Update z-index of the arrow
if (
isBindingEnabled(app.state) &&
isBindingElement(element) &&
hoveredElement
) {
const boundTextElement = getBoundTextElement(
hoveredElement,
elementsMap,
);
const containerElement = isTextElement(hoveredElement)
? getContainerElement(hoveredElement, elementsMap)
: null;
const newElements = moveArrowAboveBindable(
element,
[
hoveredElement.id,
boundTextElement?.id,
containerElement?.id,
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id),
app.scene,
);
app.syncActionResult({
elements: newElements,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
}
}
return [
pointIndex,
{
point: newPointPosition,
isDragging: pointIndex === lastClickedPoint,
},
];
}),
);
if (isSimpleArrow(element)) {
const adjacentPointIndices =
element.points.length === 2
? [0, 1]
: element.points.length === 3
? [1]
: [1, element.points.length - 2];
adjacentPointIndices
.filter((adjacentPointIndex) =>
selectedPointsIndices.includes(adjacentPointIndex),
)
.flatMap((adjacentPointIndex) =>
element.points.length === 3
? [0, 2]
: adjacentPointIndex === 1
? 0
: element.points.length - 1,
)
.forEach((pointIndex) => {
const binding =
element[pointIndex === 0 ? "startBinding" : "endBinding"];
const bindingIsOrbiting = binding?.mode === "orbit";
if (bindingIsOrbiting) {
const hoveredElement = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
const focusGlobalPoint = getGlobalFixedPointForBindableElement(
binding.fixedPoint,
hoveredElement,
elementsMap,
);
const newGlobalPointPosition = getOutlineAvoidingPoint(
element,
hoveredElement,
focusGlobalPoint,
pointIndex,
elementsMap,
);
const newPointPosition = LinearElementEditor.createPointAt(
element,
elementsMap,
newGlobalPointPosition[0] - linearElementEditor.pointerOffset.x,
newGlobalPointPosition[1] - linearElementEditor.pointerOffset.y,
null,
);
updates.set(pointIndex, {
point: newPointPosition,
isDragging: false,
});
}
});
}
return updates;
};

View File

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

View File

@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
return element.strokeWidth * 12;
case "text":
return element.fontSize / 2;
case "arrow":
if (element.endArrowhead || element.endArrowhead) {
return 40;
}
return 20;
default:
return 20;
}

View File

@ -843,10 +843,7 @@ export const resizeSingleElement = (
shouldMaintainAspectRatio,
);
updateBoundElements(latestElement, scene, {
// TODO: confirm with MARK if this actually makes sense
newSize: { width: nextWidth, height: nextHeight },
});
updateBoundElements(latestElement, scene);
}
};
@ -1385,13 +1382,12 @@ export const resizeMultipleElements = (
element,
update: { boundTextFontSize, ...update },
} of elementsAndUpdates) {
const { width, height, angle } = update;
const { angle } = update;
scene.mutateElement(element, update);
updateBoundElements(element, scene, {
simultaneouslyUpdated: elementsToUpdate,
newSize: { width, height },
});
const boundTextElement = getBoundTextElement(element, elementsMap);

View File

@ -27,6 +27,8 @@ import {
isImageElement,
} from "./index";
import type { ApplyToOptions } from "./delta";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
@ -570,9 +572,15 @@ export class StoreDelta {
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
delta.elements.applyTo(elements);
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
StoreSnapshot.empty().elements,
options,
);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);

View File

@ -28,8 +28,6 @@ import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding,
FixedPointBinding,
ExcalidrawFlowchartNodeElement,
ExcalidrawLinearElementSubType,
} from "./types";
@ -163,7 +161,7 @@ export const isLinearElementType = (
export const isBindingElement = (
element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawLinearElement => {
): element is ExcalidrawArrowElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
@ -358,15 +356,6 @@ export const getDefaultRoundnessTypeForElement = (
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
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&

View File

@ -279,23 +279,22 @@ export type ExcalidrawTextElementWithContainer = {
export type FixedPoint = [number, number];
export type PointBinding = {
elementId: ExcalidrawBindableElement["id"];
focus: number;
gap: number;
};
export type BindMode = "inside" | "outside" | "orbit";
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;
}
>;
export type FixedPointBinding = {
elementId: ExcalidrawBindableElement["id"];
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
// Determines whether the arrow remains outside the shape or is allowed to
// go all the way inside the shape up to the exact fixed point.
mode: BindMode;
};
type Index = number;
@ -323,8 +322,8 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
type: "line" | "arrow";
points: readonly LocalPoint[];
lastCommittedPoint: LocalPoint | null;
startBinding: PointBinding | null;
endBinding: PointBinding | null;
startBinding: FixedPointBinding | null;
endBinding: FixedPointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>;
@ -351,9 +350,9 @@ export type ExcalidrawElbowArrowElement = Merge<
ExcalidrawArrowElement,
{
elbowed: true;
fixedSegments: readonly FixedSegment[] | null;
startBinding: 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
* order to temporarily hide the first segment of the arrow without losing

View File

@ -12,7 +12,12 @@ import { getSelectedElements } from "./selection";
import type { Scene } from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
OrderedExcalidrawElement,
} from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId;
@ -139,6 +144,27 @@ const getContiguousFrameRangeElements = (
return allElements.slice(rangeStart, rangeEnd + 1);
};
export const moveArrowAboveBindable = (
arrow: ExcalidrawArrowElement,
bindableIds: string[],
scene: Scene,
): readonly OrderedExcalidrawElement[] => {
const elements = scene.getElementsIncludingDeleted();
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);
syncMovedIndices(elements, arrayToMap([arrow]));
return updatedElements;
}
return elements;
};
/**
* Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it).

View File

@ -49,9 +49,3 @@ exports[`Test Linear Elements > Test bound text element > should wrap the bound
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = `
"Online whiteboard
collaboration made
easy"
`;

View File

@ -589,4 +589,424 @@ describe("aligning", () => {
expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150);
});
const createGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(10, 0);
mouse.doubleClick();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
mouse.moveTo(100, 100);
mouse.click();
});
};
it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createNestedGroupAndSelectInEditGroupMode = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
mouse.moveTo(200, 200);
// create third element
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// third element is already selected, select the initial group and group together
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
// double click to enter edit mode
mouse.doubleClick();
// select nested group and other element within the group
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(200, 200);
mouse.click();
});
};
it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
const createAndSelectSingleGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
});
it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
});
it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
});
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
});
const createAndSelectSingleGroupWithNestedGroup = () => {
UI.clickTool("rectangle");
mouse.down();
mouse.up(100, 100);
UI.clickTool("rectangle");
mouse.down(0, 0);
mouse.up(100, 100);
// Select the first element.
// The second rectangle is already reselected because it was the last element created
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click();
});
API.executeAction(actionGroup);
mouse.reset();
UI.clickTool("rectangle");
mouse.down(200, 200);
mouse.up(100, 100);
// Add group to current selection
mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click();
});
// Create the nested group
API.executeAction(actionGroup);
};
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
});
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
});
});

View File

@ -8,7 +8,13 @@ import { Excalidraw, isLinearElement } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import {
act,
fireEvent,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
import { getTransformHandles } from "../src/transformHandles";
import {
@ -16,6 +22,8 @@ import {
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type { ExcalidrawLinearElement, FixedPointBinding } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@ -71,8 +79,9 @@ describe("element binding", () => {
expect(arrow.startBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
});
// Move the end point to the overlapping binding position
@ -83,13 +92,15 @@ describe("element binding", () => {
// Both the start and the end points should be bound
expect(arrow.startBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
});
expect(arrow.endBinding).toEqual({
elementId: rect.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
focus: 0,
gap: 0,
fixedPoint: expect.arrayContaining([1.1, 0]),
});
});
@ -195,9 +206,9 @@ describe("element binding", () => {
// Sever connection
expect(API.getSelectedElement().type).toBe("arrow");
Keyboard.keyPress(KEYS.ARROW_LEFT);
expect(arrow.endBinding).toBe(null);
expect(arrow.endBinding).not.toBe(null);
Keyboard.keyPress(KEYS.ARROW_RIGHT);
expect(arrow.endBinding).toBe(null);
expect(arrow.endBinding).not.toBe(null);
});
it("should unbind on bound element deletion", () => {
@ -312,15 +323,13 @@ describe("element binding", () => {
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
fixedPoint: [1, 0.5],
mode: "orbit",
},
});
@ -330,15 +339,13 @@ describe("element binding", () => {
points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
startBinding: {
elementId: "text1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [1, 0.5],
mode: "orbit",
},
});
@ -476,3 +483,346 @@ describe("element binding", () => {
});
});
});
describe("Fixed-point arrow binding", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should create fixed-point binding when both arrow endpoint is inside rectangle", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with endpoint inside the filled rectangle, since only
// filled bindables bind inside the shape
UI.clickTool("arrow");
mouse.downAt(110, 110);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(110);
expect(arrow.y).toBe(110);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const startBinding = arrow.startBinding as FixedPointBinding;
expect(startBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(startBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(startBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
mouse.reset();
// Move the bindable
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(260);
expect(arrow.y).toBe(110);
});
it("should create fixed-point binding when one of the arrow endpoint is inside rectangle", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with endpoint inside the filled rectangle, since only
// filled bindables bind inside the shape
UI.clickTool("arrow");
mouse.downAt(10, 10);
mouse.moveTo(160, 160);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBe(150);
expect(arrow.height).toBe(150);
// Should bind to the rectangle since endpoint is inside
expect(arrow.startBinding).toBe(null);
expect(arrow.endBinding?.elementId).toBe(rect.id);
const endBinding = arrow.endBinding as FixedPointBinding;
expect(endBinding.fixedPoint[0]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[0]).toBeLessThanOrEqual(1);
expect(endBinding.fixedPoint[1]).toBeGreaterThanOrEqual(0);
expect(endBinding.fixedPoint[1]).toBeLessThanOrEqual(1);
mouse.reset();
// Move the bindable
mouse.downAt(130, 110);
mouse.moveTo(280, 110);
mouse.up();
// Check if the arrow moved
expect(arrow.x).toBe(10);
expect(arrow.y).toBe(10);
expect(arrow.width).toBe(300);
expect(arrow.height).toBe(150);
});
it("should maintain relative position when arrow start point is dragged outside and rectangle is moved", () => {
// Create a filled solid rectangle
UI.clickTool("rectangle");
mouse.downAt(100, 100);
mouse.moveTo(200, 200);
mouse.up();
const rect = API.getSelectedElement();
API.updateElement(rect, { fillStyle: "solid", backgroundColor: "#a5d8ff" });
// Draw arrow with both endpoints inside the filled rectangle, creating same-element binding
UI.clickTool("arrow");
mouse.downAt(120, 120);
mouse.moveTo(180, 180);
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Both ends should be bound to the same rectangle
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding?.elementId).toBe(rect.id);
mouse.reset();
// Select the arrow and drag the start point outside the rectangle
mouse.downAt(120, 120);
mouse.moveTo(50, 50); // Move start point outside rectangle
mouse.up();
mouse.reset();
// Move the rectangle by dragging it
mouse.downAt(150, 110);
mouse.moveTo(300, 300);
mouse.up();
expect(arrow.x).toBe(50);
expect(arrow.y).toBe(50);
expect(arrow.width).toBeCloseTo(280, 0);
expect(arrow.height).toBeCloseTo(320, 0);
});
it("should move inner points when arrow is bound to same element on both ends", () => {
// Create one rectangle as binding target
const rect = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 200,
height: 100,
fillStyle: "solid",
backgroundColor: "#a5d8ff",
});
// Create a non-elbowed arrow with inner points bound to the same element on both ends
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 75,
width: 100,
height: 50,
points: [
pointFrom(0, 0), // start point
pointFrom(25, -25), // first inner point
pointFrom(75, 25), // second inner point
pointFrom(100, 0), // end point
],
startBinding: {
elementId: rect.id,
fixedPoint: [0.25, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rect.id,
fixedPoint: [0.75, 0.5],
mode: "orbit",
},
});
API.setElements([rect, arrow]);
// Store original inner point positions (local coordinates)
const originalInnerPoint1 = [...arrow.points[1]];
const originalInnerPoint2 = [...arrow.points[2]];
// Move the rectangle
mouse.reset();
mouse.downAt(150, 100); // Click on the rectangle
mouse.moveTo(300, 200); // Move it down and to the right
mouse.up();
// Verify that inner points moved with the arrow (same local coordinates)
// When both ends are bound to the same element, inner points should maintain
// their local coordinates relative to the arrow's origin
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
});
it("should NOT move inner points when arrow is bound to different elements", () => {
// Create two rectangles as binding targets
const rectLeft = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
});
const rectRight = API.createElement({
type: "rectangle",
x: 300,
y: 0,
width: 100,
height: 100,
});
// Create a non-elbowed arrow with inner points bound to different elements
const arrow = API.createElement({
type: "arrow",
x: 100,
y: 50,
width: 200,
height: 0,
points: [
pointFrom(0, 0), // start point
pointFrom(50, -20), // first inner point
pointFrom(150, 20), // second inner point
pointFrom(200, 0), // end point
],
startBinding: {
elementId: rectLeft.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rectRight.id,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
API.setElements([rectLeft, rectRight, arrow]);
// Store original inner point positions
const originalInnerPoint1 = [...arrow.points[1]];
const originalInnerPoint2 = [...arrow.points[2]];
// Move the right rectangle down by 50 pixels
mouse.reset();
mouse.downAt(350, 50); // Click on the right rectangle
mouse.moveTo(350, 100); // Move it down
mouse.up();
// Verify that inner points did NOT move when bound to different elements
// The arrow should NOT translate inner points proportionally when only one end moves
expect(arrow.points[1][0]).toBe(originalInnerPoint1[0]);
expect(arrow.points[1][1]).toBe(originalInnerPoint1[1]);
expect(arrow.points[2][0]).toBe(originalInnerPoint2[0]);
expect(arrow.points[2][1]).toBe(originalInnerPoint2[1]);
});
});
describe("line segment extension binding", () => {
beforeEach(async () => {
mouse.reset();
await act(() => {
return setLanguage(defaultLang);
});
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("should use point binding when extended segment intersects element", () => {
// Create a rectangle that will be intersected by the extended arrow segment
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
API.setElements([rect]);
// Draw an arrow that points at the rectangle (extended segment will intersect)
UI.clickTool("arrow");
mouse.downAt(0, 0); // Start point
mouse.moveTo(120, 95); // End point - arrow direction points toward rectangle
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Should create a normal point binding since the extended line segment
// from the last arrow segment intersects the rectangle
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.endBinding).toHaveProperty("focus");
expect(arrow.endBinding).toHaveProperty("gap");
});
it("should use fixed point binding when extended segment misses element", () => {
// Create a rectangle positioned so the extended arrow segment will miss it
const rect = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 100,
height: 100,
});
API.setElements([rect]);
// Draw an arrow that doesn't point at the rectangle (extended segment will miss)
UI.clickTool("arrow");
mouse.reset();
mouse.downAt(125, 93); // Start point
mouse.moveTo(175, 93); // End point - arrow direction is horizontal, misses rectangle
mouse.up();
const arrow = API.getSelectedElement() as ExcalidrawLinearElement;
// Should create a fixed point binding since the extended line segment
// from the last arrow segment misses the rectangle
expect(arrow.startBinding?.elementId).toBe(rect.id);
expect(arrow.startBinding).toHaveProperty("fixedPoint");
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
).toBeGreaterThanOrEqual(0);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[0],
).toBeLessThanOrEqual(1);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
).toBeLessThanOrEqual(0.5);
expect(
(arrow.startBinding as FixedPointBinding).fixedPoint[1],
).toBeLessThanOrEqual(1);
expect(arrow.endBinding).toBe(null);
});
});

View File

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

View File

@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => {
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => {
id: "arrow2",
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
boundElements: [{ id: "text2", type: "text" }],
});
@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => {
id: "arrow1",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => {
id: "arrow2",
startBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => {
id: "arrow3",
startBinding: {
elementId: "rectangle-not-exists",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: "rectangle1",
focus: 0.2,
gap: 7,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});

View File

@ -1,13 +1,10 @@
import { ARROW_TYPE } from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
fireEvent,
@ -15,13 +12,11 @@ import {
queryByTestId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import "@excalidraw/utils/test-utils";
import { bindBindingElement } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import { Scene } from "../src/Scene";
import type {
@ -189,8 +184,8 @@ describe("elbow arrow routing", () => {
scene.insertElement(rectangle2);
scene.insertElement(arrow);
bindLinearElement(arrow, rectangle1, "start", scene);
bindLinearElement(arrow, rectangle2, "end", scene);
bindBindingElement(arrow, rectangle1, "orbit", "start", scene);
bindBindingElement(arrow, rectangle2, "orbit", "end", scene);
expect(arrow.startBinding).not.toBe(null);
expect(arrow.endBinding).not.toBe(null);

View File

@ -0,0 +1,153 @@
import { getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
expectedStart: 90,
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
expectedStart: 120,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
expectedStart: 150,
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should parse YouTube URLs with timestamp in time format", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
expectedStart: 90, // 1*60 + 30
},
{
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
expectedStart: 165, // 2*60 + 45
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
expectedStart: 3723, // 1*3600 + 2*60 + 3
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
expectedStart: 45,
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
expectedStart: 300, // 5*60
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
expectedStart: 7200, // 2*3600
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain(`start=${expectedStart}`);
}
});
});
it("should handle YouTube URLs without timestamps", () => {
const testCases = [
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
];
testCases.forEach((url) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).not.toContain("start=");
}
});
});
it("should handle YouTube shorts URLs with timestamps", () => {
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=30");
}
// Shorts should have portrait aspect ratio
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
});
it("should handle playlist URLs with timestamps", () => {
const url =
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=60");
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
}
});
it("should handle malformed or edge case timestamps", () => {
const testCases = [
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
expectedStart: 0, // Invalid timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
expectedStart: 0, // Empty timestamp should default to 0
},
{
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
expectedStart: 0, // Zero timestamp should be handled
},
];
testCases.forEach(({ url, expectedStart }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
if (expectedStart === 0) {
expect(result.link).not.toContain("start=");
} else {
expect(result.link).toContain(`start=${expectedStart}`);
}
}
});
});
it("should preserve other URL parameters", () => {
const url =
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toContain("start=90");
expect(result.link).toContain("enablejsapi=1");
}
});
});

View File

@ -367,7 +367,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -469,7 +469,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -537,7 +537,7 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -588,7 +588,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -629,7 +629,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -677,7 +677,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`,
`17`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -735,7 +735,7 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`,
`14`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
@ -833,7 +833,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`,
`11`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);

View File

@ -174,29 +174,29 @@ describe("generic element", () => {
expect(rectangle.angle).toBeCloseTo(0);
});
it("resizes with bound arrow", async () => {
const rectangle = UI.createElement("rectangle", {
width: 200,
height: 100,
});
const arrow = UI.createElement("arrow", {
x: -30,
y: 50,
width: 28,
height: 5,
});
// it("resizes with bound arrow", async () => {
// const rectangle = UI.createElement("rectangle", {
// width: 200,
// height: 100,
// });
// const arrow = UI.createElement("arrow", {
// x: -30,
// y: 50,
// width: 28,
// 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.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
});
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
// });
it("resizes with a label", async () => {
const rectangle = UI.createElement("rectangle", {
@ -595,31 +595,31 @@ describe("text element", () => {
expect(text.fontSize).toBeCloseTo(fontSize * scale);
});
it("resizes with bound arrow", async () => {
const text = UI.createElement("text");
await UI.editText(text, "hello\nworld");
const boundArrow = UI.createElement("arrow", {
x: -30,
y: 25,
width: 28,
height: 5,
});
// it("resizes with bound arrow", async () => {
// const text = UI.createElement("text");
// await UI.editText(text, "hello\nworld");
// const boundArrow = UI.createElement("arrow", {
// x: -30,
// y: 25,
// width: 28,
// 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 scale = 20 / text.height;
UI.resize(text, "nw", [50, 20]);
// const textWidth = text.width;
// const scale = 20 / text.height;
// UI.resize(text, "nw", [50, 20]);
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
30 + textWidth * scale,
);
});
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
// 30 + textWidth * scale,
// );
// });
it("updates font size via keyboard", async () => {
const text = UI.createElement("text");
@ -801,36 +801,36 @@ describe("image element", () => {
expect(image.scale).toEqual([1, 1]);
});
it("resizes with bound arrow", async () => {
const image = API.createElement({
type: "image",
width: 100,
height: 100,
});
API.setElements([image]);
const arrow = UI.createElement("arrow", {
x: -30,
y: 50,
width: 28,
height: 5,
});
// it("resizes with bound arrow", async () => {
// const image = API.createElement({
// type: "image",
// width: 100,
// height: 100,
// });
// API.setElements([image]);
// const arrow = UI.createElement("arrow", {
// x: -30,
// y: 50,
// width: 28,
// 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 scale = 20 / image.height;
UI.resize(image, "nw", [50, 20]);
// const imageWidth = image.width;
// const scale = 20 / image.height;
// UI.resize(image, "nw", [50, 20]);
expect(arrow.endBinding?.elementId).toEqual(image.id);
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
30 + imageWidth * scale,
0,
);
});
// expect(arrow.endBinding?.elementId).toEqual(image.id);
// expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
// 30 + imageWidth * scale,
// 0,
// );
// });
});
describe("multiple selection", () => {
@ -997,68 +997,80 @@ describe("multiple selection", () => {
expect(diagLine.angle).toEqual(0);
});
it("resizes with bound arrows", async () => {
const rectangle = UI.createElement("rectangle", {
position: 0,
size: 100,
});
const leftBoundArrow = UI.createElement("arrow", {
x: -110,
y: 50,
width: 100,
height: 0,
});
// it("resizes with bound arrows", async () => {
// const rectangle = UI.createElement("rectangle", {
// position: 0,
// size: 100,
// });
// const leftBoundArrow = UI.createElement("arrow", {
// x: -110,
// y: 50,
// width: 100,
// height: 0,
// });
const rightBoundArrow = UI.createElement("arrow", {
x: 210,
y: 50,
width: -100,
height: 0,
});
// const rightBoundArrow = UI.createElement("arrow", {
// x: 210,
// y: 50,
// width: -100,
// height: 0,
// });
const selectionWidth = 210;
const selectionHeight = 100;
const move = [40, 40] as [number, number];
const scale = Math.max(
1 - move[0] / selectionWidth,
1 - move[1] / selectionHeight,
);
const leftArrowBinding = { ...leftBoundArrow.endBinding };
const rightArrowBinding = { ...rightBoundArrow.endBinding };
delete rightArrowBinding.gap;
// const selectionWidth = 210;
// const selectionHeight = 100;
// const move = [40, 40] as [number, number];
// const scale = Math.max(
// 1 - move[0] / selectionWidth,
// 1 - move[1] / selectionHeight,
// );
// const leftArrowBinding: {
// elementId: string;
// gap?: number;
// 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, {
shift: true,
});
// UI.resize([rectangle, rightBoundArrow], "nw", move, {
// shift: true,
// });
expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull();
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId,
);
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
// expect(leftBoundArrow.x).toBeCloseTo(-110);
// expect(leftBoundArrow.y).toBeCloseTo(50);
// expect(leftBoundArrow.width).toBeCloseTo(140, 0);
// expect(leftBoundArrow.height).toBeCloseTo(7, 0);
// expect(leftBoundArrow.angle).toEqual(0);
// expect(leftBoundArrow.startBinding).toBeNull();
// expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
// expect(leftBoundArrow.endBinding?.elementId).toBe(
// leftArrowBinding.elementId,
// );
// expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210);
expect(rightBoundArrow.y).toBeCloseTo(
(selectionHeight - 50) * (1 - scale) + 50,
);
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
expect(rightBoundArrow.height).toBeCloseTo(0);
expect(rightBoundArrow.angle).toEqual(0);
expect(rightBoundArrow.startBinding).toBeNull();
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId,
);
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
rightArrowBinding.focus!,
);
});
// expect(rightBoundArrow.x).toBeCloseTo(210);
// expect(rightBoundArrow.y).toBeCloseTo(
// (selectionHeight - 50) * (1 - scale) + 50,
// );
// expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
// expect(rightBoundArrow.height).toBeCloseTo(0);
// expect(rightBoundArrow.angle).toEqual(0);
// expect(rightBoundArrow.startBinding).toBeNull();
// expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
// expect(rightBoundArrow.endBinding?.elementId).toBe(
// rightArrowBinding.elementId,
// );
// expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
// rightArrowBinding.focus!,
// );
// });
it("resizes with labeled arrows", async () => {
const topArrow = UI.createElement("arrow", {

View File

@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Alignment } from "@excalidraw/element";
@ -38,7 +40,11 @@ export const alignActionsPredicate = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -52,7 +58,12 @@ const alignSelectedElements = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(selectedElements, alignment, app.scene);
const updatedElements = alignElements(
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -206,12 +206,8 @@ export const actionDeleteSelected = register({
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
if (appState.editingLinearElement) {
const {
elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const { elementId, selectedPointsIndices } =
appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@ -225,8 +221,11 @@ export const actionDeleteSelected = register({
return false;
}
// case: deleting last remaining point
if (element.points.length < 2) {
// case: deleting all points
if (
element.points.length < 2 ||
selectedPointsIndices.length === element.points.length
) {
const nextElements = elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
@ -245,19 +244,6 @@ 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(
element.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
return {
@ -266,7 +252,6 @@ export const actionDeleteSelected = register({
...appState,
editingLinearElement: {
...appState.editingLinearElement,
...binding,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1]

View File

@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { getSelectedElementsByGroup } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { Distribution } from "@excalidraw/element";
@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 1 &&
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 2 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -49,6 +55,7 @@ const distributeSelectedElements = (
selectedElements,
app.scene.getNonDeletedElementsMap(),
distribution,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -1,11 +1,12 @@
import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
getHoveredElementForBinding,
isArrowElement,
isValidPolygon,
LinearElementEditor,
} from "@excalidraw/element";
import {
isBindingElement,
@ -17,7 +18,7 @@ import {
import {
KEYS,
arrayToMap,
tupleToCoors,
invariant,
updateActiveTool,
} from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element";
@ -26,8 +27,9 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
@ -47,6 +49,7 @@ export const actionFinalize = register({
label: "",
trackEvent: false,
perform: (elements, appState, data, app) => {
let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app;
const { event, sceneCoords } =
(data as {
@ -56,6 +59,28 @@ export const actionFinalize = register({
const elementsMap = scene.getNonDeletedElementsMap();
if (event && appState.selectedLinearElement) {
const element = LinearElementEditor.getElement<ExcalidrawArrowElement>(
appState.selectedLinearElement.elementId,
elementsMap,
);
invariant(
element,
"Arrow element should exist if selectedLinearElement is set",
);
const draggedPoints =
appState.selectedLinearElement.selectedPointsIndices?.reduce(
(map, index) => {
map.set(index, {
point: element.points[index],
draggedPoints: true,
});
return map;
},
new Map(),
) ?? new Map();
const linearElementEditor = LinearElementEditor.handlePointerUp(
event,
appState.selectedLinearElement,
@ -63,19 +88,21 @@ export const actionFinalize = register({
app.scene,
);
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
app.scene,
);
}
const { start, end } = bindOrUnbindBindingElement(
element,
draggedPoints,
scene,
appState,
);
const bindableIds = [];
start.element && bindableIds.push(start.element.id);
end.element && bindableIds.push(end.element.id);
if (linearElementEditor !== appState.selectedLinearElement) {
let newElements = elements;
// `handlePointerUp()` updated the linear element instance,
// 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)) {
// 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.filter((el) => el.id !== element!.id);
@ -94,20 +121,34 @@ export const actionFinalize = register({
}
}
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
if (appState.editingLinearElement && !appState.newElement) {
const { elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
scene,
);
if (isArrowElement(element)) {
const updates =
appState.editingLinearElement?.pointerDownState.prevSelectedPointsIndices?.reduce(
(updates, index) => {
updates.set(index, {
point: element.points[index],
draggedPoints: true,
});
return updates;
},
new Map(),
) ?? new Map();
const allPointsSelected =
appState.editingLinearElement?.pointerDownState
.prevSelectedPointsIndices?.length === element.points.length;
// Dragging the entire arrow doesn't allow binding.
if (!allPointsSelected) {
bindOrUnbindBindingElement(element, updates, scene, appState);
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
@ -117,7 +158,7 @@ export const actionFinalize = register({
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
? newElements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
@ -129,8 +170,6 @@ export const actionFinalize = register({
}
}
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
focusContainer();
}
@ -159,10 +198,21 @@ export const actionFinalize = register({
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = element;
const { x: rx, y: ry, points, lastCommittedPoint } = element;
const lastGlobalPoint = pointFrom<GlobalPoint>(
rx + points[points.length - 1][0],
ry + points[points.length - 1][1],
);
const hoveredElementForBinding = getHoveredElementForBinding(
lastGlobalPoint,
app.scene.getNonDeletedElements(),
elementsMap,
app.state.zoom,
);
if (
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
!hoveredElementForBinding &&
(!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint)
) {
scene.mutateElement(element, {
points: element.points.slice(0, -1),
@ -207,23 +257,34 @@ export const actionFinalize = register({
});
}
if (
isBindingElement(element) &&
!isLoop &&
element.points.length > 1 &&
isBindingEnabled(appState)
) {
const coords =
sceneCoords ??
tupleToCoors(
LinearElementEditor.getPointAtIndexGlobalCoordinates(
if (isBindingElement(element) && !isLoop && element.points.length > 1) {
const coords = sceneCoords
? pointFrom<GlobalPoint>(sceneCoords.x, sceneCoords.y)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
arrayToMap(elements),
),
);
maybeBindLinearElement(element, appState, coords, scene);
arrayToMap(newElements),
);
const point = LinearElementEditor.pointFromAbsoluteCoords(
element,
coords,
elementsMap,
);
bindOrUnbindBindingElement(
element,
new Map([
[
element.points.length - 1,
{
point,
isDragging: false,
},
],
]),
scene,
appState,
{ newArrow: true },
);
}
}
}
@ -282,6 +343,17 @@ export const actionFinalize = register({
element && isLinearElement(element)
? new LinearElementEditor(element, arrayToMap(newElements))
: appState.selectedLinearElement,
editingLinearElement: appState.newElement
? null
: appState.editingLinearElement
? {
...appState.editingLinearElement,
pointerDownState: {
...appState.editingLinearElement.pointerDownState,
arrowOriginalStartPoint: undefined,
},
}
: null,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY,

View File

@ -38,15 +38,13 @@ describe("flipping re-centers selection", () => {
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
mode: "orbit",
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
mode: "orbit",
},
startArrowhead: null,
endArrowhead: "arrow",
@ -99,8 +97,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
@ -139,13 +137,13 @@ describe("flipping arrowheads", () => {
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});
@ -195,8 +193,8 @@ describe("flipping arrowheads", () => {
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
fixedPoint: [0.5, 0.5],
mode: "orbit",
},
});

View File

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

View File

@ -26,7 +26,7 @@ import {
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import {
bindLinearElement,
bindBindingElement,
calculateFixedPointForElbowArrowBinding,
updateBoundElements,
} from "@excalidraw/element";
@ -1717,7 +1717,13 @@ export const actionChangeArrowType = register({
newElement.startBinding.elementId,
) as ExcalidrawBindableElement;
if (startElement) {
bindLinearElement(newElement, startElement, "start", app.scene);
bindBindingElement(
newElement,
startElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"start",
app.scene,
);
}
}
if (newElement.endBinding) {
@ -1725,7 +1731,13 @@ export const actionChangeArrowType = register({
newElement.endBinding.elementId,
) as ExcalidrawBindableElement;
if (endElement) {
bindLinearElement(newElement, endElement, "end", app.scene);
bindBindingElement(
newElement,
endElement,
appState.bindMode === "inside" ? "inside" : "orbit",
"end",
app.scene,
);
}
}
}

View File

@ -124,6 +124,7 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
};
};
@ -249,6 +250,7 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
bindMode: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -505,15 +505,3 @@ export const ExitZenModeAction = ({
{t("buttons.exitZenMode")}
</button>
);
export const FinalizeAction = ({
renderAction,
className,
}: {
renderAction: ActionManager["renderAction"];
className?: string;
}) => (
<div className={`finalize-button ${className}`}>
{renderAction("finalize", { size: "small" })}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
}

View File

@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { useStable } from "../../hooks/useStable";
import { Ellipsify } from "../Ellipsify";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss";
@ -964,7 +966,7 @@ const CommandItem = ({
}
/>
)}
{command.label}
<Ellipsify>{command.label}</Ellipsify>
</div>
{showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} />

View File

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

View File

@ -0,0 +1,18 @@
export const Ellipsify = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
{...rest}
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
...rest.style,
}}
>
{children}
</span>
);
};

View File

@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
flex: "0 0 auto",
}}
>
{icon}

View File

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

View File

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

View File

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

View File

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

View File

@ -110,6 +110,7 @@ export const moveElement = (
newTopLeftY: number,
originalElement: ExcalidrawElement,
scene: Scene,
appState: AppState,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
@ -145,7 +146,7 @@ export const moveElement = (
},
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestElement, scene);
updateBindings(latestElement, scene, appState);
const boundTextElement = getBoundTextElement(
originalElement,
@ -203,7 +204,7 @@ export const moveElement = (
},
{ informMutation: shouldInformMutation, isDragging: false },
);
updateBindings(latestChildElement, scene, {
updateBindings(latestChildElement, scene, appState, {
simultaneouslyUpdated: originalChildren,
});
});

View File

@ -19,6 +19,8 @@
border-radius: var(--border-radius-lg);
position: relative;
transition: box-shadow 0.5s ease-in-out;
display: flex;
flex-direction: column;
&.zen-mode {
box-shadow: none;
@ -100,6 +102,7 @@
align-items: center;
cursor: pointer;
border-radius: var(--border-radius-md);
flex: 1 0 auto;
@media screen and (min-width: 1921px) {
height: 2.25rem;

View File

@ -1,5 +1,7 @@
import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({
@ -18,7 +20,7 @@ const MenuItemContent = ({
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
<Ellipsify>{children}</Ellipsify>
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>

View File

@ -2,13 +2,7 @@ import clsx from "clsx";
import { actionShortcuts } from "../../actions";
import { useTunnels } from "../../context/tunnels";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
import { HelpButton } from "../HelpButton";
import { Section } from "../Section";
import Stack from "../Stack";
@ -29,10 +23,6 @@ const Footer = ({
}) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
@ -60,15 +50,6 @@ const Footer = ({
})}
/>
)}
{showFinalize && (
<FinalizeAction
renderAction={actionManager.renderAction}
className={clsx("zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
appState.zenModeEnabled,
})}
/>
)}
</Section>
</Stack.Col>
</div>

View File

@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"focus": -0.007519379844961235,
"gap": 11.562288374879595,
"fixedPoint": [
0.04,
0.4633333333333333,
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
@ -118,8 +121,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "id49",
"focus": -0.0813953488372095,
"gap": 1,
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
},
"strokeColor": "#1864ab",
"strokeStyle": "solid",
@ -144,8 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"endArrowhead": "arrow",
"endBinding": {
"elementId": "ellipse-1",
"focus": 0.10666666666666667,
"gap": 3.8343264684446097,
"fixedPoint": [
-0.01,
0.44666666666666666,
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
@ -174,8 +183,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startArrowhead": null,
"startBinding": {
"elementId": "diamond-1",
"focus": 0,
"gap": 4.535423522449215,
"fixedPoint": [
0.9357142857142857,
0.5001,
],
"mode": "orbit",
},
"strokeColor": "#e67700",
"strokeStyle": "solid",
@ -334,8 +346,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endArrowhead": "arrow",
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 16,
"fixedPoint": [
-2.05,
0.5001,
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
@ -364,8 +379,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"startArrowhead": null,
"startBinding": {
"elementId": "text-1",
"focus": 0,
"gap": 1,
"fixedPoint": [
1,
0.5001,
],
"mode": "orbit",
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -421,376 +439,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id40",
"type": "text",
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id42",
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
99,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id41",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"x": 255.5,
"y": 239,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id39",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 25,
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "HELLO WORLD!!",
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "HELLO WORLD!!",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"y": 226.5,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 3`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id39",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"x": 155,
"y": 189,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id39",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "ellipse",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 100,
"x": 355,
"y": 189,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id44",
"type": "text",
},
],
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id46",
"focus": -0,
"gap": 1,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
99,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id45",
"focus": 0,
"gap": 1,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 4,
"versionNonce": Any<Number>,
"width": 100,
"x": 255.5,
"y": 239,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": null,
"containerId": "id43",
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 25,
"id": Any<String>,
"index": "a1",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "HELLO WORLD!!",
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "HELLO WORLD!!",
"textAlign": "center",
"type": "text",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 240,
"y": 226.5,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id43",
"type": "arrow",
},
],
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 25,
"id": Any<String>,
"index": "a2",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "HEYYYYY",
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "HEYYYYY",
"textAlign": "left",
"type": "text",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 70,
"x": 185,
"y": 226.5,
}
`;
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
{
"angle": 0,
"autoResize": true,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id43",
"type": "arrow",
},
],
"containerId": null,
"customData": undefined,
"fillStyle": "solid",
"fontFamily": 5,
"fontSize": 20,
"frameId": null,
"groupIds": [],
"height": 25,
"id": Any<String>,
"index": "a3",
"isDeleted": false,
"lineHeight": 1.25,
"link": null,
"locked": false,
"opacity": 100,
"originalText": "WHATS UP ?",
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"text": "WHATS UP ?",
"textAlign": "left",
"type": "text",
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 355,
"y": 226.5,
}
`;
exports[`Test Transform > should not allow duplicate ids 1`] = `
{
"angle": 0,
@ -1476,8 +1124,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "Alice",
"focus": -0,
"gap": 5.299874999999986,
"fixedPoint": [
-0.07542628418945944,
0.5001,
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
@ -1508,8 +1159,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"focus": 0,
"gap": 1,
"fixedPoint": [
1.000004978564514,
0.5001,
],
"mode": "orbit",
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
@ -1539,8 +1193,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endArrowhead": "arrow",
"endBinding": {
"elementId": "B",
"focus": 0,
"gap": 32,
"fixedPoint": [
0.46387050630528887,
0.48466257668711654,
],
"mode": "orbit",
},
"fillStyle": "solid",
"frameId": null,
@ -1567,8 +1224,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
"startArrowhead": null,
"startBinding": {
"elementId": "Bob",
"focus": 0,
"gap": 1,
"fixedPoint": [
0.39381496335223337,
1,
],
"mode": "orbit",
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",

View File

@ -32,7 +32,6 @@ import {
isArrowBoundToElement,
isArrowElement,
isElbowArrow,
isFixedPointBinding,
isLinearElement,
isLineElement,
isTextElement,
@ -61,7 +60,6 @@ import type {
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
PointBinding,
StrokeRoundness,
} from "@excalidraw/element/types";
@ -123,26 +121,20 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = <T extends ExcalidrawLinearElement>(
element: T,
binding: PointBinding | FixedPointBinding | null,
): T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null => {
binding: FixedPointBinding | null,
): FixedPointBinding | null => {
if (!binding) {
return null;
}
const focus = binding.focus || 0;
if (isElbowArrow(element)) {
const fixedPointBinding:
| ExcalidrawElbowArrowElement["startBinding"]
| ExcalidrawElbowArrowElement["endBinding"] = isFixedPointBinding(binding)
? {
...binding,
focus,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: null;
| ExcalidrawElbowArrowElement["endBinding"] = {
...binding,
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
mode: binding.mode || "orbit",
};
return fixedPointBinding;
}
@ -150,9 +142,7 @@ const repairBinding = <T extends ExcalidrawLinearElement>(
return {
...binding,
focus,
} as T extends ExcalidrawElbowArrowElement
? FixedPointBinding | null
: PointBinding | FixedPointBinding | null;
} as FixedPointBinding | null;
};
const restoreElementWithProperties = <

View File

@ -433,11 +433,11 @@ describe("Test Transform", () => {
startBinding: {
elementId: rectangle.id,
focus: 0,
gap: 1,
gap: 0,
},
endBinding: {
elementId: ellipse.id,
focus: -0,
focus: 0,
},
});
@ -518,11 +518,11 @@ describe("Test Transform", () => {
startBinding: {
elementId: text2.id,
focus: 0,
gap: 1,
gap: 0,
},
endBinding: {
elementId: text3.id,
focus: -0,
focus: 0,
},
});

View File

@ -16,7 +16,7 @@ import {
getLineHeight,
} from "@excalidraw/common";
import { bindLinearElement } from "@excalidraw/element";
import { bindBindingElement } from "@excalidraw/element";
import {
newArrowElement,
newElement,
@ -60,7 +60,6 @@ import type {
TextAlign,
VerticalAlign,
} from "@excalidraw/element/types";
import type { MarkOptional } from "@excalidraw/common/utility-types";
export type ValidLinearElement = {
@ -330,9 +329,10 @@ const bindLinearElementToElement = (
}
}
bindLinearElement(
bindBindingElement(
linearElement,
startBoundElement as ExcalidrawBindableElement,
"orbit",
"start",
scene,
);
@ -405,9 +405,10 @@ const bindLinearElementToElement = (
}
}
bindLinearElement(
bindBindingElement(
linearElement,
endBoundElement as ExcalidrawBindableElement,
"orbit",
"end",
scene,
);

View File

@ -281,6 +281,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
export { Button } from "./components/Button";
export { Footer };
export { MainMenu };
export { Ellipsify } from "./components/Ellipsify";
export { useDevice } from "./components/App";
export { WelcomeScreen };
export { LiveCollaborationTrigger };

View File

@ -16,7 +16,10 @@ import {
throttleRAF,
} from "@excalidraw/common";
import { FIXED_BINDING_DISTANCE, maxBindingGap } from "@excalidraw/element";
import {
FIXED_BINDING_DISTANCE,
maxBindingDistanceFromOutline,
} from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import {
getOmitSidesForDevice,
@ -193,7 +196,12 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"],
) => {
const padding = maxBindingGap(element, element.width, element.height, zoom);
const padding = maxBindingDistanceFromOutline(
element,
element.width,
element.height,
zoom,
);
context.fillStyle = "rgba(0,0,0,.05)";
@ -244,7 +252,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
) => {
const [element, startOrEnd, bindableElement] = suggestedBinding;
const threshold = maxBindingGap(
const threshold = maxBindingDistanceFromOutline(
bindableElement,
bindableElement.width,
bindableElement.height,

View File

@ -1,6 +1,6 @@
import { throttleRAF } from "@excalidraw/common";
import { renderElement } from "@excalidraw/element";
import { isInvisiblySmallElement, renderElement } from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
@ -34,6 +34,14 @@ const _renderNewElementScene = ({
context.scale(appState.zoom.value, appState.zoom.value);
if (newElement && newElement.type !== "selection") {
// e.g. when creating arrows and we're still below the arrow drag distance
// threshold
// (for now we skip render only with elements while we're creating to be
// safe)
if (isInvisiblySmallElement(newElement)) {
return;
}
renderElement(
newElement,
elementsMap,

View File

@ -11,6 +11,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [
@ -1083,6 +1084,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -1296,6 +1298,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -1626,6 +1629,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -1956,6 +1960,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2169,6 +2174,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2409,6 +2415,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2706,6 +2713,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3077,6 +3085,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3569,6 +3578,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3891,6 +3901,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -4213,6 +4224,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -4623,6 +4635,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [
@ -5839,6 +5852,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [
@ -7106,6 +7120,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [
@ -7772,6 +7787,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [
@ -8762,6 +8778,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": {
"items": [

View File

@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Click me
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Click me
</span>
</div>
</button>
<a
@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Excalidraw blog
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Excalidraw blog
</span>
</div>
</a>
<div
@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
Help
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Open
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Open
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Save to...
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Save to...
</span>
</div>
</button>
<button
@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Export image...
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Export image...
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Find on canvas
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Find on canvas
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Help
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
</div>
<div
class="dropdown-menu-item__shortcut"
@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Reset the canvas
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Reset the canvas
</span>
</div>
</button>
<div
@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
GitHub
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
GitHub
</span>
</div>
</a>
<a
@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Follow us
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Follow us
</span>
</div>
</a>
<a
@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Discord chat
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Discord chat
</span>
</div>
</a>
</div>
@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
Dark mode
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Dark mode
</span>
</div>
<div
class="dropdown-menu-item__shortcut"

File diff suppressed because it is too large Load Diff

View File

@ -95,135 +95,3 @@ exports[`move element > rectangle 5`] = `
"y": 40,
}
`;
exports[`move element > rectangles with binding arrow 5`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id6",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1006504105,
"width": 100,
"x": 0,
"y": 0,
}
`;
exports[`move element > rectangles with binding arrow 6`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id6",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 300,
"id": "id3",
"index": "a1",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1116226695,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 7,
"versionNonce": 1984422985,
"width": 300,
"x": 201,
"y": 2,
}
`;
exports[`move element > rectangles with binding arrow 7`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"customData": undefined,
"elbowed": false,
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id3",
"focus": "-0.46667",
"gap": 10,
},
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": "81.40630",
"id": "id6",
"index": "a2",
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
"81.00000",
"81.40630",
],
],
"roughness": 1,
"roundness": {
"type": 2,
},
"seed": 23633383,
"startArrowhead": null,
"startBinding": {
"elementId": "id0",
"focus": "-0.60000",
"gap": 10,
},
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 11,
"versionNonce": 1573789895,
"width": "81.00000",
"x": "110.00000",
"y": 50,
}
`;

View File

@ -11,6 +11,7 @@ exports[`given element A and group of elements B and given both are selected whe
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -436,6 +437,7 @@ exports[`given element A and group of elements B and given both are selected whe
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -851,6 +853,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -1416,6 +1419,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -1622,6 +1626,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2005,6 +2010,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2249,6 +2255,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2428,6 +2435,7 @@ exports[`regression tests > can drag element that covers another element, while
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -2752,6 +2760,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3006,6 +3015,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3246,6 +3256,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3481,6 +3492,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -3738,6 +3750,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -4051,6 +4064,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -4486,6 +4500,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -4768,6 +4783,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -5043,6 +5059,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -5250,6 +5267,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -5449,6 +5467,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -5841,6 +5860,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -6137,6 +6157,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"locked": false,
"type": "freedraw",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -6558,12 +6579,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingLinearElementId": "id15",
"selectedElementIds": {
"id15": true,
},
"selectedLinearElementId": null,
},
"inserted": {
"editingLinearElementId": null,
"selectedElementIds": {
"id12": true,
},
@ -6694,9 +6717,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingLinearElementId": null,
"selectedLinearElementId": "id15",
},
"inserted": {
"editingLinearElementId": "id15",
"selectedLinearElementId": null,
},
},
@ -6968,6 +6993,7 @@ exports[`regression tests > given a group of selected elements with an element t
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -7301,6 +7327,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -7579,6 +7606,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -7813,6 +7841,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8052,6 +8081,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8190,7 +8220,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -8203,7 +8233,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
"strokeWidth": 2,
"type": "rectangle",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -8231,6 +8261,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8369,7 +8400,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -8382,7 +8413,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
"strokeWidth": 2,
"type": "diamond",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -8410,6 +8441,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8548,7 +8580,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -8561,7 +8593,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
"strokeWidth": 2,
"type": "ellipse",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -8589,6 +8621,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8673,7 +8706,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"customLineAngle": null,
"elbowed": false,
"elementId": "id0",
"endBindingElement": "keep",
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
@ -8694,7 +8726,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"segmentMidPointHoveredCoords": null,
"selectedPointsIndices": null,
"startBindingElement": "keep",
},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
@ -8758,7 +8789,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -8771,8 +8802,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
0,
],
[
10,
10,
30,
30,
],
],
"roughness": 1,
@ -8786,7 +8817,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"strokeWidth": 2,
"type": "arrow",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -8814,6 +8845,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -8898,7 +8930,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"customLineAngle": null,
"elbowed": false,
"elementId": "id0",
"endBindingElement": "keep",
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
@ -8919,7 +8950,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"segmentMidPointHoveredCoords": null,
"selectedPointsIndices": null,
"startBindingElement": "keep",
},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
@ -8982,7 +9012,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -8995,8 +9025,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
0,
],
[
10,
10,
30,
30,
],
],
"polygon": false,
@ -9009,7 +9039,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"strokeWidth": 2,
"type": "line",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -9037,6 +9067,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"locked": false,
"type": "freedraw",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -9167,12 +9198,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": [
10,
10,
30,
30,
],
"link": null,
"locked": false,
@ -9183,12 +9214,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
0,
],
[
10,
10,
30,
30,
],
[
10,
10,
30,
30,
],
],
"pressures": [
@ -9204,7 +9235,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -9232,6 +9263,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -9316,7 +9348,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"customLineAngle": null,
"elbowed": false,
"elementId": "id0",
"endBindingElement": "keep",
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
@ -9337,7 +9368,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"segmentMidPointHoveredCoords": null,
"selectedPointsIndices": null,
"startBindingElement": "keep",
},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
@ -9401,7 +9431,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -9414,8 +9444,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
0,
],
[
10,
10,
30,
30,
],
],
"roughness": 1,
@ -9429,7 +9459,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"strokeWidth": 2,
"type": "arrow",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -9457,6 +9487,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -9595,7 +9626,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -9608,7 +9639,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
"strokeWidth": 2,
"type": "diamond",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -9636,6 +9667,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -9720,7 +9752,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"customLineAngle": null,
"elbowed": false,
"elementId": "id0",
"endBindingElement": "keep",
"hoverPointIndex": -1,
"isDragging": false,
"lastUncommittedPoint": null,
@ -9741,7 +9772,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"segmentMidPointHoveredCoords": null,
"selectedPointsIndices": null,
"startBindingElement": "keep",
},
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
@ -9804,7 +9834,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": null,
@ -9817,8 +9847,8 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
0,
],
[
10,
10,
30,
30,
],
],
"polygon": false,
@ -9831,7 +9861,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"strokeWidth": 2,
"type": "line",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -9859,6 +9889,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -9997,7 +10028,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -10010,7 +10041,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
"strokeWidth": 2,
"type": "ellipse",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -10038,6 +10069,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"locked": false,
"type": "freedraw",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -10168,12 +10200,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"lastCommittedPoint": [
10,
10,
30,
30,
],
"link": null,
"locked": false,
@ -10184,12 +10216,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
0,
],
[
10,
10,
30,
30,
],
[
10,
10,
30,
30,
],
],
"pressures": [
@ -10205,7 +10237,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -10233,6 +10265,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -10371,7 +10404,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"height": 30,
"index": "a0",
"isDeleted": false,
"link": null,
@ -10384,7 +10417,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
"strokeWidth": 2,
"type": "rectangle",
"version": 3,
"width": 10,
"width": 30,
"x": 10,
"y": 10,
},
@ -10412,6 +10445,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -10942,6 +10976,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -11221,6 +11256,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -11343,6 +11379,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -11542,6 +11579,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -11860,6 +11898,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -12288,6 +12327,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -12927,6 +12967,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -13052,6 +13093,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -13682,6 +13724,7 @@ exports[`regression tests > switches from group of selected elements to another
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -14020,6 +14063,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -14283,6 +14327,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -14405,6 +14450,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -14520,9 +14566,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingLinearElementId": "id6",
"selectedLinearElementId": null,
},
"inserted": {
"editingLinearElementId": null,
"selectedLinearElementId": "id6",
},
},
@ -14597,11 +14645,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"editingLinearElementId": null,
"selectedElementIds": {
"id3": true,
},
},
"inserted": {
"editingLinearElementId": "id6",
"selectedElementIds": {
"id6": true,
},
@ -14793,6 +14843,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"locked": false,
"type": "text",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@ -14915,6 +14966,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,

View File

@ -1148,7 +1148,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([
expect.objectContaining({
@ -1165,7 +1165,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
@ -1197,7 +1197,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(5);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.elements).toEqual([
expect.objectContaining({
@ -1213,7 +1213,7 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(4);
expect(assertSelectedElements(h.elements[0]));
expect(h.state.editingLinearElement).toBeNull();
expect(h.state.editingLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
expect(h.elements).toEqual([
expect.objectContaining({
@ -1638,13 +1638,15 @@ describe("history", () => {
expect(API.getUndoStack().length).toBe(5);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([1, 0.5001]),
focus: 0,
gap: 0,
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([0, 0.5001]),
focus: 0,
gap: 0,
});
expect(rect1.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
@ -1661,13 +1663,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([1, 0.5001]),
focus: 0,
gap: 0,
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([0, 0.5001]),
focus: 0,
gap: 0,
});
expect(h.elements).toEqual([
expect.objectContaining({
@ -1684,13 +1688,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([1, 0.5001]),
focus: 0,
gap: 0,
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([0, 0.5001]),
focus: 0,
gap: 0,
});
expect(h.elements).toEqual([
expect.objectContaining({
@ -1715,13 +1721,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(0);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([1, 0.5001]),
focus: 0,
gap: 0,
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([0, 0.5001]),
focus: 0,
gap: 0,
});
expect(h.elements).toEqual([
expect.objectContaining({
@ -1738,13 +1746,15 @@ describe("history", () => {
expect(API.getRedoStack().length).toBe(1);
expect(arrow.startBinding).toEqual({
elementId: rect1.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([1, 0.5001]),
focus: 0,
gap: 0,
});
expect(arrow.endBinding).toEqual({
elementId: rect2.id,
focus: expect.toBeNonNaNNumber(),
gap: expect.toBeNonNaNNumber(),
fixedPoint: expect.arrayContaining([0, 0.5001]),
focus: 0,
gap: 0,
});
expect(h.elements).toEqual([
expect.objectContaining({
@ -2347,15 +2357,13 @@ describe("history", () => {
],
startBinding: {
elementId: "KPrBI4g_v9qUB1XxYLgSz",
focus: -0.001587301587301948,
gap: 5,
fixedPoint: [1.0318471337579618, 0.49920634920634904],
mode: "orbit",
} as FixedPointBinding,
endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5",
focus: -0.0016129032258049847,
gap: 3.537079145500037,
fixedPoint: [0.4991935483870975, -0.03875193720914723],
mode: "orbit",
} as FixedPointBinding,
},
],
@ -4753,9 +4761,8 @@ describe("history", () => {
newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, {
endBinding: {
elementId: remoteContainer.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
mode: "orbit",
},
}),
remoteContainer,
@ -4842,15 +4849,13 @@ describe("history", () => {
type: "arrow",
startBinding: {
elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
mode: "orbit",
},
});
@ -4951,15 +4956,13 @@ describe("history", () => {
newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, {
startBinding: {
elementId: rect1.id,
gap: 1,
focus: 0,
fixedPoint: [0.5, 1],
mode: "orbit",
},
endBinding: {
elementId: rect2.id,
gap: 1,
focus: 0,
fixedPoint: [1, 0.5],
mode: "orbit",
},
}),
newElementWith(rect1, {
@ -5079,13 +5082,11 @@ describe("history", () => {
id: arrowId,
startBinding: expect.objectContaining({
elementId: rect1.id,
focus: 0,
gap: 1,
fixedPoint: expect.arrayContaining([1, 0.5001]),
}),
endBinding: expect.objectContaining({
elementId: rect2.id,
focus: -0,
gap: 1,
fixedPoint: expect.arrayContaining([0, 0.5001]),
}),
isDeleted: true,
}),

View File

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

View File

@ -1,16 +1,12 @@
import React from "react";
import { vi } from "vitest";
import { bindOrUnbindLinearElement } from "@excalidraw/element";
import { KEYS, reseed } from "@excalidraw/common";
import { bindBindingElement } from "@excalidraw/element";
import "@excalidraw/utils/test-utils";
import type {
ExcalidrawLinearElement,
ExcalidrawArrowElement,
NonDeleted,
ExcalidrawRectangleElement,
} from "@excalidraw/element/types";
import { Excalidraw } from "../index";
@ -85,10 +81,18 @@ describe("move element", () => {
const arrow = UI.createElement("arrow", { x: 110, y: 50, size: 80 });
act(() => {
// bind line to two rectangles
bindOrUnbindLinearElement(
arrow.get() as NonDeleted<ExcalidrawLinearElement>,
rectA.get() as ExcalidrawRectangleElement,
rectB.get() as ExcalidrawRectangleElement,
bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
rectA.get(),
"orbit",
"start",
h.app.scene,
);
bindBindingElement(
arrow.get() as NonDeleted<ExcalidrawArrowElement>,
rectB.get(),
"orbit",
"start",
h.app.scene,
);
});
@ -124,8 +128,10 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[50, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([
[301.02, 102.02],
]);
h.elements.forEach((element) => expect(element).toMatchSnapshot());
});

View File

@ -150,7 +150,7 @@ describe("regression tests", () => {
expect(h.state.activeTool.type).toBe(shape);
mouse.down(10, 10);
mouse.up(10, 10);
mouse.up(30, 30);
if (shouldSelect) {
expect(API.getSelectedElement().type).toBe(shape);

View File

@ -487,7 +487,12 @@ describe("tool locking & selection", () => {
expect(h.state.activeTool.locked).toBe(true);
for (const { value } of Object.values(SHAPES)) {
if (value !== "image" && value !== "selection" && value !== "eraser") {
if (
value !== "image" &&
value !== "selection" &&
value !== "eraser" &&
value !== "arrow"
) {
const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true);
}

View File

@ -444,6 +444,7 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true };
bindMode: "orbit" | "inside" | "skip";
}
export type SearchMatch = {

View File

@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(3);
expect(h.elements.length).toBe(2);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
@ -1198,7 +1198,7 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, " ");
Keyboard.exitTextEditor(editor);
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1].isDeleted).toBe(true);
expect(h.elements[1]).toBeUndefined();
});
it("should restore original container height and clear cache once text is unbind", async () => {

View File

@ -2,3 +2,4 @@ export * from "./export";
export * from "./withinBounds";
export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element";
export * from "./visualdebug";

View File

@ -11,6 +11,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"locked": false,
"type": "selection",
},
"bindMode": "orbit",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,