Compare commits

...

71 Commits

Author SHA1 Message Date
dwelle
dba41c4116 tweak animation some more + remove countdown 2025-09-19 19:58:01 +02:00
dwelle
83fa9099f6 alt anims + increase timeout to 700 2025-09-18 20:25:32 +02:00
Mark Tolmacs
97fa922060
fix:Animated binding highlight 2025-09-18 19:30:54 +02:00
Mark Tolmacs
b8d1b8a5bd
fix:Overlap inside binding 2025-09-18 16:16:01 +02:00
Mark Tolmacs
50e58abfd3
fix:Tool lock binding behavior restored 2025-09-17 20:52:52 +02:00
Mark Tolmacs
43816eb62d
fix Arrow edit mode selection 2025-09-17 20:41:04 +02:00
Mark Tolmacs
d6e3839d31
fix:Poisoned arrow 2025-09-17 18:56:41 +02:00
Mark Tolmacs
e0dd29aa36
fix:Refactored and fixed highlight animation 2025-09-17 18:37:56 +02:00
Mark Tolmacs
f0494ced4c
feat:Highlight animations
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-17 10:46:36 +02:00
Mark Tolmacs
345e3f68f1
chore:Basic interactive canvas animation re-render trigger for highlights
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-16 20:35:17 +02:00
Mark Tolmacs
5b77409eff
fix: Jump arrow inside it gets visially too short
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-16 19:14:29 +02:00
Mark Tolmacs
6398d14f3f
feat: Remove center binding 2025-09-16 17:08:07 +02:00
Mark Tolmacs
32526c4d4a
fix: Locked tool + arrow 2025-09-16 17:05:57 +02:00
Mark Tolmacs
f2f5168355
chore: Logic for measurement 2025-09-16 17:05:30 +02:00
Mark Tolmacs
55115d2ee4
feat: Allow inside binding for new arrows in nested cases 2025-09-16 17:05:03 +02:00
Mark Tolmacs
acc1241015
fix: Update history snapshot 2025-09-15 12:38:44 +02:00
Mark Tolmacs
e67338bff0
fix: New arrow start orbit when nested binds on the end 2025-09-15 11:01:30 +02:00
Mark Tolmacs
5a350a17c0
fix:Add intersection padding 2025-09-15 10:55:00 +02:00
dwelle
073f47d253 tweak highlight stroke width some more 2025-09-14 22:48:56 +02:00
dwelle
6c2f5dbd81 handle overlap when both elements the same size 2025-09-14 19:04:31 +02:00
dwelle
4e7b399927 reduce timeout 2025-09-14 14:48:28 +02:00
dwelle
7172006d1b update hints 2025-09-14 14:48:28 +02:00
Mark Tolmacs
b1006e2bfd
fix: Locked elbow arrow creation 2025-09-14 14:05:55 +02:00
dwelle
4d8a1b29f6 render highlight on the outside 2025-09-14 13:41:32 +02:00
dwelle
f6978ae162 tweak binding highlight width 2025-09-14 13:39:44 +02:00
dwelle
b789308798 POC: highlight center on hover 2025-09-14 13:39:40 +02:00
Mark Tolmacs
ef2bde0d03
fix:More stroke width for highlight 2025-09-13 14:46:24 +02:00
Mark Tolmacs
80706f733b
fix:Arrow tool hover stuck highlight 2025-09-13 14:44:24 +02:00
Mark Tolmacs
737f6e08c1
feat: Center point with more precise highlight outlines 2025-09-13 14:41:47 +02:00
dwelle
c4874e9dd9 ignore invisible elements when binding 2025-09-13 12:06:48 +02:00
Mark Tolmacs
45b7cfc14b
fix:Change center binding to circular 2025-09-13 11:54:11 +02:00
Mark Tolmacs
65a105e30a
fix: Reverse 2025-09-13 11:22:45 +02:00
Mark Tolmacs
ee6f4d9ce5
Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding 2025-09-13 11:17:02 +02:00
Mark Tolmacs
7ae4d3aab5
feat:Add tolerance to shape nesting detection 2025-09-13 10:59:36 +02:00
Mark Tolmacs
017b36aeae
fix:More precise element nesting check 2025-09-12 19:19:49 +02:00
Mark Tolmacs
3ab8f67bc6
fix: history 2025-09-11 22:04:51 +02:00
Mark Tolmacs
fb3fe09226
fix: Nested shape binding 2025-09-11 21:58:19 +02:00
Mark Tolmacs
e5c7a6304e
fix: Binding highlight stroke on sharp bindables 2025-09-11 21:00:11 +02:00
Mark Tolmacs
434ed03f1e
fix: Size-based orbit jump-in 2025-09-11 18:00:51 +02:00
Mark Tolmacs
d73e273e63
fix: Tune nested shape binding 2025-09-11 17:48:56 +02:00
Mark Tolmacs
8d7af92719
fix: Fully nested shapes 2025-09-11 16:39:41 +02:00
Mark Tolmacs
9ac0f8231c
fix:Highlight flicker
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-10 17:34:19 +02:00
Mark Tolmacs
d4680df3d9
Trigger Rebuild 2025-09-10 14:44:31 +02:00
Mark Tolmacs
5830d518d4
fix: Image binding rule changed 2025-09-10 14:31:16 +02:00
Mark Tolmacs
b23768719d
fix: New highlight overdraws arrow
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 13:50:05 +02:00
Mark Tolmacs
fce13ccefd
chore: Laxing on invariants
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 11:20:43 +02:00
Mark Tolmacs
35c986cbef
fix: Alt immediate update
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-09 11:10:54 +02:00
Mark Tolmacs
a06b828ed2
fix: snapshots
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:29:26 +02:00
Mark Tolmacs
7703cc2597
fix: Circular dep
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:27:12 +02:00
Mark Tolmacs
433774e892
fix: Binding suggestions
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 18:22:33 +02:00
Mark Tolmacs
be56e84596
fix: Existing arrow nested bindable
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 17:59:35 +02:00
Mark Tolmacs
eb9efc261a
Alt unbind fix
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-07 16:31:18 +02:00
Mark Tolmacs
b01eea9eb4
fix: Overlap behavior 2025-09-05 16:03:24 +02:00
Mark Tolmacs
109ff756f5
feat: Nested shapes handling
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 17:03:19 +02:00
Mark Tolmacs
6ea0102b0a
fix: Lint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 15:50:00 +02:00
Mark Tolmacs
8a3ba853ab
fix: Multi-point arrows and linears
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 15:03:22 +02:00
Mark Tolmacs
bcf3127fe5
chore: Small refactor
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-04 10:23:13 +02:00
Mark Tolmacs
5a62499e95
fix: New arrow never binds inside
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 21:34:22 +02:00
Mark Tolmacs
f8b8c0e95c
fix: Arrow start inside binding switch
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 20:58:20 +02:00
Mark Tolmacs
364f0be815
Fix multipoint arrow orbit
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2025-09-03 18:29:03 +02:00
Mark Tolmacs
10d38a8539
feat: Alt inside start binding mode change 2025-09-02 17:00:45 +02:00
Mark Tolmacs
67fff43b92
fix: Ctrl+Alt precedence 2025-09-02 16:49:44 +02:00
Mark Tolmacs
62d7740c94
fix: Multipoint arrow opposite point movement 2025-09-02 16:39:38 +02:00
Mark Tolmacs
4f43399951
fix: Disable drag drag when arrow is bound 2025-09-02 16:15:57 +02:00
Mark Tolmacs
8dae900bbb
fix: Dragging fix 2 2025-09-02 15:50:32 +02:00
Mark Tolmacs
9a49c8e448
fix: Dragging issues 2025-09-02 15:45:32 +02:00
Mark Tolmacs
8d77f1daf5
fix: Deleted arrow causes problems 2025-09-02 15:23:52 +02:00
Mark Tolmacs
405d37e158
fix: Delete invariant violation with arrows 2025-09-02 15:14:33 +02:00
dwelle
cba5d01460 fix: allow inside binding via timeout if arrow has no startBinding 2025-09-02 14:35:30 +02:00
Mark Tolmacs
aa7351f649
CHange new arrow creation 2025-09-02 13:57:27 +02:00
Mark Tolmacs
4438137a57
Fixed point binding for simple arrows
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

