Compare commits

..

10 Commits

Author SHA1 Message Date
Mark Tolmacs a7a3f9d82b Merge branch 'master' into mtolmacs/fix/grid-binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-04 15:54:48 +00:00
Mark Tolmacs 895c2b23c7 fix: Elbow midpoint
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 19:46:56 +00:00
Mark Tolmacs 50099012c6 fix Suggested binding flicker
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 16:47:43 +00:00
Mark Tolmacs de2ad7cd3f fix: Inside binding grid respect
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-19 14:55:56 +00:00
Mark Tolmacs d7abb6a309 fix: Diamonds and ellipses
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 20:25:19 +00:00
Mark Tolmacs 9fd91d9a59 fix: False binding
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 16:29:09 +00:00
Mark Tolmacs ba087233cb fix: Grid is secondary to snap distance
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-18 12:04:23 +00:00
Mark Tolmacs 7c58d1f6f4 chore: Remove more non-needed grid snapping
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 20:28:29 +00:00
Mark Tolmacs d9ab298526 fix: Remove duplicated grid snapping
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 20:21:45 +00:00
Mark Tolmacs 7b2496bfd7 fix: Dragged arrow endpoint ignore grid and angle locks
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-17 19:59:33 +00:00
98 changed files with 1604 additions and 3051 deletions
-7
View File
@@ -1,7 +0,0 @@
# VITE_DEBUG_DOM
# When "true", testing-library failures (waitFor / getBy*) include the full
# serialized DOM in the error message. It's off by default because it's noisy.
#
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
# inspect the DOM of a failing test.
VITE_DEBUG_DOM=false
+10 -1
View File
@@ -22,6 +22,7 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
THEME,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -951,7 +952,6 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
@@ -988,6 +988,7 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
@@ -1228,6 +1229,14 @@ const ExcalidrawWrapper = () => {
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
+6 -1
View File
@@ -20,6 +20,7 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
@@ -77,7 +78,11 @@ export const AppMainMenu: React.FC<{
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
+20 -1
View File
@@ -1,4 +1,5 @@
import { THEME } from "@excalidraw/excalidraw";
import { EVENT, CODES, KEYS } from "@excalidraw/common";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
@@ -30,10 +31,28 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme]);
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
-7
View File
@@ -82,13 +82,6 @@ export default defineConfig(({ mode }) => {
"../packages/fractional-indexing/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
},
],
},
build: {
+1 -2
View File
@@ -57,8 +57,7 @@
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+1 -5
View File
@@ -80,11 +80,7 @@ const cssInvert = (
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string, enable = true): string => {
if (!enable) {
return color;
}
export const applyDarkModeFilter = (color: string): string => {
const cached = DARK_MODE_COLORS_CACHE?.get(color);
if (cached) {
return cached;
+4 -43
View File
@@ -404,47 +404,11 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
export const STROKE_WIDTH = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
@@ -459,7 +423,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeWidth: 2,
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
@@ -550,6 +514,3 @@ export const BIND_MODE_TIMEOUT = 700; // ms
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
+5 -4
View File
@@ -1,4 +1,5 @@
import {
pointFrom,
pointFromPair,
type GlobalPoint,
type LocalPoint,
@@ -69,12 +70,12 @@ export const getGridPoint = (
x: number,
y: number,
gridSize: NullableGridSize,
): [number, number] => {
): GlobalPoint => {
if (gridSize) {
return [
return pointFrom<GlobalPoint>(
Math.round(x / gridSize) * gridSize,
Math.round(y / gridSize) * gridSize,
];
);
}
return [x, y];
return pointFrom<GlobalPoint>(x, y);
};
-109
View File
@@ -1,109 +0,0 @@
import { BinaryHeap } from "../src/binary-heap";
describe("BinaryHeap", () => {
const numberHeap = () => new BinaryHeap<number>((n) => n);
const drain = (heap: BinaryHeap<number>) => {
const out: number[] = [];
while (heap.size() > 0) {
out.push(heap.pop()!);
}
return out;
};
describe("empty heap", () => {
it("has size 0", () => {
expect(numberHeap().size()).toBe(0);
});
it("pop() returns null", () => {
expect(numberHeap().pop()).toBe(null);
});
it("remove() is a no-op and does not throw", () => {
const heap = numberHeap();
expect(() => heap.remove(1)).not.toThrow();
expect(heap.size()).toBe(0);
});
});
describe("push / pop", () => {
it("tracks size as items are added and removed", () => {
const heap = numberHeap();
[3, 1, 2].forEach((n) => heap.push(n));
expect(heap.size()).toBe(3);
heap.pop();
expect(heap.size()).toBe(2);
});
it("pops a single pushed element back out", () => {
const heap = numberHeap();
heap.push(42);
expect(heap.pop()).toBe(42);
expect(heap.pop()).toBe(null);
});
it("always pops the smallest score first", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
});
it("handles duplicate scores", () => {
const heap = numberHeap();
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
});
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
const heap = numberHeap();
// pushing in descending order forces a sift-up on every insert
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
input.forEach((n) => heap.push(n));
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
});
});
describe("remove", () => {
it("removes an interior element and keeps the rest ordered", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(8);
expect(heap.size()).toBe(4);
expect(drain(heap)).toEqual([1, 3, 5, 9]);
});
it("can remove the current minimum", () => {
const heap = numberHeap();
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
heap.remove(1);
expect(heap.size()).toBe(4);
expect(heap.pop()).toBe(3);
});
});
describe("rescoreElement", () => {
type Node = { id: string; f: number };
it("re-sorts a node after its score is lowered", () => {
const heap = new BinaryHeap<Node>((node) => node.f);
const a = { id: "a", f: 10 };
const b = { id: "b", f: 20 };
const c = { id: "c", f: 30 };
[a, b, c].forEach((node) => heap.push(node));
c.f = 5;
heap.rescoreElement(c);
expect(heap.pop()).toBe(c);
expect(heap.pop()).toBe(a);
expect(heap.pop()).toBe(b);
});
});
});
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+134 -20
View File
@@ -1,6 +1,7 @@
import {
arrayToMap,
getFeatureFlag,
getGridPoint,
invariant,
isTransparent,
} from "@excalidraw/common";
@@ -22,7 +23,7 @@ import {
} from "@excalidraw/math";
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common";
@@ -154,6 +155,7 @@ export const bindOrUnbindBindingElement = (
altKey?: boolean;
angleLocked?: boolean;
initialBinding?: boolean;
gridSize?: NullableGridSize;
},
) => {
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
@@ -170,12 +172,16 @@ export const bindOrUnbindBindingElement = (
},
);
const isMidpointSnappingEnabled =
appState.isMidpointSnappingEnabled && !appState.gridModeEnabled;
bindOrUnbindBindingElementEdge(
arrow,
start,
"start",
scene,
appState.isBindingEnabled,
isMidpointSnappingEnabled,
);
bindOrUnbindBindingElementEdge(
arrow,
@@ -183,6 +189,7 @@ export const bindOrUnbindBindingElement = (
"end",
scene,
appState.isBindingEnabled,
isMidpointSnappingEnabled,
);
if (start.focusPoint || end.focusPoint) {
// If the strategy dictates a focus point override, then
@@ -227,6 +234,7 @@ const bindOrUnbindBindingElementEdge = (
startOrEnd: "start" | "end",
scene: Scene,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => {
if (mode === null) {
// null means break the binding
@@ -240,6 +248,7 @@ const bindOrUnbindBindingElementEdge = (
scene,
focusPoint,
shouldSnapToOutline,
isMidpointSnappingEnabled,
);
}
};
@@ -593,6 +602,7 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
finalize?: boolean;
initialBinding?: boolean;
zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
},
): { start: BindingStrategy; end: BindingStrategy } => {
if (getFeatureFlag("COMPLEX_BINDINGS")) {
@@ -633,6 +643,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
finalize?: boolean;
initialBinding?: boolean;
zoom?: AppState["zoom"];
gridSize?: NullableGridSize;
},
): { start: BindingStrategy; end: BindingStrategy } => {
const startIdx = 0;
@@ -643,13 +654,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) {
console.error(
"Attempting to bind a linear element with less than 2 points",
);
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
invariant(
arrow.points.length > 1,
"Do not attempt to bind linear elements with a single point",
);
// If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) {
@@ -698,7 +706,9 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
elementsMap,
);
const hit = getHoveredElementForBinding(
globalPoint,
opts?.angleLocked || appState.gridModeEnabled
? pointFrom<GlobalPoint>(scenePointerX, scenePointerY)
: globalPoint,
elements,
elementsMap,
maxBindingDistance_simple(appState.zoom),
@@ -750,7 +760,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
? globalPoint
: // NOTE: Can only affect the start point because new arrows always drag the end point
opts?.newArrow
? appState.selectedLinearElement!.initialState.origin!
? getGridPoint(
appState.selectedLinearElement!.initialState.origin![0],
appState.selectedLinearElement!.initialState.origin![1],
opts.gridSize as NullableGridSize,
)
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
0,
@@ -809,12 +823,27 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
focusPoint:
projectFixedPointOntoDiagonal(
arrow,
globalPoint,
opts?.angleLocked || appState.gridModeEnabled
? snapBoundPointToGrid(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
hit,
elementsMap,
appState.gridSize as NullableGridSize,
arrow,
LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startDragged ? 1 : -2,
elementsMap,
),
)
: globalPoint,
hit,
startDragged ? "start" : "end",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
appState.isMidpointSnappingEnabled &&
!opts?.angleLocked &&
!appState.gridModeEnabled,
) || globalPoint,
}
: { mode: null };
@@ -859,7 +888,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? "end" : "start",
elementsMap,
appState.zoom,
appState.isMidpointSnappingEnabled,
false,
) || otherEndpoint,
}
: { mode: undefined }
@@ -893,13 +922,10 @@ const getBindingStrategyForDraggingBindingElementEndpoints_complex = (
let start: BindingStrategy = { mode: undefined };
let end: BindingStrategy = { mode: undefined };
if (arrow.points.length < 2) {
console.error(
"Attempting to bind a linear element with less than 2 points",
);
// a single-point can't be bound -> cancel
return { start: { mode: undefined }, end: { mode: undefined } };
}
invariant(
arrow.points.length > 1,
"Do not attempt to bind linear elements with a single point",
);
// If none of the ends are dragged, we don't change anything
if (!startDragged && !endDragged) {
@@ -1027,6 +1053,7 @@ export const bindBindingElement = (
scene: Scene,
focusPoint?: GlobalPoint,
shouldSnapToOutline = true,
isMidpointSnappingEnabled = true,
): void => {
const elementsMap = scene.getNonDeletedElementsMap();
@@ -1042,6 +1069,7 @@ export const bindBindingElement = (
startOrEnd,
elementsMap,
shouldSnapToOutline,
isMidpointSnappingEnabled,
),
};
} else {
@@ -1746,6 +1774,92 @@ const extractBinding = (
};
};
/**
* Snaps a bound arrow endpoint to the grid on the axis parallel to the
* bindable element's side, while preserving the binding gap distance on the
* perpendicular axis. In other words, the grid axis closest to the side's
* perpendicular (normal) is used as the snap axis and the other axis is kept at
* the binding gap distance.
*/
const snapBoundPointToGrid = (
outlinePoint: GlobalPoint,
bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
gridSize: NullableGridSize,
arrowElement: ExcalidrawArrowElement,
adjacentPoint?: GlobalPoint,
): GlobalPoint => {
if (!gridSize) {
return outlinePoint;
}
const aabb = aabbForElement(bindableElement, elementsMap);
// For ellipses and diamonds use the arrow's incoming direction instead of
// the position-based heading, which can give the wrong axis when the
// outline point is near a cardinal zone or an angled diamond face.
const heading =
adjacentPoint &&
(bindableElement.type === "ellipse" || bindableElement.type === "diamond")
? vectorToHeading(vectorFromPoint(adjacentPoint, outlinePoint))
: headingForPointFromElement(bindableElement, aabb, outlinePoint);
const normalLocal = pointFrom<GlobalPoint>(heading[0], heading[1]);
const normalGlobal = pointRotateRads(
normalLocal,
pointFrom<GlobalPoint>(0, 0),
bindableElement.angle,
);
const bindingGap = getBindingGap(bindableElement, arrowElement);
const extent =
Math.max(bindableElement.width, bindableElement.height) + bindingGap * 2;
const center = getCenterForBounds(aabb);
const absNX = Math.abs(normalGlobal[0]);
const absNY = Math.abs(normalGlobal[1]);
if (absNX >= absNY) {
// Global X is closest to the perpendicular so snap Y, intersect horizontal line
const [, snappedY] = getGridPoint(
outlinePoint[0],
outlinePoint[1],
gridSize,
);
const intersector = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(center[0] - extent, snappedY),
pointFrom<GlobalPoint>(center[0] + extent, snappedY),
);
const intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
bindingGap,
).sort(
(a, b) =>
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
)[0];
return intersection ?? pointFrom<GlobalPoint>(outlinePoint[0], snappedY);
}
// Global Y is closest to the perpendicular so snap X, intersect vertical line
const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize);
const intersector = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(snappedX, center[1] - extent),
pointFrom<GlobalPoint>(snappedX, center[1] + extent),
);
const intersection = intersectElementWithLineSegment(
bindableElement,
elementsMap,
intersector,
bindingGap,
).sort(
(a, b) =>
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
)[0];
return intersection ?? pointFrom<GlobalPoint>(snappedX, outlinePoint[1]);
};
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
+8 -294
View File
@@ -1,4 +1,5 @@
import rough from "roughjs/bin/rough";
import {
arrayToMap,
type Bounds,
@@ -6,6 +7,7 @@ import {
rescalePoints,
sizeOf,
} from "@excalidraw/common";
import {
degreesToRadians,
lineSegment,
@@ -14,7 +16,9 @@ import {
pointFromArray,
pointRotateRads,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
import { pointsOnBezierCurves } from "points-on-curve";
import type {
@@ -25,7 +29,9 @@ import type {
LocalPoint,
Radians,
} from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Mutable } from "@excalidraw/common/utility-types";
import { generateRoughOptions } from "./shape";
@@ -35,20 +41,18 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
isExcalidrawElement,
} from "./typeChecks";
import { getElementShape } from "./shape";
import {
deconstructDiamondElement,
deconstructRectanguloidElement,
} from "./utils";
import { intersectElementWithLineSegment } from "./collision";
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
import type { Drawable, Op } from "roughjs/bin/core";
import type { Point as RoughPoint } from "roughjs/bin/geometry";
@@ -63,7 +67,6 @@ import type {
ExcalidrawRectanguloidElement,
ExcalidrawTextElementWithContainer,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
export type RectangleBox = {
@@ -1292,295 +1295,6 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
/**
* High level helper to get elements overlapping a bounding box.
* It can be used to get elements overlapping a selection box, for example.
*
*/
export const elementsOverlappingBBox = ({
elements,
elementsMap,
bounds,
type,
excludeElementsInFrames,
shouldIgnoreElementFromSelection,
}: {
elements: readonly NonDeletedExcalidrawElement[];
elementsMap?: ElementsMap;
bounds: Bounds | ExcalidrawElement;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds
**/
type: "contain" | "overlap";
excludeElementsInFrames?: boolean;
shouldIgnoreElementFromSelection?: (
element: NonDeletedExcalidrawElement,
) => boolean;
}) => {
if (!elementsMap) {
elementsMap = arrayToMap(elements) as ElementsMap;
}
const selectionBounds = isExcalidrawElement(bounds)
? getElementBounds(bounds, elementsMap)
: bounds;
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
for (const element of elements) {
if (shouldIgnoreElementFromSelection?.(element)) {
continue;
}
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
type === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (type === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (type === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
};
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
-2
View File
@@ -38,8 +38,6 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "arrow" ||
type === "line";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
+7 -6
View File
@@ -1,9 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import {
isPointWithinBounds,
pointFrom,
segmentsIntersectAt,
} from "@excalidraw/math";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import type {
AppClassProperties,
@@ -81,7 +78,7 @@ export function isElementIntersectingFrame(
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
segmentsIntersectAt(frameLineSegment, elementLineSegment),
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
@@ -569,6 +566,10 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
continue;
}
if (element.frameId && element.frameId !== frame.id) {
continue;
}
finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
+18 -4
View File
@@ -359,6 +359,7 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked = shouldRotateWithDiscreteAngle(event);
LinearElementEditor.movePoints(
element,
app.scene,
@@ -370,7 +371,10 @@ export class LinearElementEditor {
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
},
);
// Set the suggested binding from the updates if available
@@ -427,7 +431,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -554,6 +560,8 @@ export class LinearElementEditor {
linearElementEditor,
);
const angleLocked =
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
LinearElementEditor.movePoints(
element,
app.scene,
@@ -565,7 +573,10 @@ export class LinearElementEditor {
},
{
isBindingEnabled: app.state.isBindingEnabled,
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
isMidpointSnappingEnabled:
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
},
);
@@ -661,7 +672,9 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
app.state.isMidpointSnappingEnabled,
app.state.isMidpointSnappingEnabled &&
!angleLocked &&
!app.state.gridModeEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -2176,6 +2189,7 @@ const pointDraggingUpdates = (
newArrow: !!app.state.newElement,
angleLocked,
altKey,
gridSize: app.getEffectiveGridSize(),
},
);
-6
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN,
randomInteger,
randomId,
@@ -445,7 +444,6 @@ export const newFreeDrawElement = (
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -454,10 +452,6 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
};
};
+63 -50
View File
@@ -422,10 +422,10 @@ const drawElementOnCanvas = (
for (const shape of shapes) {
if (typeof shape === "string") {
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
@@ -555,10 +555,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save();
context.font = getFontString(element);
context.fillStyle = applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
);
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default
@@ -811,10 +811,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = applyDarkModeFilter(
FRAME_STYLE.strokeColor,
appState.theme === THEME.DARK,
);
context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
@@ -889,10 +889,8 @@ export const renderElement = (
case "embeddable": {
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
@@ -914,49 +912,64 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {
// Draw arrow directly as vector and clear label hole separately.
// This avoids temp-canvas bitmap blit which introduces resampling blur.
const tempCanvas = document.createElement("canvas");
const tempCanvasContext = tempCanvas.getContext("2d")!;
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
const padding = getCanvasPadding(element);
tempCanvas.width =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvas.height =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvasContext.translate(
tempCanvas.width / 2,
tempCanvas.height / 2,
);
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
// Shift the canvas to left most point of the arrow
shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1);
context.save();
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
tempCanvasContext.rotate(element.angle);
const tempRc = rough.canvas(tempCanvas);
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement,
elementsMap,
);
const holeX =
boundTextCx -
centerX -
boundTextElement.width / 2 -
BOUND_TEXT_PADDING;
const holeY =
boundTextCy -
centerY -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING;
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
const isTransparentHole =
"viewBackgroundColor" in appState &&
(appState.viewBackgroundColor === "transparent" ||
!appState.viewBackgroundColor);
if (!isTransparentHole) {
context.save();
context.fillStyle = applyDarkModeFilter(
renderConfig.canvasBackgroundColor,
renderConfig.theme === THEME.DARK,
);
context.fillRect(holeX, holeY, holeWidth, holeHeight);
context.restore();
} else {
context.clearRect(holeX, holeY, holeWidth, holeHeight);
}
// Clear the bound text area
tempCanvasContext.clearRect(
-boundTextElement.width / 2,
-boundTextElement.height / 2,
boundTextElement.width,
boundTextElement.height,
);
context.scale(1 / appState.exportScale, 1 / appState.exportScale);
context.drawImage(
tempCanvas,
-tempCanvas.width / 2,
-tempCanvas.height / 2,
tempCanvas.width,
tempCanvas.height,
);
} else {
context.rotate(element.angle);
+279 -10
View File
@@ -1,4 +1,10 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type {
AppState,
@@ -6,18 +12,33 @@ import type {
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
import { getFrameChildren } from "./frame";
import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
@@ -86,15 +107,263 @@ export const getElementsWithinSelection = (
selectionX2,
selectionY2,
] as Bounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
return elementsOverlappingBBox({
elements,
bounds: selectionBounds,
elementsMap,
type: boxSelectionMode,
shouldIgnoreElementFromSelection,
excludeElementsInFrames,
});
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) {
continue;
}
// Track only selectable top-level group members, so ignored elements such
// as bound text and locked elements don't affect group selection.
const groupId = element.groupIds.at(-1);
if (groupId) {
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(element);
}
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
Math.max(elementAABB[0], frameAABB[0]),
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB
? ([
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.add(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.add(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
if (boxSelectionMode === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
});
} else if (boxSelectionMode === "contain") {
elementsInSelection.forEach((element) => {
// note: currently we only support top-level group handling since
// we don't support box selecting while editing the group/subgroup
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (
group &&
!group.every((groupElement) => elementsInSelection.has(groupElement))
) {
elementsInSelection.delete(element);
}
});
}
// to maintain original order elements (namely for group selection)
return elements.filter((element) => elementsInSelection.has(element));
};
export const getVisibleAndNonSelectedElements = (
+22 -77
View File
@@ -1,6 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
type GeometricShape,
@@ -25,7 +24,6 @@ import {
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -220,7 +218,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element),
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist,
};
@@ -234,7 +234,9 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor)
? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
if (element.type === "ellipse") {
options.curveFitting = 1;
}
@@ -247,7 +249,9 @@ export const generateRoughOptions = (
options.fill =
element.backgroundColor === "transparent"
? undefined
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor;
}
return options;
}
@@ -382,11 +386,12 @@ const getArrowheadShapes = (
return [];
}
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
const backgroundFillColor = applyDarkModeFilter(
canvasBackgroundColor,
isDarkMode,
);
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const backgroundFillColor = isDarkMode
? applyDarkModeFilter(canvasBackgroundColor)
: canvasBackgroundColor;
const cardinalityOneOrManyOffset = -0.25;
const cardinalityZeroCircleScale = 0.8;
@@ -1173,87 +1178,27 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString;
};
/**
* Freedraw stroke geometry tuning constants.
*
* These factors are not derived analytically — they were tuned empirically by
* visually comparing rendered strokes until they matched the desired feel.
* Treat them as magic numbers backed by visual verification.
*/
const VARIABLE_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
* perfect-freehand. This is the original Excalidraw freedraw look.
*/
const getVariableWidthFreedrawOutline = (
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING,
streamline: getFreedrawStreamline(element),
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
new LaserPointer({
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
simplify: 0,
sizeMapping: (details) => Math.max(0.1, details.pressure),
});
/**
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
*/
const getConstantWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const laserPointer = createLaserPointer(element);
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
return laserPointer
.getStrokeOutline()
.map(([x, y]) => [x, y] as [number, number]);
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
// Unknown/absent variability falls back to the original variable rendering.
return element.strokeOptions?.variability === "constant"
? getConstantWidthFreedrawOutline(element)
: getVariableWidthFreedrawOutline(element);
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
-8
View File
@@ -384,20 +384,12 @@ export type ExcalidrawElbowArrowElement = Merge<
}
>;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>;
export type FileId = string & { _brand: "FileId" };
+1 -1
View File
@@ -5,7 +5,6 @@ import {
pointFrom,
type GlobalPoint,
type LocalPoint,
type LineSegment,
} from "@excalidraw/math";
import { type Bounds, isBounds } from "@excalidraw/common";
import {
@@ -18,6 +17,7 @@ import {
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Curve } from "@excalidraw/math";
import type { LineSegment } from "@excalidraw/utils";
// The global data holder to collect the debug operations
declare global {
+3 -69
View File
@@ -1,7 +1,5 @@
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
import { isFiniteNumber } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { GlobalPoint } from "@excalidraw/math";
@@ -315,46 +313,12 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, new Map<string, ExcalidrawElement>());
};
const hasSameElementIds = (
prevElements: readonly ExcalidrawElement[],
nextElements: readonly ExcalidrawElement[],
) => {
if (prevElements.length !== nextElements.length) {
console.error(
"z-index reordering failed: resulting array have different lengths",
);
return false;
}
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
for (const element of prevElements) {
prevElementIdCounts.set(
element.id,
(prevElementIdCounts.get(element.id) || 0) + 1,
);
}
for (const element of nextElements) {
const count = prevElementIdCounts.get(element.id);
if (!count) {
console.error(
"z-index reordering failed: element id mismatch / duplicate ids",
);
return false;
}
prevElementIdCounts.set(element.id, count - 1);
}
return true;
};
const shiftElementsByOne = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
scene: Scene,
) => {
const originalElements = elements;
const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
@@ -425,10 +389,6 @@ const shiftElementsByOne = (
];
});
if (!hasSameElementIds(originalElements, elements)) {
return originalElements;
}
syncMovedIndices(elements, targetElementsMap);
return elements;
@@ -442,20 +402,11 @@ const shiftElementsToEnd = (
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
// elements were frame children handled in a prior pass). Bail out early —
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
// the resulting `slice()` calls overlap, duplicating elements.
if (indicesToMove.length === 0) {
return elements;
}
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number | undefined;
let trailingIndex: number | undefined;
let leadingIndex: number;
let trailingIndex: number;
if (direction === "left") {
if (containingFrame) {
leadingIndex = findIndex(elements, (el) =>
@@ -500,19 +451,6 @@ const shiftElementsToEnd = (
leadingIndex = 0;
}
const isValidIndex = (index: number | undefined): index is number => {
return isFiniteNumber(index) && index >= 0;
};
if (
!isValidIndex(leadingIndex) ||
!isValidIndex(trailingIndex) ||
leadingIndex > trailingIndex ||
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
) {
return elements;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]);
@@ -537,10 +475,6 @@ const shiftElementsToEnd = (
...trailingElements,
];
if (!hasSameElementIds(elements, nextElements)) {
return elements;
}
syncMovedIndices(nextElements, targetElementsMap);
return nextElements;
@@ -609,7 +543,7 @@ function shiftElementsAccountingForFrames(
for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction(
nextElements,
allElements,
appState,
direction,
frameId,
-60
View File
@@ -178,64 +178,6 @@ describe("binding for simple arrows", () => {
});
});
describe("self-binding (both ends to the same element) single-click finalize", () => {
// rect spans x:200..400, y:200..400; orbit ring is ~15px outside the outline
const INSIDE: [number, number] = [250, 250];
const ORBIT_LEFT: [number, number] = [187, 300];
const ORBIT_RIGHT: [number, number] = [413, 300];
const MIDDLE: [number, number] = [550, 100];
beforeEach(async () => {
mouse.reset();
await act(() => setLanguage(defaultLang));
await render(<Excalidraw handleKeyboardGlobally={true} />);
UI.createElement("rectangle", {
x: 200,
y: 200,
width: 200,
height: 200,
});
});
const drawSelfArrow = (start: [number, number], end: [number, number]) => {
UI.clickTool("arrow");
mouse.reset();
mouse.clickAt(...start);
mouse.moveTo(...MIDDLE);
mouse.clickAt(...MIDDLE); // commit a middle point so it's a multi-point arrow
mouse.moveTo(...end);
mouse.clickAt(...end); // single click at the end
};
it("orbit -> orbit finalizes on a single click", () => {
drawSelfArrow(ORBIT_LEFT, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> orbit finalizes on a single click", () => {
drawSelfArrow(INSIDE, ORBIT_RIGHT);
const arrow = h.elements[h.elements.length - 1] as ExcalidrawArrowElement;
expect(h.state.multiElement).toBe(null);
expect(h.state.activeTool.type).toBe("selection");
expect(arrow.startBinding?.elementId).toBe(arrow.endBinding?.elementId);
expect(arrow.endBinding?.elementId).not.toBe(undefined);
});
it("inside -> inside keep in multi-point mode (no single-click finalize)", () => {
drawSelfArrow(INSIDE, [INSIDE[0] + 50, INSIDE[1] + 50]); // end dropped inside the rect
// ambiguous → must be confirmed with a second click, so still in progress
expect(h.state.multiElement).not.toBe(null);
expect(h.state.activeTool.type).toBe("arrow");
});
});
describe("when arrow is outside of shape", () => {
beforeEach(async () => {
mouse.reset();
@@ -461,7 +403,6 @@ describe("binding for simple arrows", () => {
mouse.moveTo(340, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = h.elements[h.elements.length - 1] as any;
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
@@ -506,7 +447,6 @@ describe("binding for simple arrows", () => {
mouse.moveTo(350, 251);
mouse.moveTo(410, 251);
mouse.clickAt(410, 251);
mouse.clickAt(410, 251);
const arrow = API.getSelectedElement() as ExcalidrawArrowElement;
+3 -69
View File
@@ -1,14 +1,10 @@
import { pointFrom } from "@excalidraw/math";
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
import type { LocalPoint } from "@excalidraw/math";
import {
elementsOverlappingBBox,
getElementAbsoluteCoords,
getElementBounds,
} from "../src/bounds";
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
@@ -145,65 +141,3 @@ describe("getElementBounds", () => {
expect(y2).toEqual(319.8162855827246);
});
});
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 85, 85);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
});
-28
View File
@@ -692,34 +692,6 @@ describe("adding elements to frames", () => {
expect(rect2.frameId).toBe(frame.id);
});
it("should move an element dragged from one frame into another", () => {
const otherFrame = API.createElement({
id: "otherFrame",
type: "frame",
x: 300,
y: 0,
width: 150,
height: 150,
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 50,
y: 50,
width: 20,
height: 20,
frameId: frame.id,
});
API.setElements([frame, frameChild, otherFrame]);
expect(frameChild.frameId).toBe(frame.id);
dragElementIntoFrame(otherFrame, frameChild);
expect(frameChild.frameId).toBe(otherFrame.id);
});
it("should layer a dragged element above the highest frame child", () => {
const frameChild = API.createElement({
id: "frameChild",
-186
View File
@@ -1509,190 +1509,4 @@ describe("z-indexing with frames", () => {
],
});
});
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", isSelected: true },
{ id: "F2_2", frameId: "F2" },
{ id: "F2", type: "frame" },
],
operations: [
// +∞: each selected child moves to the front of its own frame
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
// -∞: each selected child moves to the back of its own frame
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
],
});
});
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
{ id: "F1", type: "frame" },
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
{ id: "F2", type: "frame" },
],
appState: { editingGroupId: "g1" },
operations: [
// -∞ (send to back, within the frame)
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
// +∞ (bring to front, within the frame)
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
],
});
});
});
/**
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
* frame's children — and a group's members — are contiguous in the elements
* array. Such states shouldn't occur in normal use, but they CAN arise from
* bugs or broken input, because nothing re-defragments element order during
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
* tests so the reordering ops stay exercised against malformed order.
*
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
*
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
* contract for invalid input — it's whatever the slice math happens to
* produce. If a future change alters an `expected` order below, that is NOT
* necessarily a functional regression. First confirm from the diff that the
* hard contract still holds (nothing duplicated/lost), then update the
* expected order to match, provided it's deemed an improvement over the
* previous order, or it's an acceptable change given the underlying logic
* change.
*/
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
const assertReorderPreservesElements = (
elements: Parameters<typeof populateElements>[0],
appState: Parameters<typeof populateElements>[1],
// each op is applied to a freshly-populated (broken) state
cases: [Actions, string[]][],
) => {
for (const [action, expected] of cases) {
populateElements(elements, appState);
const before = h.elements.map((el) => el.id);
expect(() => API.executeAction(action)).not.toThrow();
const after = h.elements.map((el) => el.id);
// hard contract:
expect(after.length).toBe(before.length); // no loss
expect(new Set(after).size).toBe(after.length); // no duplication
// soft snapshot (see block comment before changing):
expect(after).toEqual(expected);
}
};
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
// never a duplication/loss, and the op does not throw.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F2_1", frameId: "F2" },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "F2", type: "frame" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
]);
});
it("discontiguous group, whole group selected", () => {
// g1 = {A, C}, scattered by the loose elements B and D.
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, undefined, [
// move-by-one leaves the group scattered (each run moves independently)
[actionBringForward, ["B", "A", "D", "C"]],
[actionSendBackward, ["A", "C", "B", "D"]],
// to-front / to-back gather the scattered members back into one block
[actionBringToFront, ["B", "D", "A", "C"]],
[actionSendToBack, ["A", "C", "B", "D"]],
]);
});
it("discontiguous group, single member selected in group-editing mode", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"] },
{ id: "B" },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "D" },
];
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
[actionBringForward, ["A", "B", "C", "D"]],
[actionSendBackward, ["C", "A", "B", "D"]],
[actionBringToFront, ["A", "B", "C", "D"]],
[actionSendToBack, ["C", "A", "B", "D"]],
]);
});
it("two interleaved groups, both fully selected", () => {
const elements: Parameters<typeof populateElements>[0] = [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "C", groupIds: ["g1"], isSelected: true },
{ id: "Y", groupIds: ["g2"], isSelected: true },
{ id: "Z" },
];
assertReorderPreservesElements(elements, undefined, [
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
]);
});
});
describe("z-index reordering with inconsistent group-editing state", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"], isSelected: true },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"] },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g2" },
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
});
assertZindex({
elements: [
{ id: "A", groupIds: ["g1"] },
{ id: "C", groupIds: ["g1"] },
{ id: "X", groupIds: ["g2"], isSelected: true },
{ id: "Y", groupIds: ["g2"] },
{ id: "R" },
],
appState: { editingGroupId: "g1" },
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
});
});
});
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
-7
View File
@@ -17,13 +17,6 @@ Please add the latest change on the top under the correct section.
### Breaking changes
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
- `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `<Excalidraw />` instead.
- `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`.
- `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below).
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN,
arrayToMap,
getFontString,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
getOriginalContainerHeightFromCache,
@@ -250,10 +249,7 @@ export const actionWrapTextInContainer = register({
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: getStrokeWidthByKey(
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
+4 -15
View File
@@ -477,28 +477,17 @@ export const actionToggleTheme = register<AppState["theme"]>({
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value, app) => {
const nextTheme =
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
if (app.props.onThemeChange) {
app.props.onThemeChange(nextTheme);
return false;
}
perform: (_, appState, value) => {
return {
appState: {
...appState,
theme: nextTheme,
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D,
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
predicate: (elements, appState, props, app) => {
return !!app.props.UIOptions.canvasActions.toggleTheme;
},
+21 -51
View File
@@ -27,7 +27,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -54,7 +54,6 @@ export const actionFinalize = register<FormData>({
label: "",
trackEvent: false,
perform: (elements, appState, data, app) => {
let shouldCommit = true;
let newElements = elements;
const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap();
@@ -94,32 +93,40 @@ export const actionFinalize = register<FormData>({
? [element.points.length - 1] // New arrow creation
: appState.selectedLinearElement.selectedPointsIndices;
const angleLocked = shouldRotateWithDiscreteAngle(event);
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
? null
: app.getEffectiveGridSize();
const draggedPoints: PointsPositionUpdates =
selectedPointsIndices.reduce((map, index) => {
map.set(index, {
point: LinearElementEditor.pointFromAbsoluteCoords(
element,
pointFrom<GlobalPoint>(
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
),
elementsMap,
),
point: angleLocked
? element.points[index]
: LinearElementEditor.createPointAt(
element,
elementsMap,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
effectiveGridSize,
),
});
return map;
}, new Map()) ?? new Map();
bindOrUnbindBindingElement(
element,
draggedPoints,
sceneCoords.x - linearElementEditor.pointerOffset.x,
sceneCoords.y - linearElementEditor.pointerOffset.y,
sceneCoords.x,
sceneCoords.y,
scene,
appState,
{
newArrow,
altKey: event.altKey,
angleLocked: shouldRotateWithDiscreteAngle(event),
angleLocked,
gridSize: app.getEffectiveGridSize(),
},
);
} else if (isLineElement(element)) {
@@ -223,44 +230,9 @@ export const actionFinalize = register<FormData>({
!lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint
) {
shouldCommit = false;
scene.mutateElement(element, {
points: element.points.slice(0, -1),
});
if (
isBindingElement(element) &&
element.endBinding &&
// after slicing the trailing point a <2-point arrow may be left
element.points.length > 1
) {
const newArrow = !!appState.newElement;
const draggedPoints: PointsPositionUpdates = new Map([
[
element.points.length - 1,
{
point: element.points[element.points.length - 1],
isDragging: false,
},
],
]);
const globalPoint =
LinearElementEditor.getPointAtIndexGlobalCoordinates(
element,
-1,
elementsMap,
);
bindOrUnbindBindingElement(
element,
draggedPoints,
globalPoint[0],
globalPoint[1],
scene,
appState,
{
newArrow,
},
);
}
}
}
@@ -380,9 +352,7 @@ export const actionFinalize = register<FormData>({
selectedLinearElement,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: shouldCommit
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.NEVER,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState) =>
@@ -1,9 +1,8 @@
import { fireEvent, queryByTestId } from "@testing-library/react";
import { queryByTestId } from "@testing-library/react";
import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY,
STROKE_WIDTH,
} from "@excalidraw/common";
@@ -129,62 +128,6 @@ describe("element locking", () => {
expect(thinStrokeWidthButton).toBeChecked();
});
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
});
it("should apply stroke width by element type", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
const boldStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-bold`,
);
expect(boldStrokeWidthButton).not.toBe(null);
fireEvent.click(boldStrokeWidthButton!);
const selectedElements = API.getSelectedElements();
const selectedRect = selectedElements.find(
(element) => element.type === "rectangle",
);
const selectedFreedraw = selectedElements.find(
(element) => element.type === "freedraw",
);
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should create new elements with stroke width by element type", () => {
API.setAppState({ currentItemStrokeWidthKey: "bold" });
const rect = API.createElement({ type: "rectangle" });
const freedraw = API.createElement({ type: "freedraw" });
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -192,7 +135,7 @@ describe("element locking", () => {
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
strokeWidth: STROKE_WIDTH.bold,
});
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
@@ -202,17 +145,17 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-medium`),
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
@@ -221,7 +164,7 @@ describe("element locking", () => {
API.setElements([rect, text]);
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
+19 -128
View File
@@ -12,7 +12,7 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH_KEYS,
STROKE_WIDTH,
VERTICAL_ALIGN,
KEYS,
randomInteger,
@@ -20,11 +20,9 @@ import {
getFontFamilyString,
getLineHeight,
isTransparent,
getStrokeWidthByKey,
reduceToCommonValue,
invariant,
FONT_SIZES,
type StrokeWidthKey,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -72,11 +70,9 @@ import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
StrokeVariability,
TextAlign,
VerticalAlign,
} from "@excalidraw/element/types";
@@ -87,7 +83,6 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker";
@@ -136,8 +131,6 @@ import {
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
strokeVariabilityConstantIcon,
strokeVariabilityVariableIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -197,11 +190,7 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
/**
* input value (usually the element attribute value,
* but depends on what the action's PanelComponent input expects)
*/
getValue: (element: ExcalidrawElement) => T,
getAttribute: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
@@ -211,7 +200,7 @@ export const getFormValue = function <T extends Primitive>(
let ret: T | null = null;
if (editingTextElement) {
ret = getValue(editingTextElement);
ret = getAttribute(editingTextElement);
}
if (!ret) {
@@ -225,7 +214,7 @@ export const getFormValue = function <T extends Primitive>(
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getValue) ??
reduceToCommonValue(targetElements, getAttribute) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
@@ -555,37 +544,20 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
},
});
const getStrokeWidthKeyForElement = (
element: ExcalidrawElement,
): StrokeWidthKey | null => {
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
invariant(value, "actionChangeStrokeWidth: value must be defined");
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value),
strokeWidth: value,
}),
),
appState: { ...appState, currentItemStrokeWidthKey: value },
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
@@ -593,35 +565,35 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList">
<RadioSelection<StrokeWidthKey>
<RadioSelection
group="stroke-width"
options={[
{
value: "thin",
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: "medium",
text: t("labels.medium"),
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-medium",
testId: "strokeWidth-bold",
},
{
value: "bold",
text: t("labels.bold"),
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-bold",
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
app,
getStrokeWidthKeyForElement,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidthKey,
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
@@ -684,87 +656,6 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
),
});
export const actionChangeFreedrawMode = register<StrokeVariability>({
name: "changeFreedrawMode",
label: "labels.pressure",
trackEvent: false,
perform: (elements, appState, value) => {
const variability = value || "constant";
return {
elements: changeProperty(elements, appState, (el) => {
if (el.type !== "freedraw") {
return el;
}
return newElementWith(el, {
strokeOptions: {
...el.strokeOptions,
variability,
},
}) as ExcalidrawElement;
}),
appState: { ...appState, currentItemStrokeVariability: variability },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const strokeVariability =
getFormValue(
elements,
app,
(element) =>
(element as ExcalidrawFreeDrawElement).strokeOptions?.variability,
(element) => element.type === "freedraw",
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeVariability,
) ?? appState.currentItemStrokeVariability;
// in the compact UI the pressure setting is rendered as a single button
// that cycles between the two variability modes on click
if (data?.cycle) {
const isVariable = strokeVariability === "variable";
return (
<ToolButton
type="button"
icon={
isVariable
? strokeVariabilityVariableIcon
: strokeVariabilityConstantIcon
}
title={t("labels.pressure")}
aria-label={t("labels.pressure")}
onClick={() => updateData(isVariable ? "constant" : "variable")}
/>
);
}
return (
<fieldset>
<legend>{t("labels.pressure")}</legend>
<div className="buttonList">
<RadioSelection<StrokeVariability>
group="strokeOptions.variability"
options={[
{
value: "constant",
text: t("labels.pressure_constant"),
icon: strokeVariabilityConstantIcon,
},
{
value: "variable",
text: t("labels.pressure_variable"),
icon: strokeVariabilityVariableIcon,
},
]}
value={strokeVariability}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"]
>({
-1
View File
@@ -13,7 +13,6 @@ export {
actionChangeStrokeWidth,
actionChangeFillStyle,
actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity,
actionChangeFontSize,
actionChangeFontFamily,
-1
View File
@@ -68,7 +68,6 @@ export type ActionName =
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
+2 -9
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
@@ -35,13 +34,12 @@ export const getDefaultAppState = (): Omit<
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStrokeVariability: "constant",
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
@@ -169,15 +167,10 @@ const APP_STATE_STORAGE_CONF = (<
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStrokeVariability: {
browser: true,
export: false,
server: false,
},
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
+11 -46
View File
@@ -41,7 +41,6 @@ import {
canHaveArrowheads,
getTargetElements,
hasBackground,
hasFreedrawMode,
hasStrokeStyle,
hasStrokeWidth,
} from "../scene";
@@ -202,9 +201,9 @@ export const SelectedShapeActions = ({
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) &&
renderAction("changeFreedrawMode")}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
@@ -395,17 +394,6 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{
/* in compact UI the freedraw pressure setting is rendered as a
standalone cycle button in the compact actions list; we render
it in the combined properties popup as well for clarity
*/
(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")
}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
@@ -838,14 +826,6 @@ export const CompactShapeActions = ({
</div>
)}
{/* Freedraw pressure: standalone button cycling the variability mode */}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) && (
<div className="compact-action-item">
{renderAction("changeFreedrawMode", { cycle: true })}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
@@ -1074,11 +1054,6 @@ export const ShapesSwitcher = ({
const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact";
// a pen detected on a tool button's pointer-down, to be applied (enabling
// pen mode) only after the tap's `change` has committed — see the tool
// button handlers below
const pendingPenDetectionRef = useRef(false);
const SELECTION_TOOLS = [
{
type: "selection",
@@ -1177,13 +1152,8 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
// Detect the pen here (pointerType is reliable on pointer-down)
// but DON'T enable pen mode yet: calling setState mid-gesture
// re-renders the controlled radio and, on iOS/iPadOS, aborts
// the ensuing click so the tool isn't selected on the first pen
// tap. Defer it until the tap's `change` has committed (below).
if (!app.state.penDetected && pointerType === "pen") {
pendingPenDetectionRef.current = true;
app.togglePenMode(true);
}
if (value === "selection") {
@@ -1194,21 +1164,16 @@ export const ShapesSwitcher = ({
}
}
}}
onChange={() => {
onChange={({ pointerType }) => {
if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
app.setActiveTool({ type: value });
// Apply the pen detection captured on pointer-down now that the
// tool is selected. rAF keeps the resulting re-render out of the
// `change` event itself. We rely on the pointer-down detection
// rather than this handler's pointerType because the latter is
// unreliable on iOS (its backing ref is cleared before the
// delayed click fires).
if (pendingPenDetectionRef.current) {
pendingPenDetectionRef.current = false;
requestAnimationFrame(() => app.togglePenMode(true));
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
+110 -111
View File
@@ -27,8 +27,6 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -111,7 +109,6 @@ import {
setDesktopUIMode,
isSelectionLikeTool,
oneOf,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
@@ -263,7 +260,6 @@ import {
getUncroppedWidthAndHeight,
getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -1999,10 +1995,9 @@ class App extends React.Component<AppProps, AppState> {
}
}}
style={{
background: applyDarkModeFilter(
this.state.viewBackgroundColor,
isDarkTheme,
),
background: isDarkTheme
? applyDarkModeFilter(this.state.viewBackgroundColor)
: this.state.viewBackgroundColor,
zIndex: 2,
border: "none",
display: "block",
@@ -4137,7 +4132,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roundness: null,
roughness: this.state.currentItemRoughness,
@@ -4308,9 +4303,6 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: force ?? !prevState.penMode,
penDetected: true,
currentItemStrokeVariability: !prevState.penDetected
? "variable"
: prevState.currentItemStrokeVariability,
};
});
};
@@ -6310,7 +6302,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -7119,7 +7111,45 @@ class App extends React.Component<AppProps, AppState> {
setCursorForShape(this.interactiveCanvas, this.state);
if (lastPoint === lastCommittedPoint) {
if (
const hoveredElement =
isArrowElement(this.state.newElement) &&
isBindingEnabled(this.state) &&
getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom),
);
if (hoveredElement) {
this.actionManager.executeAction(actionFinalize, "ui", {
event: event.nativeEvent,
sceneCoords: {
x: scenePointerX,
y: scenePointerY,
},
});
this.setState({ suggestedBinding: null });
if (!this.state.activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: this.state.preferredSelectionTool.type,
}),
selectedElementIds: makeNextSelectedElementIds(
{
...prevState.selectedElementIds,
[multiElement.id]: true,
},
prevState,
),
selectedLinearElement: new LinearElementEditor(
multiElement,
this.scene.getNonDeletedElementsMap(),
),
}));
}
} else if (
// if we haven't yet created a temp point and we're beyond commit-zone
// threshold, add a point
pointDistance(
@@ -7127,24 +7157,6 @@ class App extends React.Component<AppProps, AppState> {
lastPoint,
) >= LINE_CONFIRM_THRESHOLD
) {
this.store.scheduleCapture();
flushSync(() => {
invariant(
this.state.selectedLinearElement?.initialState,
"initialState must be set",
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
lastCommittedPoint: points[points.length - 1],
selectedPointsIndices: [multiElement.points.length],
initialState: {
...this.state.selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length,
},
},
});
});
this.scene.mutateElement(
multiElement,
{
@@ -7155,6 +7167,21 @@ class App extends React.Component<AppProps, AppState> {
},
{ informMutation: false, isDragging: false },
);
invariant(
this.state.selectedLinearElement?.initialState,
"initialState must be set",
);
this.setState({
selectedLinearElement: {
...this.state.selectedLinearElement,
lastCommittedPoint: points[points.length - 1],
selectedPointsIndices: [multiElement.points.length - 1],
initialState: {
...this.state.selectedLinearElement.initialState,
lastClickedPoint: multiElement.points.length - 1,
},
},
});
} else {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
// in this branch, we're inside the commit zone, and no uncommitted
@@ -7236,14 +7263,16 @@ class App extends React.Component<AppProps, AppState> {
return;
}
if (this.state.activeTool.type === "arrow") {
// Set suggested binding if we're hovering with an arrow tool
// and not dragging out a new element
if (this.state.activeTool.type === "arrow" && !this.state.newElement) {
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const hit = getHoveredElementForBinding(
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
scenePointer,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
maxBindingDistance_simple(this.state.zoom),
);
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
const elementsMap = this.scene.getNonDeletedElementsMap();
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
this.setState({
@@ -7780,7 +7809,6 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: true,
penDetected: true,
currentItemStrokeVariability: "variable",
};
});
}
@@ -8999,8 +9027,6 @@ class App extends React.Component<AppProps, AppState> {
const simulatePressure = event.pressure === 0.5;
const strokeVariability = this.state.currentItemStrokeVariability;
const element = newFreeDrawElement({
type: elementType,
x: gridX,
@@ -9008,24 +9034,15 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("freedraw"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness: null,
simulatePressure,
strokeOptions: {
variability: strokeVariability,
streamline:
event.pointerType !== "mouse"
? DEFAULT_STROKE_STREAMLINE_PRECISE
: DEFAULT_STROKE_STREAMLINE,
},
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [pointFrom<LocalPoint>(0, 0)],
// pressures are only consumed when rendering a real-pressure stroke, so
// skip persisting them while pressure is being simulated
pressures: simulatePressure ? [] : [event.pressure],
});
@@ -9076,7 +9093,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("iframe"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("iframe"),
@@ -9129,7 +9146,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("embeddable"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("embeddable"),
@@ -9176,7 +9193,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("image"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: null,
@@ -9248,58 +9265,32 @@ class App extends React.Component<AppProps, AppState> {
const { x: rx, y: ry } = multiElement;
const { lastCommittedPoint } = selectedLinearElement;
const sceneCoords = viewportCoordsToSceneCoords(event, this.state);
const { start, end } =
isBindingElement(multiElement) && isBindingEnabled(this.state)
? getBindingStrategyForDraggingBindingElementEndpoints(
multiElement,
new Map([
[
multiElement.points.length - 1,
{
point: multiElement.points[multiElement.points.length - 1],
isDragging: false,
},
],
]),
sceneCoords.x,
sceneCoords.y,
this.scene.getNonDeletedElementsMap(),
this.scene.getNonDeletedElements(),
this.state,
{
newArrow: Boolean(this.state.newElement),
zoom: this.state.zoom,
},
)
: { end: { mode: undefined } };
const elementsMap = this.scene.getNonDeletedElementsMap();
// Auto-confirm when both ends bind to the SAME element and the end point
// lands on the outline rather than inside it
const endOutsideSameElement =
start?.mode != null &&
end.mode != null &&
start.element.id === end.element.id &&
!isPointInElement(end.focusPoint, end.element, elementsMap);
const boundOutsideFromElsewhere =
end.mode === "orbit" &&
multiElement.startBinding?.elementId !== end.element?.id;
const lastCommittedPointIsInsideCommitZone =
lastCommittedPoint &&
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
const hoveredElementForBinding =
isBindingEnabled(this.state) &&
getHoveredElementForBinding(
pointFrom<GlobalPoint>(
this.lastPointerMoveCoords?.x ??
rx + multiElement.points[multiElement.points.length - 1][0],
this.lastPointerMoveCoords?.y ??
ry + multiElement.points[multiElement.points.length - 1][1],
),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD;
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
// clicking inside commit zone → finalize arrow
if (
boundOutsideFromElsewhere || // Outside -> orbit: Bind immediately
endOutsideSameElement || // End outside the start's element: Bind immediately
(multiElement.points.length > 1 && lastCommittedPointIsInsideCommitZone)
(isBindingElement(multiElement) && hoveredElementForBinding) ||
(multiElement.points.length > 1 &&
lastCommittedPoint &&
pointDistance(
pointFrom(
pointerDownState.origin.x - rx,
pointerDownState.origin.y - ry,
),
lastCommittedPoint,
) < LINE_CONFIRM_THRESHOLD)
) {
this.actionManager.executeAction(actionFinalize, "ui", {
event: event.nativeEvent,
@@ -9354,7 +9345,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9381,7 +9372,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9518,13 +9509,6 @@ class App extends React.Component<AppProps, AppState> {
: null;
}
private getCurrentItemStrokeWidth(elementType: ExcalidrawElement["type"]) {
return getStrokeWidthByKey(
elementType,
this.state.currentItemStrokeWidthKey,
);
}
private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"] | "embeddable",
pointerDownState: PointerDownState,
@@ -9548,7 +9532,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -10881,6 +10865,13 @@ class App extends React.Component<AppProps, AppState> {
}
if (isLinearElement(newElement)) {
if (
newElement!.points.length > 1 &&
newElement.points[1][0] !== 0 &&
newElement.points[1][1] !== 0
) {
this.store.scheduleCapture();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
this.state,
@@ -10918,15 +10909,23 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionFinalize);
} else {
// Movement out of commit area will create the point
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
this.scene.mutateElement(
newElement,
{
points: [newElement.points[0], pointFrom<LocalPoint>(dx, dy)],
},
{ informMutation: false, isDragging: false },
);
this.setState({
multiElement: newElement,
newElement,
});
}
} else if (pointerDownState.drag.hasOccurred && !multiElement) {
this.store.scheduleCapture();
if (isLinearElement(newElement)) {
this.actionManager.executeAction(actionFinalize, "ui", {
event: childEvent,
@@ -19,7 +19,6 @@ import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
actionToggleTheme,
} from "../../actions";
import {
actionCopyElementLink,
@@ -425,7 +424,6 @@ function CommandPaletteInner({
];
const additionalCommands: CommandPaletteItem[] = [
actionToCommand(actionToggleTheme, DEFAULT_CATEGORIES.app),
{
label: t("toolBar.library"),
category: DEFAULT_CATEGORIES.app,
@@ -1 +1,12 @@
export {};
import { actionToggleTheme } from "../../actions";
import type { CommandPaletteItem } from "./types";
export const toggleTheme: CommandPaletteItem = {
...actionToggleTheme,
category: "App",
label: "Toggle theme",
perform: ({ actionManager }) => {
actionManager.executeAction(actionToggleTheme, "commandPalette");
},
};
@@ -831,13 +831,14 @@ const convertElementType = <
newElement({
...element,
type: targetType,
roundness: element.roundness
? {
type: isUsingAdaptiveRadius(targetType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: element.roundness,
roundness:
targetType === "diamond" && element.roundness
? {
type: isUsingAdaptiveRadius(targetType)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
}
: element.roundness,
}),
) as typeof element;
@@ -4,13 +4,11 @@ import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
import { KEYS } from "@excalidraw/common";
import { actionToggleTheme } from "../actions";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
import { probablySupportsClipboardBlob } from "../clipboard";
import { t } from "../i18n";
import { getShortcutKey } from "../shortcut";
import { useExcalidrawActionManager } from "./App";
import { Dialog } from "./Dialog";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
@@ -126,7 +124,6 @@ const ShortcutKey = (props: { children: React.ReactNode }) => (
);
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const actionManager = useExcalidrawActionManager();
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
@@ -305,12 +302,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
{actionManager.isActionEnabled(actionToggleTheme) && (
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
)}
<Shortcut
label={t("labels.toggleTheme")}
shortcuts={[getShortcutKey("Alt+Shift+D")]}
/>
<Shortcut
label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]}
@@ -7,6 +7,7 @@
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
@@ -120,24 +120,6 @@
}
}
// on tablet, the pen mode button is rendered as a separate floating button
// below the compact actions menu (see LayerUI.tsx)
.App-menu_top__left > .ToolIcon__penMode {
justify-self: center;
.ToolIcon__icon {
width: var(--lg-button-size);
height: var(--lg-button-size);
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
}
// no shadow while pen mode is active (the active fill is enough)
.ToolIcon_type_checkbox:checked + .ToolIcon__icon {
box-shadow: none;
}
}
.disable-view-mode {
display: flex;
justify-content: center;
+11 -31
View File
@@ -122,7 +122,7 @@ const DefaultMainMenu: React.FC<{
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ChangeCanvasBackground />
</MainMenu>
);
@@ -235,6 +235,8 @@ const LayerUI = ({
);
const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel;
return (
<Section
heading="selectedShapeActions"
@@ -242,7 +244,7 @@ const LayerUI = ({
"transition-left": appState.zenModeEnabled,
})}
>
{isCompactStylesPanel ? (
{isCompactMode ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
@@ -310,23 +312,6 @@ const LayerUI = ({
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
{/* in compact UI the pen mode button lives outside the toolbar, as
a separate floating button below the compact actions menu
(same as we render it on mobile); shown alongside the compact
actions island, i.e. when a drawing tool or elements are
selected */}
{isCompactStylesPanel &&
!appState.viewModeEnabled &&
shouldRenderSelectedShapeActions && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
)}
</Stack.Col>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
@@ -358,18 +343,13 @@ const LayerUI = ({
/>
{heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}>
{/* in compact UI the pen mode button is rendered
as a separate floating button below the compact
actions menu */}
{!isCompactStylesPanel && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
)}
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
@@ -29,7 +29,6 @@
gap: 2px;
&__choice {
box-sizing: content-box;
position: relative;
display: flex;
align-items: center;
@@ -51,11 +50,13 @@
user-select: none;
letter-spacing: 0.4px;
transition: all 75ms ease-out;
&:hover {
color: var(--RadioGroup-choice-color-off-hover);
}
&:not(.active):active {
&:active {
background: var(--RadioGroup-choice-background-off-active);
}
@@ -253,6 +253,7 @@ const getRelevantAppStateProps = (
newElement: appState.newElement,
isBindingEnabled: appState.isBindingEnabled,
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
gridModeEnabled: appState.gridModeEnabled,
suggestedBinding: appState.suggestedBinding,
isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight,
-68
View File
@@ -1249,74 +1249,6 @@ export const SloppinessCartoonistIcon = createIcon(
modifiedTablerIconProps,
);
export const strokeVariabilityConstantIcon = createIcon(
<g>
<path
d="M4 12 C 5 8, 6 8, 8 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 12 C 9 16, 10 16, 12 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 12 C 14 8, 15 8, 16 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 12 C 17 16, 18 16, 19 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
tablerIconProps,
);
export const strokeVariabilityVariableIcon = createIcon(
<g>
<path
d="M4 12 C 5 8, 6 8, 8 12"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 12 C 9 16, 10 16, 12 12"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 12 C 14 8, 15 8, 16 12"
fill="none"
strokeWidth="2.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 12 C 17 16, 18 16, 19 12"
fill="none"
strokeWidth="3.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
tablerIconProps,
);
export const EdgeSharpIcon = createIcon(
<svg strokeWidth="1.5">
<path d="M3.33334 9.99998V6.66665C3.33334 6.04326 3.33403 4.9332 3.33539 3.33646C4.95233 3.33436 6.06276 3.33331 6.66668 3.33331H10" />
@@ -232,22 +232,18 @@ export const ToggleTheme = (
props:
| {
allowSystemTheme: true;
/**
* Controls the theme of this UI component only.
* You should subscribe to `props.onThemeChange` and control the theme
* upstream.
*/
theme: Theme | "system";
onSelect: (theme: Theme | "system") => void;
}
| {
allowSystemTheme: false;
allowSystemTheme?: false;
onSelect?: (theme: Theme) => void;
},
) => {
const { t } = useI18n();
const appState = useUIAppState();
const actionManager = useExcalidrawActionManager();
const shortcut = getShortcutFromShortcutName("toggleTheme");
const appProps = useAppProps();
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
@@ -258,16 +254,7 @@ export const ToggleTheme = (
<DropdownMenuItemContentRadio
name="theme"
value={props.theme}
onChange={(value: Theme | "system") => {
if (appProps.onThemeChange) {
appProps.onThemeChange(value);
return;
}
console.warn(
"MainMenu.DefaultItems.ToggleTheme: `<Excalidraw/> props.onThemeChange` must be defined to use system theme selection.",
);
}}
onChange={(value: Theme | "system") => props.onSelect(value)}
choices={[
{
value: THEME.LIGHT,
@@ -297,7 +284,13 @@ export const ToggleTheme = (
// do not close the menu when changing theme
event.preventDefault();
actionManager.executeAction(actionToggleTheme);
if (props?.onSelect) {
props.onSelect(
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
} else {
return actionManager.executeAction(actionToggleTheme);
}
}}
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
-50
View File
@@ -3,7 +3,6 @@ import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
DEFAULT_FONT_FAMILY,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
FONT_FAMILY,
@@ -19,9 +18,6 @@ import {
getSizeFromPoints,
normalizeLink,
getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS,
type StrokeWidthKey,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -74,7 +70,6 @@ import type {
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
StrokeVariability,
StrokeRoundness,
} from "@excalidraw/element/types";
@@ -193,43 +188,6 @@ export type RestoredDataState = {
files: BinaryFiles;
};
const ALLOWED_STROKE_VARIABILITIES = new Set<StrokeVariability>([
"constant",
"variable",
]);
const restoreStrokeVariability = (
variability: unknown,
defaultValue: StrokeVariability,
): StrokeVariability => {
return typeof variability === "string" &&
ALLOWED_STROKE_VARIABILITIES.has(variability as StrokeVariability)
? (variability as StrokeVariability)
: defaultValue;
};
const getStrokeWidthKey = (strokeWidth: unknown): StrokeWidthKey | null => {
return isFiniteNumber(strokeWidth)
? STROKE_WIDTH_KEYS.find((key) => STROKE_WIDTH[key] === strokeWidth) ?? null
: null;
};
const restoreFreedrawStrokeOptions = (
strokeOptions: unknown,
): { variability: StrokeVariability; streamline: number } => {
const options =
strokeOptions && typeof strokeOptions === "object"
? (strokeOptions as { variability?: unknown; streamline?: unknown })
: null;
return {
variability: restoreStrokeVariability(options?.variability, "variable"),
streamline: isFiniteNumber(options?.streamline)
? options?.streamline
: DEFAULT_STROKE_STREAMLINE,
};
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
return FONT_FAMILY[
@@ -525,7 +483,6 @@ export const restoreElement = (
return restoreElementWithProperties(element, {
points,
simulatePressure: element.simulatePressure,
strokeOptions: restoreFreedrawStrokeOptions(element.strokeOptions),
pressures,
});
}
@@ -1099,13 +1056,6 @@ export const restoreAppState = (
nextAppState.boxSelectionMode = boxSelectionMode;
}
// legacy
if ((appState as any).currentItemStrokeWidth !== undefined) {
nextAppState.currentItemStrokeWidthKey =
getStrokeWidthKey((appState as any).currentItemStrokeWidth) ??
defaultAppState.currentItemStrokeWidthKey;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
+6 -8
View File
@@ -7,10 +7,8 @@ import React, {
} from "react";
import {
applyDarkModeFilter,
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
getStrokeWidthByKey,
isShallowEqual,
} from "@excalidraw/common";
@@ -68,7 +66,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
const {
onExport,
onChange,
onThemeChange,
onIncrement,
initialData,
onExcalidrawAPI,
@@ -131,7 +128,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
if (
UIOptions.canvasActions.toggleTheme === null &&
(theme == null || onThemeChange)
typeof theme === "undefined"
) {
UIOptions.canvasActions.toggleTheme = true;
}
@@ -187,7 +184,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
<App
onExport={onExport}
onChange={onChange}
onThemeChange={onThemeChange}
onIncrement={onIncrement}
initialData={initialData}
onExcalidrawAPI={handleExcalidrawAPI}
@@ -401,7 +397,11 @@ export {
convertToExcalidrawElements,
} from "@excalidraw/element";
export { elementsOverlappingBBox } from "@excalidraw/element";
export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "@excalidraw/utils/withinBounds";
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
@@ -450,5 +450,3 @@ export function useExcalidrawStateValue(
// -----------------------------------------------------------------------------
export { _useOnAppStateChange as useOnExcalidrawStateChange };
export { applyDarkModeFilter, getStrokeWidthByKey };
-3
View File
@@ -35,9 +35,6 @@
"strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness",
"pressure": "Pressure",
"pressure_constant": "Constant",
"pressure_variable": "Variable",
"opacity": "Opacity",
"textAlign": "Text align",
"edges": "Edges",
+4 -4
View File
@@ -62,10 +62,10 @@ export const bootstrapCanvas = ({
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillStyle =
theme === THEME.DARK
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
@@ -17,6 +17,7 @@ import {
FRAME_STYLE,
getFeatureFlag,
invariant,
shouldRotateWithDiscreteAngle,
THEME,
} from "@excalidraw/common";
@@ -229,6 +230,7 @@ const renderBindingHighlightForBindableElement_simple = (
elementsMap: ElementsMap,
appState: InteractiveCanvasAppState,
pointerCoords: GlobalPoint | null,
angleLocked = false,
) => {
const enclosingFrame =
suggestedBinding.element.frameId &&
@@ -415,6 +417,8 @@ const renderBindingHighlightForBindableElement_simple = (
if (
appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
!angleLocked &&
(isFrameLikeElement(suggestedBinding.element) ||
isBindableElement(suggestedBinding.element))
) {
@@ -807,7 +811,12 @@ const renderBindingHighlightForBindableElement_complex = (
context.restore();
if (appState.isMidpointSnappingEnabled) {
if (
appState.isMidpointSnappingEnabled &&
!appState.gridModeEnabled &&
(!app.lastPointerMoveEvent ||
!shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent))
) {
// Draw midpoint indicators
context.save();
context.translate(
@@ -920,12 +929,16 @@ const renderBindingHighlightForBindableElement = (
app.lastPointerMoveCoords.y,
)
: null;
const angleLocked =
!!app.lastPointerMoveEvent &&
shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent);
renderBindingHighlightForBindableElement_simple(
context,
suggestedBinding,
allElementsMap,
appState,
pointerCoords,
angleLocked,
);
context.restore();
};
+9 -20
View File
@@ -291,14 +291,6 @@ const renderElementToSvg = (
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
// Pin the mask to user space; the default maskUnits="objectBoundingBox"
// collapses to zero area for axis-aligned arrows (zero-size bbox),
// hiding the whole line from SVG exports (#11439).
maskPath.setAttribute("maskUnits", "userSpaceOnUse");
maskPath.setAttribute("x", "0");
maskPath.setAttribute("y", "0");
maskPath.setAttribute("width", `${element.width + 100 + offsetX}`);
maskPath.setAttribute("height", `${element.height + 100 + offsetY}`);
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
@@ -394,10 +386,9 @@ const renderElementToSvg = (
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
path.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
path.setAttribute("d", shape);
wrapper.appendChild(path);
@@ -630,10 +621,9 @@ const renderElementToSvg = (
rect.setAttribute("fill", "none");
rect.setAttribute(
"stroke",
applyDarkModeFilter(
FRAME_STYLE.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor,
);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
@@ -687,10 +677,9 @@ const renderElementToSvg = (
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute(
"fill",
applyDarkModeFilter(
element.strokeColor,
renderConfig.theme === THEME.DARK,
),
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
+3 -1
View File
@@ -459,7 +459,9 @@ export const exportToSvg = async (
rect.setAttribute("height", `${height}`);
rect.setAttribute(
"fill",
applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode),
exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
);
svgRoot.appendChild(rect);
}
-1
View File
@@ -9,7 +9,6 @@ export {
hasBackground,
hasStrokeWidth,
hasStrokeStyle,
hasFreedrawMode,
canHaveArrowheads,
canChangeRoundness,
} from "@excalidraw/element";
@@ -904,8 +904,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1104,8 +1103,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1319,8 +1317,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1651,8 +1648,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1983,8 +1979,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2198,8 +2193,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2440,8 +2434,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2739,8 +2732,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3112,8 +3104,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "dotted",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "bold",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3223,11 +3214,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"seed": 1278240551,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1349943049,
"versionNonce": 1402203177,
"width": 20,
"x": -10,
"y": 0,
@@ -3252,14 +3243,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 406373543,
"seed": 1898319239,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 10,
"versionNonce": 1402203177,
"version": 9,
"versionNonce": 941653321,
"width": 20,
"x": 20,
"y": 30,
@@ -3268,7 +3259,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `17`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `16`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] redo stack 1`] = `[]`;
@@ -3468,11 +3459,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"strokeWidth": 4,
"strokeStyle": "dotted",
"version": 7,
},
"inserted": {
"strokeWidth": 2,
"strokeStyle": "solid",
"version": 6,
},
},
@@ -3493,11 +3484,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"strokeStyle": "dotted",
"roughness": 2,
"version": 8,
},
"inserted": {
"strokeStyle": "solid",
"roughness": 1,
"version": 7,
},
},
@@ -3518,11 +3509,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"roughness": 2,
"opacity": 60,
"version": 9,
},
"inserted": {
"roughness": 1,
"opacity": 100,
"version": 8,
},
},
@@ -3530,31 +3521,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"id": "id17",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {
"id3": {
"deleted": {
"opacity": 60,
"version": 10,
},
"inserted": {
"opacity": 100,
"version": 9,
},
},
},
},
"id": "id19",
},
{
"appState": AppStateDelta {
"delta": Delta {
@@ -3582,7 +3548,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 2,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"version": 4,
},
"inserted": {
@@ -3592,13 +3557,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"version": 3,
},
},
},
},
"id": "id21",
"id": "id19",
},
]
`;
@@ -3633,8 +3597,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3957,8 +3920,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4281,8 +4243,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5567,8 +5528,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6785,8 +6745,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7743,8 +7702,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8744,8 +8702,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9736,8 +9693,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -30,8 +30,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -665,8 +664,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1228,8 +1226,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1589,8 +1586,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1952,8 +1948,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2216,8 +2211,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2704,8 +2698,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3008,8 +3001,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3328,8 +3320,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3623,8 +3614,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3910,8 +3900,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4146,8 +4135,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4404,8 +4392,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4676,8 +4663,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4906,8 +4892,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5136,8 +5121,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5384,8 +5368,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5641,8 +5624,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5900,8 +5882,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6230,8 +6211,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6658,8 +6638,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7033,8 +7012,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7346,8 +7324,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7640,8 +7617,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7871,8 +7847,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8224,8 +8199,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8577,8 +8551,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8984,8 +8957,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9111,12 +9083,8 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"updated": 1,
"version": 7,
@@ -9217,12 +9185,8 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"version": 7,
"width": 50,
@@ -9272,8 +9236,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9537,8 +9500,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9803,8 +9765,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10036,8 +9997,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10334,8 +10294,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10653,8 +10612,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10890,8 +10848,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11332,7 +11289,7 @@ exports[`history > multiplayer undo/redo > should support undo and redo when esc
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11816,8 +11773,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12077,8 +12033,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12313,8 +12268,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12551,8 +12505,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12703,12 +12656,8 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"updated": 1,
"version": 5,
@@ -12757,12 +12706,8 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#e03131",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"updated": 1,
"version": 4,
@@ -12900,12 +12845,8 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#e03131",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 50,
@@ -12955,8 +12896,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13166,8 +13106,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13374,8 +13313,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13676,8 +13614,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13975,8 +13912,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14221,8 +14157,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14459,8 +14394,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14697,8 +14631,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14945,8 +14878,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15277,8 +15209,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15448,8 +15379,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15733,8 +15663,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15997,8 +15926,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16151,8 +16079,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16434,8 +16361,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16597,8 +16523,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -17347,8 +17272,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -17995,8 +17919,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -18643,8 +18566,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -19394,8 +19316,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -20164,8 +20085,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -20645,8 +20565,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -21157,8 +21076,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -21617,8 +21535,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -30,8 +30,7 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -457,8 +456,7 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -874,8 +872,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1441,8 +1438,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1649,8 +1645,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2034,8 +2029,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2280,8 +2274,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2461,8 +2454,7 @@ exports[`regression tests > can drag element that covers another element, while
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2787,8 +2779,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1971c2",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3043,8 +3034,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3285,8 +3275,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3522,8 +3511,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3781,8 +3769,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4096,8 +4083,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4533,8 +4519,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -4817,8 +4802,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5094,8 +5078,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -5303,8 +5286,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5504,8 +5486,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5898,8 +5879,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6196,8 +6176,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6935,12 +6914,8 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 50,
@@ -6990,8 +6965,7 @@ exports[`regression tests > given a group of selected elements with an element t
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7325,8 +7299,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7605,8 +7578,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7841,8 +7813,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8082,8 +8053,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8263,8 +8233,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8444,8 +8413,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8625,8 +8593,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8859,8 +8826,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9091,8 +9057,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9233,12 +9198,8 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 30,
@@ -9288,8 +9249,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9522,8 +9482,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9703,8 +9662,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9935,8 +9893,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10116,8 +10073,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10258,12 +10214,8 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"version": 4,
"width": 30,
@@ -10313,8 +10265,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10494,8 +10445,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11026,8 +10976,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11307,8 +11256,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -11431,8 +11379,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11632,8 +11579,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11952,8 +11898,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12382,8 +12327,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13023,8 +12967,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13150,8 +13093,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13782,8 +13724,7 @@ exports[`regression tests > switches from group of selected elements to another
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14122,8 +14063,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14387,8 +14327,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14511,8 +14450,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14877,8 +14815,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15001,8 +14938,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1,6 +1,6 @@
import React from "react";
import { CODES, STROKE_WIDTH } from "@excalidraw/common";
import { CODES } from "@excalidraw/common";
import { copiedStyles } from "../actions/actionStyles";
import { Excalidraw } from "../index";
@@ -78,7 +78,7 @@ describe("actionStyles", () => {
expect(firstRect.strokeColor).toBe("#e03131");
expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
@@ -1,7 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, STROKE_WIDTH, reseed } from "@excalidraw/common";
import { KEYS, reseed } from "@excalidraw/common";
import { setDateTimeForTests } from "@excalidraw/common";
@@ -378,7 +378,7 @@ describe("contextMenu element", () => {
expect(firstRect.strokeColor).toBe("#e03131");
expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
@@ -1,46 +0,0 @@
import { ROUNDNESS } from "@excalidraw/common";
import { convertElementTypes } from "../components/ConvertElementTypePopup";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
const { h } = window;
describe("convert element type", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
// #9662
it("recalculates roundness type when switching between generic shapes", () => {
const rectangle = API.createElement({
type: "rectangle",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, // Dooesn't matter as long as it is set
});
API.setElements([rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
convertElementTypes(h.app, {
conversionType: "generic",
nextType: "diamond",
});
});
expect(h.elements[0].type).toBe("diamond");
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.PROPORTIONAL_RADIUS);
act(() => {
convertElementTypes(h.app, {
conversionType: "generic",
nextType: "rectangle",
});
});
expect(h.elements[0].type).toBe("rectangle");
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.ADAPTIVE_RADIUS);
});
});
@@ -154,7 +154,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"opacity": 10,
"roughness": 2,
"roundness": {
"type": 2,
"type": 3,
},
"seed": Any<Number>,
"strokeColor": "red",
@@ -192,7 +192,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
"opacity": 10,
"roughness": 2,
"roundness": {
"type": 2,
"type": 3,
},
"seed": Any<Number>,
"strokeColor": "red",
@@ -240,12 +240,8 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"seed": Any<Number>,
"simulatePressure": true,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "variable",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"updated": 1,
"version": 2,
@@ -193,53 +193,6 @@ describe("restoreElements", () => {
expect(restoredFreedraw.pressures).toEqual([0.1, 0.4]);
});
it("should restore freedraw stroke variability", () => {
const freedrawElement = API.createElement({
type: "freedraw",
id: "id-freedraw-mode",
points: [pointFrom(0, 0), pointFrom(10, 10)],
});
const [missing, bogusString, bogusNumber, valid, variable] =
restore.restoreElements(
[
{ ...freedrawElement, id: "missing", strokeOptions: undefined },
{
...freedrawElement,
id: "bogusString",
strokeOptions: { variability: "scribble" },
},
{
...freedrawElement,
id: "bogusNumber",
strokeOptions: { variability: 42 },
},
{
...freedrawElement,
id: "valid",
strokeOptions: { variability: "constant", streamline: 0.8 },
},
{
...freedrawElement,
id: "variable",
strokeOptions: { variability: "variable", streamline: 0.8 },
},
] as any,
null,
) as ExcalidrawFreeDrawElement[];
expect(missing.strokeOptions?.variability).toBe("variable");
expect(bogusString.strokeOptions?.variability).toBe("variable");
expect(bogusNumber.strokeOptions?.variability).toBe("variable");
expect(valid.strokeOptions?.variability).toBe("constant");
expect(variable.strokeOptions?.variability).toBe("variable");
expect(missing.strokeOptions?.streamline).toBe(0.5);
expect(bogusString.strokeOptions?.streamline).toBe(0.5);
expect(bogusNumber.strokeOptions?.streamline).toBe(0.5);
expect(valid.strokeOptions?.streamline).toBe(0.8);
expect(variable.strokeOptions?.streamline).toBe(0.8);
});
it("should restore line and draw elements correctly", () => {
const lineElement = API.createElement({ type: "line", id: "id-line01" });
@@ -687,21 +640,6 @@ describe("restoreElements", () => {
});
describe("restoreAppState", () => {
it("should restore freedraw mode app state values", () => {
expect(
restore.restoreAppState(
{ currentItemStrokeVariability: "constant" } as any,
null,
).currentItemStrokeVariability,
).toBe("constant");
expect(
restore.restoreAppState(
{ currentItemStrokeVariability: "variable" } as any,
null,
).currentItemStrokeVariability,
).toBe("variable");
});
it("when appState is null it should return the local app state property", () => {
const stubLocalAppState = getDefaultAppState();
stubLocalAppState.cursorButton = "down";
@@ -750,21 +688,6 @@ describe("restoreAppState", () => {
expect(restoredAppState.name).toBe(stubImportedAppState.name);
});
it("should migrate legacy current item stroke width to stroke width key", () => {
const stubImportedAppState = {
...getDefaultAppState(),
currentItemStrokeWidth: 4,
currentItemStrokeWidthKey: undefined,
} as any;
const restoredAppState = restore.restoreAppState(
stubImportedAppState,
null,
);
expect(restoredAppState.currentItemStrokeWidthKey).toBe("bold");
});
it("should restore with current app state when imported data state is undefined", () => {
const stubImportedAppState = {
...getDefaultAppState(),
+2 -28
View File
@@ -1,4 +1,5 @@
import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react";
import { THEME } from "@excalidraw/common";
@@ -432,7 +433,7 @@ describe("<Excalidraw/>", () => {
const customMenu = useMemo(() => {
return (
<MainMenu>
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
<MainMenu.DefaultItems.ToggleTheme />
</MainMenu>
);
}, []);
@@ -456,32 +457,5 @@ describe("<Excalidraw/>", () => {
queryByTestId(container, "toggle-dark-mode")?.textContent,
).toContain(t("buttons.lightMode"));
});
it("should show theme toggle when the theme prop and onThemeChange are defined", async () => {
const onThemeChange = vi.fn();
const { container } = await render(
<Excalidraw theme={THEME.DARK} onThemeChange={onThemeChange} />,
);
expect(h.state.theme).toBe(THEME.DARK);
//open menu
toggleMenu(container);
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy();
});
it("should call onThemeChange instead of mutating theme when defined", async () => {
const onThemeChange = vi.fn();
const { container } = await render(
<Excalidraw theme={THEME.LIGHT} onThemeChange={onThemeChange} />,
);
//open menu
toggleMenu(container);
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK);
expect(h.state.theme).toBe(THEME.LIGHT);
});
});
});
@@ -1,57 +0,0 @@
import type { ExcalidrawFreeDrawElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { UI } from "./helpers/ui";
import { act, fireEvent, render, screen } from "./test-utils";
const { h } = window;
describe("freedraw mode action", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
afterEach(async () => {
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
await act(async () => {});
});
it("applies currentItemStrokeVariability to newly drawn freedraw elements", () => {
// default app state draws constant-width strokes
expect(h.state.currentItemStrokeVariability).toBe("constant");
UI.createElement("freedraw", { x: 0, y: 0 });
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("constant");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
});
it("toggling the radio updates both the selected element and the default", () => {
const element = UI.createElement("freedraw", { x: 0, y: 0 });
API.setSelectedElements([element.get()]);
fireEvent.click(screen.getByTitle("Variable"));
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("variable");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
expect(h.state.currentItemStrokeVariability).toBe("variable");
fireEvent.click(screen.getByTitle("Constant"));
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("constant");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
expect(h.state.currentItemStrokeVariability).toBe("constant");
});
});
+7 -17
View File
@@ -4,12 +4,7 @@ import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import {
DEFAULT_VERTICAL_ALIGN,
ROUNDNESS,
assertNever,
getStrokeWidthByKey,
} from "@excalidraw/common";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import {
newArrowElement,
@@ -24,7 +19,8 @@ import {
newTextElement,
} from "@excalidraw/element";
import { isUsingAdaptiveRadius, getSelectedElements } from "@excalidraw/element";
import { isLinearElementType } from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element";
import { selectGroupsForSelectedElements } from "@excalidraw/element";
import { FONT_SIZES } from "@excalidraw/common";
@@ -205,9 +201,6 @@ export class API {
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
strokeOptions?: T extends "freedraw"
? ExcalidrawFreeDrawElement["strokeOptions"]
: never;
locked?: boolean;
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@@ -266,9 +259,7 @@ export class API {
backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor,
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
strokeWidth:
rest.strokeWidth ??
getStrokeWidthByKey(type, appState.currentItemStrokeWidthKey),
strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
roundness: (
rest.roundness === undefined
@@ -276,9 +267,9 @@ export class API {
: rest.roundness
)
? {
type: isUsingAdaptiveRadius(type)
? ROUNDNESS.ADAPTIVE_RADIUS
: ROUNDNESS.PROPORTIONAL_RADIUS,
type: isLinearElementType(type)
? ROUNDNESS.PROPORTIONAL_RADIUS
: ROUNDNESS.ADAPTIVE_RADIUS,
}
: null,
roughness: rest.roughness ?? appState.currentItemRoughness,
@@ -327,7 +318,6 @@ export class API {
type: type as "freedraw",
simulatePressure: true,
points: rest.points,
strokeOptions: rest.strokeOptions,
...base,
});
break;
@@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6,16 +6,12 @@ import {
FRAME_STYLE,
} from "@excalidraw/common";
import { pointFrom } from "@excalidraw/math";
import type {
ExcalidrawTextElement,
FractionalIndex,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { LocalPoint } from "@excalidraw/math";
import { prepareElementsForExport } from "../../data";
import * as exportUtils from "../../scene/export";
import {
@@ -196,45 +192,6 @@ describe("exportToSvg", () => {
);
expect(svgElement.innerHTML).toMatchSnapshot();
});
// #11439: a perfectly horizontal/vertical arrow has a zero-size bounding box.
// The bound-text "gap" mask must use userSpaceOnUse units, otherwise its
// objectBoundingBox region collapses to zero area and the whole arrow line
// disappears from the SVG export (only the label remains).
it("keeps a horizontal arrow with a bound label visible (#11439)", async () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow-11439",
width: 200,
height: 0,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
boundElements: [{ type: "text", id: "label-11439" }],
});
const label = API.createElement({
type: "text",
id: "label-11439",
text: "label",
width: 50,
height: 20,
containerId: "arrow-11439",
});
const svgElement = await exportUtils.exportToSvg(
[arrow, label] as NonDeletedExcalidrawElement[],
DEFAULT_OPTIONS,
null,
);
const mask = svgElement.querySelector("mask");
expect(mask).not.toBeNull();
expect(mask?.getAttribute("maskUnits")).toBe("userSpaceOnUse");
// a degenerate (objectBoundingBox) region would be zero-area here
expect(Number(mask?.getAttribute("width"))).toBeGreaterThan(0);
expect(Number(mask?.getAttribute("height"))).toBeGreaterThan(0);
// the masked arrow group still renders its line (not clipped away)
expect(svgElement.querySelector("g[mask] path")).not.toBeNull();
});
});
describe("exporting frames", () => {
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["**/*"],
+3 -10
View File
@@ -4,7 +4,6 @@ import type {
throttleRAF,
MIME_TYPES,
EditorInterface,
StrokeWidthKey,
} from "@excalidraw/common";
import type { LinearElementEditor } from "@excalidraw/element";
@@ -34,7 +33,6 @@ import type {
ExcalidrawNonSelectionElement,
BindMode,
ExcalidrawTextElement,
StrokeVariability,
} from "@excalidraw/element/types";
import type {
@@ -226,6 +224,7 @@ export type InteractiveCanvasAppState = Readonly<
newElement: AppState["newElement"];
isBindingEnabled: AppState["isBindingEnabled"];
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
gridModeEnabled: AppState["gridModeEnabled"];
suggestedBinding: AppState["suggestedBinding"];
isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"];
@@ -364,10 +363,9 @@ export interface AppState {
currentItemStrokeColor: string;
currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidthKey: StrokeWidthKey;
currentItemStrokeWidth: number;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number;
currentItemStrokeVariability: StrokeVariability;
currentItemOpacity: number;
currentItemFontFamily: FontFamilyValues;
currentItemFontSize: number;
@@ -576,7 +574,6 @@ export interface ExcalidrawProps {
appState: AppState,
files: BinaryFiles,
) => void;
onThemeChange?: (theme: Theme | "system") => void;
/**
* note: only subscribes if the props.onIncrement is defined on initial render
*/
@@ -754,11 +751,6 @@ export type CanvasActions = Partial<{
export: false | ExportOpts;
loadScene: boolean;
saveToActiveFile: boolean;
/**
* defaults to true if `props.theme` is omitted or `props.onThemeChange`
* is supplied (at which point the theme is considered as host-app controlled),
* else default to false
* */
toggleTheme: boolean | null;
saveAsImage: boolean;
}>;
@@ -854,6 +846,7 @@ export type AppClassProperties = {
onStateChange: App["onStateChange"];
lastPointerMoveCoords: App["lastPointerMoveCoords"];
lastPointerMoveEvent: App["lastPointerMoveEvent"];
bindModeHandler: App["bindModeHandler"];
setAppState: App["setAppState"];
+4 -4
View File
@@ -392,10 +392,10 @@ export const textWysiwyg = ({
),
textAlign,
verticalAlign,
color: applyDarkModeFilter(
updatedTextElement.strokeColor,
appState.theme === THEME.DARK,
),
color:
appState.theme === THEME.DARK
? applyDarkModeFilter(updatedTextElement.strokeColor)
: updatedTextElement.strokeColor,
opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`,
});
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Excalidraw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-23
View File
@@ -1,23 +0,0 @@
# Laser Pointer
## Usage
import { LaserPointer } from '@excalidraw/laser-pointer'
const stroke = new LaserPointer(options)
stroke.addPoint([100, 200, 1])
stroke.close()
const outline = stroke.getStrokeOutline()
## Options
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `number` | `2` | Radius of the stroke. |
| `streamline` | `number` | `0.42` | Interpolate input points to reduce jitter. |
| `simplify` | `number` | `0.1` | Reduce stroke size by sacrificing precision. |
| `simplifyPhase` | `"input" \| "output" \| "tail" ` | `"output"` | Decides when the simplification algorithm should be applied. |
| `sizeMapping` | `(details: SizeMappingDetails) => number` | `() => 1` | Maps each point to a value between `0.0` and `1.0`. |
| `keepHead` | `boolean` | `false` | Whether size mapping should influence the head of the stroke. |
View File
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@excalidraw/laser-pointer",
"version": "1.3.1",
"description": "Generate outline for laser pointer tool",
"type": "module",
"types": "./dist/types/laser-pointer/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/laser-pointer/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
"dist/*"
],
"keywords": [
"excalidraw",
"laserpointer"
],
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./state";
export type { Point } from "./math";
-105
View File
@@ -1,105 +0,0 @@
export type Point = [x: number, y: number, r: number];
export function add([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax + bx, ay + by, ar + br];
}
export function sub([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax - bx, ay - by, ar - br];
}
export function smul([x, y, r]: Point, s: number): Point {
return [x * s, y * s, r * s];
}
export function norm([x, y, r]: Point): Point {
return [x / Math.sqrt(x ** 2 + y ** 2), y / Math.sqrt(x ** 2 + y ** 2), r];
}
export function rot([x, y, r]: Point, rad: number): Point {
return [
Math.cos(rad) * x - Math.sin(rad) * y,
Math.sin(rad) * x + Math.cos(rad) * y,
r,
];
}
export function plerp(a: Point, b: Point, t: number): Point {
return add(a, smul(sub(b, a), t));
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function angle(p: Point, p1: Point, p2: Point) {
return (
Math.atan2(p2[1] - p[1], p2[0] - p[0]) -
Math.atan2(p1[1] - p[1], p1[0] - p[0])
);
}
export function normAngle(a: number) {
return Math.atan2(Math.sin(a), Math.cos(a));
}
export function mag([x, y]: Point) {
return Math.sqrt(x ** 2 + y ** 2);
}
export function dist([ax, ay]: Point, [bx, by]: Point): number {
return Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
}
export function getCircleAndPerpendicularLineIntersectionsAtPoint(
point: Point,
direction: Point,
radius: number,
): [Point, Point] {
return [
add(point, smul(norm(rot(direction, Math.PI / 2)), radius)),
add(point, smul(norm(rot(direction, -Math.PI / 2)), radius)),
];
}
export function runLength(ps: Point[]): number {
if (ps.length < 2) {
return 0;
}
let len = 0;
for (let i = 1; i <= ps.length - 1; i++) {
len += dist(ps[i - 1], ps[i]);
}
len += dist(ps[ps.length - 2], ps[ps.length - 1]);
return len;
}
export const clamp = (v: number, min: number, max: number) =>
Math.max(min, Math.min(max, v));
export function distancePointToSegment(p3: Point, p1: Point, p2: Point) {
const sMag = dist(p1, p2);
if (sMag === 0) {
return dist(p3, p1);
}
const u = clamp(
((p3[0] - p1[0]) * (p2[0] - p1[0]) + (p3[1] - p1[1]) * (p2[1] - p1[1])) /
sMag ** 2,
0,
1,
);
const pi: Point = [
p1[0] + u * (p2[0] - p1[0]),
p1[1] + u * (p2[1] - p1[1]),
p3[2],
];
return dist(pi, p3);
}
-42
View File
@@ -1,42 +0,0 @@
import { type Point, distancePointToSegment } from "./math";
export function douglasPeucker(points: Point[], epsilon: number): Point[] {
if (epsilon === 0) {
return points;
}
if (points.length <= 2) {
return points;
}
const first = points[0];
const last = points[points.length - 1];
const [maxDistance, maxIndex] = points.reduce(
([maxDistance, maxIndex], point, index) => {
const distance = distancePointToSegment(point, first, last);
return distance > maxDistance
? [distance, index]
: [maxDistance, maxIndex];
},
[0, -1],
);
if (maxDistance >= epsilon) {
const maxIndexPoint = points[maxIndex];
return [
...douglasPeucker(
[first, ...points.slice(1, maxIndex), maxIndexPoint],
epsilon,
).slice(0, -1),
maxIndexPoint,
...douglasPeucker(
[maxIndexPoint, ...points.slice(maxIndex, -1), last],
epsilon,
).slice(1),
];
}
return [first, last];
}
-377
View File
@@ -1,377 +0,0 @@
import * as m from "./math";
import { douglasPeucker } from "./simplify";
import type { Point } from "./math";
export type SizeMappingDetails = {
pressure: number;
runningLength: number;
currentIndex: number;
totalLength: number;
};
export type LaserPointerOptions = {
size: number;
streamline: number;
simplify: number;
simplifyPhase: "tail" | "output" | "input";
keepHead: boolean;
sizeMapping: (details: SizeMappingDetails) => number;
};
export class LaserPointer {
static defaults: LaserPointerOptions = {
size: 2,
streamline: 0.45,
simplify: 0.1,
simplifyPhase: "output",
keepHead: false,
sizeMapping: () => 1,
};
static constants = {
cornerDetectionMaxAngle: 75,
cornerDetectionVariance: (s: number) => (s > 35 ? 0.5 : 1),
maxTailLength: 50,
};
options: LaserPointerOptions;
constructor(options: Partial<LaserPointerOptions>) {
this.options = Object.assign({}, LaserPointer.defaults, options);
}
originalPoints: Point[] = [];
private stablePoints: Point[] = [];
private tailPoints: Point[] = [];
private isFresh = true;
private get lastPoint(): Point {
return (
this.tailPoints[this.tailPoints.length - 1] ??
this.stablePoints[this.stablePoints.length - 1]
);
}
addPoint(point: Point) {
const lastPoint = this.originalPoints[this.originalPoints.length - 1];
if (lastPoint && lastPoint[0] === point[0] && lastPoint[1] === point[1]) {
return;
}
this.originalPoints.push(point);
if (this.isFresh) {
this.isFresh = false;
this.stablePoints.push(point);
return;
}
if (this.options.streamline > 0) {
point = m.plerp(this.lastPoint, point, 1 - this.options.streamline);
}
this.tailPoints.push(point);
if (m.runLength(this.tailPoints) > LaserPointer.constants.maxTailLength) {
this.stabilizeTail();
}
}
close() {
this.stabilizeTail();
}
stabilizeTail() {
if (this.options.simplify > 0 && this.options.simplifyPhase === "tail") {
throw new Error("Not implemented yet");
} else {
this.stablePoints.push(...this.tailPoints);
this.tailPoints = [];
}
}
private getSize(
sizeOverride: number | undefined,
pressure: number,
index: number,
totalLength: number,
runningLength: number,
) {
return (
(sizeOverride ?? this.options.size) *
this.options.sizeMapping({
pressure,
runningLength,
currentIndex: index,
totalLength,
})
);
}
getStrokeOutline(sizeOverride?: number | undefined): Point[] {
if (this.isFresh) {
return [];
}
let points = [...this.stablePoints, ...this.tailPoints];
if (this.options.simplify > 0 && this.options.simplifyPhase === "input") {
points = douglasPeucker(points, this.options.simplify);
}
const len = points.length;
if (len === 0) {
return [];
}
if (len === 1) {
const c = points[0];
const size = this.getSize(sizeOverride, c[2], 0, len, 0);
if (size < 0.5) {
return [];
}
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), size)));
}
ps.push(
m.add(
c,
m.smul(
[1, 0, 0] as Point,
this.getSize(sizeOverride, c[2], 0, len, 0),
),
),
);
return ps;
}
if (len === 2) {
const c = points[0];
const n = points[1];
const cSize = this.getSize(sizeOverride, c[2], 0, len, 0);
const nSize = this.getSize(sizeOverride, n[2], 0, len, 0);
if (cSize < 0.5 || nSize < 0.5) {
return [];
}
const ps: Point[] = [];
const pAngle = m.angle(c, [c[0], c[1] - 100, c[2]] as Point, n);
for (
let theta = pAngle;
theta <= Math.PI + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), cSize)));
}
for (
let theta = Math.PI + pAngle;
theta <= Math.PI * 2 + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(n, m.smul(m.rot([1, 0, 0] as Point, theta), nSize)));
}
ps.push(ps[0]);
return ps;
}
const forwardPoints: Point[] = [];
const backwardPoints: Point[] = [];
let speed = 0;
let prevSpeed = 0;
let visibleStartIndex = 0;
let runningLength = 0;
for (let i = 1; i < len - 1; i++) {
const p = points[i - 1];
const c = points[i];
const n = points[i + 1];
const pressure = c[2];
const d = m.dist(p, c);
runningLength += d;
speed = prevSpeed + (d - prevSpeed) * 0.2;
const cSize = this.getSize(sizeOverride, pressure, i, len, runningLength);
if (cSize === 0) {
visibleStartIndex = i + 1;
continue;
}
const dirPC = m.norm(m.sub(p, c));
const dirNC = m.norm(m.sub(n, c));
const p1dirPC = m.rot(dirPC, Math.PI / 2);
const p2dirPC = m.rot(dirPC, -Math.PI / 2);
const p1dirNC = m.rot(dirNC, Math.PI / 2);
const p2dirNC = m.rot(dirNC, -Math.PI / 2);
const p1PC = m.add(c, m.smul(p1dirPC, cSize));
const p2PC = m.add(c, m.smul(p2dirPC, cSize));
const p1NC = m.add(c, m.smul(p1dirNC, cSize));
const p2NC = m.add(c, m.smul(p2dirNC, cSize));
const ftdir = m.add(p1dirPC, p2dirNC);
const btdir = m.add(p2dirPC, p1dirNC);
const paPC = m.add(
c,
m.smul(m.mag(ftdir) === 0 ? dirPC : m.norm(ftdir), cSize),
);
const paNC = m.add(
c,
m.smul(m.mag(btdir) === 0 ? dirNC : m.norm(btdir), cSize),
);
const cAngle = m.normAngle(m.angle(c, p, n));
const D_ANGLE =
(LaserPointer.constants.cornerDetectionMaxAngle / 180) *
Math.PI *
LaserPointer.constants.cornerDetectionVariance(speed);
if (Math.abs(cAngle) < D_ANGLE) {
const tAngle = Math.abs(m.normAngle(Math.PI - cAngle)); // turn angle
if (tAngle === 0) {
continue;
}
if (cAngle < 0) {
backwardPoints.push(p2PC, paNC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
forwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
backwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
backwardPoints.push(paNC, p1NC);
} else {
forwardPoints.push(p1PC, paPC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
backwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
forwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
forwardPoints.push(paPC, p2NC);
}
} else {
forwardPoints.push(paPC);
backwardPoints.push(paNC);
}
prevSpeed = speed;
}
if (visibleStartIndex >= len - 2) {
if (this.options.keepHead) {
const c = points[len - 1];
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(
m.add(
c,
m.smul(m.rot([1, 0, 0] as Point, theta), this.options.size),
),
);
}
ps.push(m.add(c, m.smul([1, 0, 0] as Point, this.options.size)));
return ps;
}
return [];
}
const first = points[visibleStartIndex];
const second = points[visibleStartIndex + 1];
const penultimate = points[len - 2];
const ultimate = points[len - 1];
const dirFS = m.norm(m.sub(second, first));
const dirPU = m.norm(m.sub(penultimate, ultimate));
const ppdirFS = m.rot(dirFS, -Math.PI / 2);
const ppdirPU = m.rot(dirPU, Math.PI / 2);
const startCapSize = this.getSize(sizeOverride, first[2], 0, len, 0);
const startCap: Point[] = [];
const endCapSize = this.options.keepHead
? this.options.size
: this.getSize(sizeOverride, penultimate[2], len - 2, len, runningLength);
const endCap: Point[] = [];
// Lowered threshold to 0.1,
// ensuring virtually all strokes get proper rounded caps for visual consistency.
if (startCapSize > 0.1) {
for (let theta = 0; theta <= Math.PI; theta += Math.PI / 16) {
startCap.unshift(
m.add(first, m.rot(m.smul(ppdirFS, startCapSize), -theta)),
);
}
startCap.unshift(m.add(first, m.smul(ppdirFS, -startCapSize)));
} else {
startCap.push(first);
}
for (let theta = 0; theta <= Math.PI * 3; theta += Math.PI / 16) {
endCap.push(m.add(ultimate, m.rot(m.smul(ppdirPU, -endCapSize), -theta)));
}
const strokeOutline = [
...startCap,
...forwardPoints,
...endCap.reverse(),
...backwardPoints.reverse(),
];
if (startCap.length > 0) {
strokeOutline.push(startCap[0]);
}
if (this.options.simplify > 0 && this.options.simplifyPhase === "output") {
return douglasPeucker(strokeOutline, this.options.simplify);
}
return strokeOutline;
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+1 -3
View File
@@ -6,7 +6,7 @@
"declaration": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "Node",
"resolveJsonModule": true,
"jsx": "react-jsx",
"emitDeclarationOnly": true,
@@ -17,8 +17,6 @@
"@excalidraw/element/*": ["./element/src/*"],
"@excalidraw/excalidraw": ["./excalidraw/index.tsx"],
"@excalidraw/excalidraw/*": ["./excalidraw/*"],
"@excalidraw/laser-pointer": ["./laser-pointer/src/index.ts"],
"@excalidraw/laser-pointer/*": ["./laser-pointer/src/*"],
"@excalidraw/math": ["./math/src/index.ts"],
"@excalidraw/math/*": ["./math/src/*"],
"@excalidraw/utils": ["./utils/src/index.ts"],
+73
View File
@@ -0,0 +1,73 @@
import {
vectorCross,
vectorFromPoint,
type GlobalPoint,
type LocalPoint,
} from "@excalidraw/math";
import type { Bounds } from "@excalidraw/common";
export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
export function getBBox<P extends LocalPoint | GlobalPoint>(
line: LineSegment<P>,
): Bounds {
return [
Math.min(line[0][0], line[1][0]),
Math.min(line[0][1], line[1][1]),
Math.max(line[0][0], line[1][0]),
Math.max(line[0][1], line[1][1]),
];
}
export function doBBoxesIntersect(a: Bounds, b: Bounds) {
return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
}
const EPSILON = 0.000001;
export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
l: LineSegment<P>,
p: P,
) {
const p1 = vectorFromPoint(l[1], l[0]);
const p2 = vectorFromPoint(p, l[0]);
const r = vectorCross(p1, p2);
return Math.abs(r) < EPSILON;
}
export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
l: LineSegment<P>,
p: P,
) {
const p1 = vectorFromPoint(l[1], l[0]);
const p2 = vectorFromPoint(p, l[0]);
return vectorCross(p1, p2) < 0;
}
export function isLineSegmentTouchingOrCrossingLine<
P extends GlobalPoint | LocalPoint,
>(a: LineSegment<P>, b: LineSegment<P>) {
return (
isPointOnLine(a, b[0]) ||
isPointOnLine(a, b[1]) ||
(isPointRightOfLine(a, b[0])
? !isPointRightOfLine(a, b[1])
: isPointRightOfLine(a, b[1]))
);
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
a: LineSegment<P>,
b: LineSegment<P>,
) {
return (
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
isLineSegmentTouchingOrCrossingLine(a, b) &&
isLineSegmentTouchingOrCrossingLine(b, a)
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
export * from "./export";
export { elementsOverlappingBBox } from "@excalidraw/element";
export * from "./withinBounds";
export * from "./bbox";
export { getCommonBounds } from "@excalidraw/element";
+228
View File
@@ -0,0 +1,228 @@
import { arrayToMap, type Bounds } from "@excalidraw/common";
import { getElementBounds } from "@excalidraw/element";
import {
isArrowElement,
isExcalidrawElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "@excalidraw/element";
import {
rangeIncludesValue,
pointFrom,
pointRotateRads,
rangeInclusive,
} from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import type { LocalPoint } from "@excalidraw/math";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
type Points = readonly LocalPoint[];
/** @returns vertices relative to element's top-left [0,0] position */
const getNonLinearElementRelativePoints = (
element: Exclude<
Element,
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
>,
): [
TopLeft: LocalPoint,
TopRight: LocalPoint,
BottomRight: LocalPoint,
BottomLeft: LocalPoint,
] => {
if (element.type === "diamond") {
return [
pointFrom(element.width / 2, 0),
pointFrom(element.width, element.height / 2),
pointFrom(element.width / 2, element.height),
pointFrom(0, element.height / 2),
];
}
return [
pointFrom(0, 0),
pointFrom(0 + element.width, 0),
pointFrom(0 + element.width, element.height),
pointFrom(0, element.height),
];
};
/** @returns vertices relative to element's top-left [0,0] position */
const getElementRelativePoints = (element: ExcalidrawElement): Points => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points;
}
return getNonLinearElementRelativePoints(element);
};
const getMinMaxPoints = (points: Points) => {
const ret = points.reduce(
(limits, [x, y]) => {
limits.minY = Math.min(limits.minY, y);
limits.minX = Math.min(limits.minX, x);
limits.maxX = Math.max(limits.maxX, x);
limits.maxY = Math.max(limits.maxY, y);
return limits;
},
{
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
cx: 0,
cy: 0,
},
);
ret.cx = (ret.maxX + ret.minX) / 2;
ret.cy = (ret.maxY + ret.minY) / 2;
return ret;
};
const getRotatedBBox = (element: Element): Bounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
const centerPoint = pointFrom<LocalPoint>(cx, cy);
const rotatedPoints = points.map((p) =>
pointRotateRads(p, centerPoint, element.angle),
);
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return [
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
};
export const isElementInsideBBox = (
element: Element,
bbox: Bounds,
eitherDirection = false,
): boolean => {
const elementBBox = getRotatedBBox(element);
const elementInsideBbox =
bbox[0] <= elementBBox[0] &&
bbox[2] >= elementBBox[2] &&
bbox[1] <= elementBBox[1] &&
bbox[3] >= elementBBox[3];
if (!eitherDirection) {
return elementInsideBbox;
}
if (elementInsideBbox) {
return true;
}
return (
elementBBox[0] <= bbox[0] &&
elementBBox[2] >= bbox[2] &&
elementBBox[1] <= bbox[1] &&
elementBBox[3] >= bbox[3]
);
};
export const elementPartiallyOverlapsWithOrContainsBBox = (
element: Element,
bbox: Bounds,
): boolean => {
const elementBBox = getRotatedBBox(element);
return (
(rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
rangeIncludesValue(
bbox[0],
rangeInclusive(elementBBox[0], elementBBox[2]),
)) &&
(rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
rangeIncludesValue(
bbox[1],
rangeInclusive(elementBBox[1], elementBBox[3]),
))
);
};
export const elementsOverlappingBBox = ({
elements,
bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
* - overlap: elements overlapping or inside bounds
* - contain: elements inside bounds or bounds inside elements
* - inside: elements inside bounds
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds, arrayToMap(elements));
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,
bounds[2] + errorMargin,
bounds[3] + errorMargin,
];
const includedElementSet = new Set<string>();
for (const element of elements) {
if (includedElementSet.has(element.id)) {
continue;
}
const isOverlaping =
type === "overlap"
? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
: type === "inside"
? isElementInsideBBox(element, adjustedBBox)
: isElementInsideBBox(element, adjustedBBox, true);
if (isOverlaping) {
includedElementSet.add(element.id);
if (element.boundElements) {
for (const boundElement of element.boundElements) {
includedElementSet.add(boundElement.id);
}
}
if (isTextElement(element) && element.containerId) {
includedElementSet.add(element.containerId);
}
if (isArrowElement(element)) {
if (element.startBinding) {
includedElementSet.add(element.startBinding.elementId);
}
if (element.endBinding) {
includedElementSet.add(element.endBinding?.elementId);
}
}
}
}
return elements.filter((element) => includedElementSet.has(element.id));
};
@@ -30,8 +30,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -23,7 +23,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
+264
View File
@@ -0,0 +1,264 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { Bounds } from "@excalidraw/common";
import {
elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox,
isElementInsideBBox,
} from "../src/withinBounds";
const makeElement = (x: number, y: number, width: number, height: number) =>
API.createElement({
type: "rectangle",
x,
y,
width,
height,
});
const makeBBox = (
minX: number,
minY: number,
maxX: number,
maxY: number,
): Bounds => [minX, minY, maxX, maxY];
describe("isElementInsideBBox()", () => {
it("should return true if element is fully inside", () => {
const bbox = makeBBox(0, 0, 100, 100);
// bbox contains element
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
});
it("should return false if element is only partially overlapping", () => {
const bbox = makeBBox(0, 0, 100, 100);
// element contains bbox
expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe(
false,
);
// element overlaps bbox from top-left
expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from top-right
expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from bottom-left
expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe(
false,
);
// element overlaps bbox from bottom-right
expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe(
false,
);
});
it("should return false if element outside", () => {
const bbox = makeBBox(0, 0, 100, 100);
// outside diagonally
expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe(
false,
);
// outside on the left
expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe(
false,
);
// outside on the right
expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false);
// outside on the top
expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe(
false,
);
// outside on the bottom
expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false);
});
it("should return true if bbox contains element and flag enabled", () => {
const bbox = makeBBox(0, 0, 100, 100);
// element contains bbox
expect(
isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true),
).toBe(true);
// bbox contains element
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
});
});
describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
it("should return true if element overlaps, is inside, or contains", () => {
const bbox = makeBBox(0, 0, 100, 100);
// bbox contains element
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(0, 0, 100, 100),
bbox,
),
).toBe(true);
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, 10, 90, 90),
bbox,
),
).toBe(true);
// element contains bbox
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, -10, 110, 110),
bbox,
),
).toBe(true);
// element overlaps bbox from top-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from top-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(90, -10, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-10, 90, 100, 100),
bbox,
),
).toBe(true);
// element overlaps bbox from bottom-right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(90, 90, 100, 100),
bbox,
),
).toBe(true);
});
it("should return false if element does not overlap", () => {
const bbox = makeBBox(0, 0, 100, 100);
// outside diagonally
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(110, 110, 100, 100),
bbox,
),
).toBe(false);
// outside on the left
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(-110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the right
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(110, 10, 50, 50),
bbox,
),
).toBe(false);
// outside on the top
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, -110, 50, 50),
bbox,
),
).toBe(false);
// outside on the bottom
expect(
elementPartiallyOverlapsWithOrContainsBBox(
makeElement(10, 110, 50, 50),
bbox,
),
).toBe(false);
});
});
describe("elementsOverlappingBBox()", () => {
it("should return elements that overlap bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "overlap",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]);
});
it("should return elements inside/containing bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "contain",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside, rectContainingBBox]);
});
it("should return elements inside bbox", () => {
const bbox = makeBBox(0, 0, 100, 100);
const rectOutside = makeElement(110, 110, 100, 100);
const rectInside = makeElement(10, 10, 90, 90);
const rectContainingBBox = makeElement(-10, -10, 110, 110);
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
expect(
elementsOverlappingBBox({
bounds: bbox,
type: "inside",
elements: [
rectOutside,
rectInside,
rectContainingBBox,
rectOverlappingTopLeft,
],
}),
).toEqual([rectInside]);
});
// TODO test linear, freedraw, and diamond element types (+rotated)
});
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
-16
View File
@@ -3,7 +3,6 @@ import fs from "fs";
// vitest.setup.ts
import "vitest-canvas-mock";
import "@testing-library/jest-dom";
import { configure } from "@testing-library/react";
import { vi } from "vitest";
import polyfill from "./packages/excalidraw/polyfill";
@@ -17,21 +16,6 @@ import {
Object.assign(globalThis, testPolyfills);
PolyfillLocalStorage();
// By default testing-library dumps the entire serialized DOM into the error
// message whenever a `waitFor`/`getBy*` fails, which floods the test output
// (often hundreds of lines of HTML per failure). Strip it out unless
// VITE_DEBUG_DOM is enabled (see .env.test), e.g. `VITE_DEBUG_DOM=true yarn test`.
const debugDom = ["true", "1"].includes(process.env.VITE_DEBUG_DOM ?? "");
if (!debugDom) {
configure({
getElementError: (message) => {
const error = new Error(message ?? undefined);
error.name = "TestingLibraryElementError";
return error;
},
});
}
vi.mock("@excalidraw/common", async (importOriginal) => {
const module = await importOriginal<typeof import("@excalidraw/common")>();
+1 -3
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -27,8 +27,6 @@
"@excalidraw/element/*": ["./packages/element/src/*"],
"@excalidraw/fractional-indexing": ["./packages/fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./packages/fractional-indexing/src/*"],
"@excalidraw/laser-pointer": ["./packages/laser-pointer/src/index.ts"],
"@excalidraw/laser-pointer/*": ["./packages/laser-pointer/src/*"],
"@excalidraw/math": ["./packages/math/src/index.ts"],
"@excalidraw/math/*": ["./packages/math/src/*"],
"@excalidraw/utils": ["./packages/utils/src/index.ts"],
+1
View File
@@ -1,4 +1,5 @@
{
"public": true,
"headers": [
{
"source": "/(.*)",
-13
View File
@@ -59,17 +59,6 @@ export default defineConfig({
"./packages/fractional-indexing/src/$1",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"./packages/laser-pointer/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer\/(.*?)/,
replacement: path.resolve(__dirname, "./packages/laser-pointer/src/$1"),
},
],
},
//@ts-ignore
@@ -82,8 +71,6 @@ export default defineConfig({
setupFiles: ["./setupTests.ts"],
globals: true,
environment: "jsdom",
// don't list skipped tests in the failure tree — keeps output readable
hideSkippedTests: true,
coverage: {
reporter: ["text", "json-summary", "json", "html", "lcovonly"],
// Since v2, it ignores empty lines by default and we need to disable it as it affects the coverage
+5
View File
@@ -1521,6 +1521,11 @@
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
"@excalidraw/laser-pointer@1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c"
integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==
"@excalidraw/markdown-to-text@0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"