Arrow dragging gets a little drag to avoid accidental unbinding

Fixed point binding for simple arrows when the arrow doesn't point to the element

Fix binding disabled use-case triggering arrow editor

Timed binding mode change for simple arrows

Apply fixes

Remove code to unbind on drag

Update simple arrow fixed point when arrow is dragged or moved by arrow keys

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>

Only transparent bindables allow binding fallthrough

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

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

Restore drag

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

Completely rewritten binding

Include point updates after binding update

Fix point updates when endpoint dragged and opposite endpoint orbits

centered focus point only for new arrows

Make z-index arrow reorder on bind

Turn off inside binding mode after leaving a shape

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)

z-index update

Bind mode on precise binding

Fix binding to inside element

Fix initial arrow not following cursor (white dot)

Fix elbow arrow

Fix z-index so it works on hover

Fix fixed angle orbiting

Move point click arrow creation over to common strategy

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

Add binding strategy for drag arrow creation

Fix elbow arrow

Fix point handles

Snap to center

Fix transparent shape binding

Internal arrow creation fix

Fix point binding

Fix selection bug

Fix new arrow focus point

Images now always bind inside

Flashing arrow creation on binding band

Add watchState debug method to window.h

Fix debug canvas crash

Remove non-needed bind mode

Fix restore

No keyboard movement when bound

Add actionFinalize when arrow in edit mode

Add drag to the Stats panel when bound arrow is moved

Further simplify curve tracking

Add typing to action register()

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

Fix point at finalize

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

Fix type errors

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

New arrow binding rules

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

Fix cyclical dep

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

Fix jiggly arrows

Fix jiggly arrow x2

Long inside-other binding

Click-click binding

Fix arrows

Performance

[PERF] Replace in-place Jacobian derivation with analytical version

Different approach to inside binding

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

Fixes

Fix inconsistent arrow start jump out

Change how images are bound to on new arrow creation

Lower timeout

Small insurance fix

Fix curve test

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

No center focus point

90% inside center binding

Fixing tests

fix: Elbow arrow fixes

fix: More arrow fixes

Do not trigger arrow binding for linear elements

fix: Linear elements

fix: Refactor actionFinalize for linear

Binding tests updated

fix: Jump when cursor not moved

fix: history tests

Fix history snapshot

Fix undo issue

fix(eraser): Remove binding from the other element

fix(tests): Update tests

chore: Attempt filtering new set state

Fix excessive history recording

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

Fix all tests

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

fix(transform): Fix group resize and rotate

fix(binding): Harmonize binding param usage

fix: Center focus point

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

chore: Trigger build

Remove binding gap

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

Binding highlight refactor

fix: Refactored timeout bind mode handling

fix: Center when orbiting

feat: Color change on highlight

Fix orbit binding highlight

fix: hiding arrow

Fix arrow binding

Fix arrow drag selection logic

Binding highlight is now hot pink

Change inside binding logic for start point

Render focus point in debug mode

Fix snap to center

Fix actionFinalize for new arrow creation

fix: snapToCenter()

80% by length

fix: attempt at fixing the dancing arrows

feat: No center snap when start is not bound

Fix centering for existing arrows

tweak binding highlight color

change `appState.suggestedBindings` -> `suggestedBinding` & remove unused code

Refactor delayed bind mode change

Binding highlight rotation support + image support

fix(highlight): Overdraw fixes

feat: Do not allow drag bound arrow closer to the shape than dragging distance

feat: Stroke width adaptive fixed binding distance

chore: More point dragging centralization

New element behavior

Refactor dragging

Fix incorrect highlight sizing

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

Fix delayed bind mode for multiElement arrows

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

Fix multi-point

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

Fix elbow arrows

Simplify state

Small positional fixes

Fix jiggly arrows

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

Fixes for arrow dragging

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

Elbow arrow fixes

Highlight fixes

Fix elbow arrow binding

Frame highlight

Fix elbow mid-point binding

Fix binding suggestion for disabled binding state

Implement Alt

Remove debug
2025-09-01 21:56:20 +02:00
89 changed files with 6107 additions and 4580 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -539,3 +539,5 @@ export enum UserIdleState {
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20; export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35; export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms

View File

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

View File

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

View File

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

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 { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
@ -34,10 +34,13 @@ import {
elementCenterPoint, elementCenterPoint,
getCenterForBounds, getCenterForBounds,
getCubicBezierCurveBound, getCubicBezierCurveBound,
getDiamondPoints,
getElementBounds, getElementBounds,
} from "./bounds"; } from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isBindableElement,
isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
@ -58,12 +61,17 @@ import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawBindableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
NonDeleted,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
} from "./types"; } from "./types";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
@ -94,6 +102,7 @@ export type HitTestArgs = {
threshold: number; threshold: number;
elementsMap: ElementsMap; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
overrideShouldTestInside?: boolean;
}; };
export const hitElementItself = ({ export const hitElementItself = ({
@ -102,6 +111,7 @@ export const hitElementItself = ({
threshold, threshold,
elementsMap, elementsMap,
frameNameBound = null, frameNameBound = null,
overrideShouldTestInside = false,
}: HitTestArgs) => { }: HitTestArgs) => {
// Hit test against a frame's name // Hit test against a frame's name
const hitFrameName = frameNameBound const hitFrameName = frameNameBound
@ -134,7 +144,9 @@ export const hitElementItself = ({
} }
// Do the precise (and relatively costly) hit test // Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element) const hitElement = (
overrideShouldTestInside ? true : shouldTestInside(element)
)
? // Since `inShape` tests STRICTLY againt the insides of a shape ? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders" // we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) || isPointInElement(point, element, elementsMap) ||
@ -193,6 +205,102 @@ export const hitElementBoundText = (
return isPointInElement(point, boundTextElement, elementsMap); return isPointInElement(point, boundTextElement, elementsMap);
}; };
const bindingBorderTest = (
element: NonDeleted<ExcalidrawBindableElement>,
[x, y]: Readonly<GlobalPoint>,
elementsMap: NonDeletedSceneElementsMap,
tolerance: number = 0,
): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element);
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
);
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= tolerance
: intersections.length > 0 && distance <= t;
};
export const getAllHoveredElementAtPoint = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement>[] => {
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
invariant(
!element.isDeleted,
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
);
if (
isBindableElement(element, false) &&
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
break;
}
}
}
return candidateElements;
};
export const getHoveredElementForBinding = (
point: Readonly<GlobalPoint>,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
toleranceFn?: (element: ExcalidrawBindableElement) => number,
): NonDeleted<ExcalidrawBindableElement> | null => {
const candidateElements = getAllHoveredElementAtPoint(
point,
elements,
elementsMap,
toleranceFn,
);
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
};
/** /**
* Intersect a line with an element for binding test * Intersect a line with an element for binding test
* *
@ -554,3 +662,61 @@ export const isPointInElement = (
return intersections.length % 2 === 1; return intersections.length % 2 === 1;
}; };
export const isBindableElementInsideOtherBindable = (
innerElement: ExcalidrawBindableElement,
outerElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): boolean => {
// Get corner points of the inner element based on its type
const getCornerPoints = (
element: ExcalidrawElement,
offset: number,
): GlobalPoint[] => {
const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap);
if (element.type === "diamond") {
// Diamond has 4 corner points at the middle of each side
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const corners: GlobalPoint[] = [
pointFrom(x + topX, y + topY - offset), // top
pointFrom(x + rightX + offset, y + rightY), // right
pointFrom(x + bottomX, y + bottomY + offset), // bottom
pointFrom(x + leftX - offset, y + leftY), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
if (element.type === "ellipse") {
// For ellipse, test points at the extremes (top, right, bottom, left)
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
const corners: GlobalPoint[] = [
pointFrom(cx, cy - ry - offset), // top
pointFrom(cx + rx + offset, cy), // right
pointFrom(cx, cy + ry + offset), // bottom
pointFrom(cx - rx - offset, cy), // left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
}
// Rectangle and other rectangular shapes (image, text, etc.)
const corners: GlobalPoint[] = [
pointFrom(x - offset, y - offset), // top-left
pointFrom(x + width + offset, y - offset), // top-right
pointFrom(x + width + offset, y + height + offset), // bottom-right
pointFrom(x - offset, y + height + offset), // bottom-left
];
return corners.map((corner) => pointRotateRads(corner, center, angle));
};
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
const innerCorners = getCornerPoints(innerElement, offset);
// Check if all corner points of the inner element are inside the outer element
return innerCorners.every((corner) =>
isPointInElement(corner, outerElement, elementsMap),
);
};

View File

@ -2,6 +2,7 @@ import {
TEXT_AUTOWRAP_THRESHOLD, TEXT_AUTOWRAP_THRESHOLD,
getGridPoint, getGridPoint,
getFontString, getFontString,
DRAGGING_THRESHOLD,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@ -13,7 +14,7 @@ import type {
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { updateBoundElements } from "./binding"; import { unbindBindingElement, updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { getCommonBounds } from "./bounds";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
@ -102,9 +103,26 @@ export const dragSelectedElements = (
gridSize, gridSize,
); );
const elementsToUpdateIds = new Set(
Array.from(elementsToUpdate, (el) => el.id),
);
elementsToUpdate.forEach((element) => { 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)) { if (!isArrowElement(element)) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
// skip arrow labels since we calculate its position during render // skip arrow labels since we calculate its position during render
const textElement = getBoundTextElement( const textElement = getBoundTextElement(
element, element,
@ -121,6 +139,33 @@ export const dragSelectedElements = (
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
simultaneouslyUpdated: Array.from(elementsToUpdate), simultaneouslyUpdated: Array.from(elementsToUpdate),
}); });
} else if (
// NOTE: Add a little initial drag to the arrow dragging when the arrow
// is the single element being dragged to avoid accidentally unbinding
// the arrow when the user just wants to select it.
elementsToUpdate.size > 1 ||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
DRAGGING_THRESHOLD ||
(!element.startBinding && !element.endBinding)
) {
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
const shouldUnbindStart =
element.startBinding && !isStartBoundElementSelected;
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
if (shouldUnbindStart || shouldUnbindEnd) {
// NOTE: Moving the bound arrow should unbind it, otherwise we would
// have weird situations, like 0 lenght arrow when the user moves
// the arrow outside a filled shape suddenly forcing the arrow start
// and end point to jump "outside" the shape.
if (shouldUnbindStart) {
unbindBindingElement(element, "start", scene);
}
if (shouldUnbindEnd) {
unbindBindingElement(element, "end", scene);
}
}
} }
}); });
}; };

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -90,7 +90,7 @@ const isPendingImageElement = (
const shouldResetImageFilter = ( const shouldResetImageFilter = (
element: ExcalidrawElement, element: ExcalidrawElement,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
return ( return (
appState.theme === THEME.DARK && appState.theme === THEME.DARK &&
@ -217,7 +217,7 @@ const generateElementCanvas = (
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom: Zoom, zoom: Zoom,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
): ExcalidrawElementWithCanvas | null => { ): ExcalidrawElementWithCanvas | null => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
@ -269,7 +269,7 @@ const generateElementCanvas = (
context.filter = IMAGE_INVERT_FILTER; context.filter = IMAGE_INVERT_FILTER;
} }
drawElementOnCanvas(element, rc, context, renderConfig, appState); drawElementOnCanvas(element, rc, context, renderConfig);
context.restore(); context.restore();
@ -404,7 +404,6 @@ const drawElementOnCanvas = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => { ) => {
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
@ -550,7 +549,7 @@ const generateElementWithCanvas = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
const zoom: Zoom = renderConfig const zoom: Zoom = renderConfig
? appState.zoom ? appState.zoom
@ -607,7 +606,7 @@ const drawElementFromCanvas = (
elementWithCanvas: ExcalidrawElementWithCanvas, elementWithCanvas: ExcalidrawElementWithCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
allElementsMap: NonDeletedSceneElementsMap, allElementsMap: NonDeletedSceneElementsMap,
) => { ) => {
const element = elementWithCanvas.element; const element = elementWithCanvas.element;
@ -725,7 +724,7 @@ export const renderElement = (
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState | InteractiveCanvasAppState,
) => { ) => {
const reduceAlphaForSelection = const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" && appState.openDialog?.name === "elementLinkSelector" &&
@ -795,7 +794,7 @@ export const renderElement = (
context.translate(cx, cy); context.translate(cx, cy);
context.rotate(element.angle); context.rotate(element.angle);
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig, appState); drawElementOnCanvas(element, rc, context, renderConfig);
context.restore(); context.restore();
} else { } else {
const elementWithCanvas = generateElementWithCanvas( const elementWithCanvas = generateElementWithCanvas(
@ -888,13 +887,7 @@ export const renderElement = (
tempCanvasContext.translate(-shiftX, -shiftY); tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas( drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
element,
tempRc,
tempCanvasContext,
renderConfig,
appState,
);
tempCanvasContext.translate(shiftX, shiftY); tempCanvasContext.translate(shiftX, shiftY);
@ -933,7 +926,7 @@ export const renderElement = (
} }
context.translate(-shiftX, -shiftY); context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig, appState); drawElementOnCanvas(element, rc, context, renderConfig);
} }
context.restore(); context.restore();
@ -1054,7 +1047,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
smoothing: 0.5, smoothing: 0.5,
streamline: 0.5, streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup last: true,
}; };
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));

View File

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

View File

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

View File

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

View File

@ -1,18 +1,25 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common"; import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
import { isFrameLikeElement } from "./typeChecks"; import { isFrameLikeElement, isTextElement } from "./typeChecks";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
import { syncMovedIndices } from "./fractionalIndex"; import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { getHoveredElementForBinding } from "./collision";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type {
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types"; ExcalidrawArrowElement,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
Ordered,
OrderedExcalidrawElement,
} from "./types";
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => { const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId; return element.frameId === frameId || element.id === frameId;
@ -139,6 +146,51 @@ const getContiguousFrameRangeElements = (
return allElements.slice(rangeStart, rangeEnd + 1); return allElements.slice(rangeStart, rangeEnd + 1);
}; };
/**
* Moves the arrow element above any bindable elements it intersects with or
* hovers over.
*/
export const moveArrowAboveBindable = (
point: GlobalPoint,
arrow: ExcalidrawArrowElement,
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
scene: Scene,
): readonly OrderedExcalidrawElement[] => {
const hoveredElement = getHoveredElementForBinding(
point,
elements,
elementsMap,
);
if (!hoveredElement) {
return elements;
}
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
const containerElement = isTextElement(hoveredElement)
? getContainerElement(hoveredElement, elementsMap)
: null;
const bindableIds = [
hoveredElement.id,
boundTextElement?.id,
containerElement?.id,
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
const updatedElements = Array.from(elements);
const arrow = updatedElements.splice(arrowIdx, 1)[0];
updatedElements.splice(bindableIdx, 0, arrow);
scene.replaceAllElements(updatedElements);
}
return elements;
};
/** /**
* Returns next candidate index that's available to be moved to. Currently that * Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it). * is a non-deleted element, and not inside a group (unless we're editing it).

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -206,12 +206,8 @@ export const actionDeleteSelected = register({
trackEvent: { category: "element", action: "delete" }, trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => { perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) { if (appState.selectedLinearElement?.isEditing) {
const { const { elementId, selectedPointsIndices } =
elementId, appState.selectedLinearElement;
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.selectedLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap(); const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement( const linearElement = LinearElementEditor.getElement(
elementId, elementId,
@ -248,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(
linearElement.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(
linearElement, linearElement,
app, app,
@ -273,7 +256,6 @@ export const actionDeleteSelected = register({
...appState, ...appState,
selectedLinearElement: { selectedLinearElement: {
...appState.selectedLinearElement, ...appState.selectedLinearElement,
...binding,
selectedPointsIndices: selectedPointsIndices:
selectedPointsIndices?.[0] > 0 selectedPointsIndices?.[0] > 0
? [selectedPointsIndices[0] - 1] ? [selectedPointsIndices[0] - 1]
@ -302,6 +284,7 @@ export const actionDeleteSelected = register({
type: app.defaultSelectionTool, type: app.defaultSelectionTool,
}), }),
multiElement: null, multiElement: null,
newElement: null,
activeEmbeddable: null, activeEmbeddable: null,
selectedLinearElement: null, selectedLinearElement: null,
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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