Compare commits

..

6 Commits

Author SHA1 Message Date
Mark Tolmacs f3dba020b0 chore: Introducing RotatedBounds
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-30 14:17:53 +00:00
Mark Tolmacs 7c766bdfc4 fix: Test
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-30 13:15:00 +00:00
dwelle 23959099e1 fix rotated filled box-selection & refactor 2026-03-30 12:27:35 +02:00
dwelle 1e61f1c66a fix(editor): ctrl+alt with lasso over selected elements should keep lassoing 2026-03-27 13:43:34 +01:00
dwelle 22b0f1f918 fix(editor): move cursor not displaying for common bbox 2026-03-27 13:42:52 +01:00
dwelle 379dba47aa feat(editor): implement overlap box selection 2026-03-27 13:41:53 +01:00
57 changed files with 702 additions and 1736 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
- uses: styfle/cancel-workflow-action@0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body
- name: Update description with coverage
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
uses: kt3k/update-pr-description@v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
+4 -4
View File
@@ -13,16 +13,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
- name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
uses: docker/build-push-action@v5
with:
context: .
push: true
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw
env:
CI: true
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage
- name: "Report Coverage"
if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
uses: davelosert/vitest-coverage-report-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+36 -1
View File
@@ -1,3 +1,5 @@
import type { Radians } from "@excalidraw/math";
/**
* x and y position of top left corner, x and y position of bottom right corner
*/
@@ -6,7 +8,31 @@ export type Bounds = readonly [
minY: number,
maxX: number,
maxY: number,
];
] & { _brand: "excalidraw__bounds" };
export type RotatedBounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: Radians,
] & {
_brand_rotated: "excalidraw__rotated_bounds";
};
export const bounds = <T extends Radians | undefined = undefined>(
minX: number,
minY: number,
maxX: number,
maxY: number,
angle: T = undefined as T,
) => {
return (
angle
? ([minX, minY, maxX, maxY, angle] as unknown)
: ([minX, minY, maxX, maxY] as unknown)
) as T extends Radians ? RotatedBounds : Bounds;
};
export const isBounds = (box: unknown): box is Bounds =>
Array.isArray(box) &&
@@ -15,3 +41,12 @@ export const isBounds = (box: unknown): box is Bounds =>
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number";
export const isRotatedBounds = (box: unknown): box is RotatedBounds =>
Array.isArray(box) &&
box.length === 5 &&
typeof box[0] === "number" &&
typeof box[1] === "number" &&
typeof box[2] === "number" &&
typeof box[3] === "number" &&
typeof box[4] === "number";
+2 -5
View File
@@ -405,9 +405,8 @@ export const ROUGHNESS = {
export const STROKE_WIDTH = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
@@ -430,8 +429,6 @@ export const DEFAULT_ELEMENT_PROPS: {
locked: false,
};
export const DEFAULT_FREE_DRAW_STROKE_SHAPE = "variable" as const;
export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
+83 -92
View File
@@ -2,9 +2,11 @@ import rough from "roughjs/bin/rough";
import {
arrayToMap,
bounds,
type Bounds,
invariant,
rescalePoints,
type RotatedBounds,
sizeOf,
} from "@excalidraw/common";
@@ -90,7 +92,7 @@ export class ElementBounds {
private static boundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
bounds: RotatedBounds;
version: ExcalidrawElement["version"];
}
>();
@@ -102,11 +104,11 @@ export class ElementBounds {
}
>();
static getBounds(
static getBounds<T extends boolean = false>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
nonRotated: T = false as T,
): T extends true ? Bounds : RotatedBounds {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
@@ -119,40 +121,41 @@ export class ElementBounds {
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
return cachedBounds.bounds;
return cachedBounds.bounds as T extends true ? Bounds : RotatedBounds;
}
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
const [minX, minY, maxX, maxY] = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const nonRotatedBounds = bounds(minX, minY, maxX, maxY);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
return nonRotatedBounds as T extends true ? Bounds : RotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap);
const _bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
bounds: _bounds,
});
return bounds;
return _bounds as T extends true ? Bounds : RotatedBounds;
}
private static calculateBounds(
element: ExcalidrawElement,
elementsMap: ElementsMap,
): Bounds {
let bounds: Bounds;
): RotatedBounds {
let _bounds: RotatedBounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
@@ -168,17 +171,16 @@ export class ElementBounds {
),
),
);
const padding =
element.strokeShape === "fixed" ? element.strokeWidth / 2 : 0;
return [
minX + element.x - padding,
minY + element.y - padding,
maxX + element.x + padding,
maxY + element.y + padding,
];
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
element.angle,
);
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
_bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
} else if (element.type === "diamond") {
const [x11, y11] = pointRotateRads(
pointFrom(cx, y1),
@@ -204,7 +206,7 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
_bounds = bounds(minX, minY, maxX, maxY, element.angle);
} else if (element.type === "ellipse") {
const w = (x2 - x1) / 2;
const h = (y2 - y1) / 2;
@@ -212,7 +214,7 @@ export class ElementBounds {
const sin = Math.sin(element.angle);
const ww = Math.hypot(w * cos, h * sin);
const hh = Math.hypot(h * cos, w * sin);
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
_bounds = bounds(cx - ww, cy - hh, cx + ww, cy + hh, element.angle);
} else {
const [x11, y11] = pointRotateRads(
pointFrom(x1, y1),
@@ -238,10 +240,10 @@ export class ElementBounds {
const minY = Math.min(y11, y12, y22, y21);
const maxX = Math.max(x11, x12, x22, x21);
const maxY = Math.max(y11, y12, y22, y21);
bounds = [minX, minY, maxX, maxY];
_bounds = bounds(minX, minY, maxX, maxY, element.angle);
}
return bounds;
return _bounds;
}
}
@@ -626,7 +628,7 @@ export const getCubicBezierCurveBound = (
minY = Math.min(minY, ...ys);
maxY = Math.max(maxY, ...ys);
}
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getMinMaxXYFromCurvePathOps = (
@@ -679,12 +681,11 @@ export const getMinMaxXYFromCurvePathOps = (
},
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[],
padding: number = 0,
export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
): Bounds => {
let minX = Infinity;
let minY = Infinity;
@@ -698,18 +699,17 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y);
}
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
return bounds(minX, minY, maxX, maxY);
};
const getFreeDrawElementAbsoluteCoords = (
element: ExcalidrawFreeDrawElement,
): [number, number, number, number, number, number] => {
const [minX, minY, maxX, maxY] = getBoundsFromPoints(element.points);
const padding = element.strokeShape === "fixed" ? element.strokeWidth / 2 : 0;
const x1 = minX + element.x - padding;
const y1 = minY + element.y - padding;
const x2 = maxX + element.x + padding;
const y2 = maxY + element.y + padding;
const x1 = minX + element.x;
const y1 = minY + element.y;
const x2 = maxX + element.x;
const y2 = maxY + element.y;
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
@@ -942,7 +942,7 @@ const getLinearElementRotatedBounds = (
cx: number,
cy: number,
elementsMap: ElementsMap,
): Bounds => {
): RotatedBounds => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (element.points.length < 2) {
@@ -953,20 +953,21 @@ const getLinearElementRotatedBounds = (
element.angle,
);
let coords: Bounds = [x, y, x, y];
let coords: RotatedBounds = bounds(x, y, x, y, element.angle);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
elementsMap,
[x, y, x, y],
bounds(x, y, x, y, element.angle),
boundTextElement,
);
coords = [
coords = bounds(
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
element.angle,
);
}
return coords;
}
@@ -982,7 +983,13 @@ const getLinearElementRotatedBounds = (
element.angle,
);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: Bounds = [res[0], res[1], res[2], res[3]];
let coords: RotatedBounds = bounds(
res[0],
res[1],
res[2],
res[3],
element.angle,
);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
element,
@@ -990,22 +997,23 @@ const getLinearElementRotatedBounds = (
coords,
boundTextElement,
);
coords = [
coords = bounds(
coordsWithBoundText[0],
coordsWithBoundText[1],
coordsWithBoundText[2],
coordsWithBoundText[3],
];
element.angle,
);
}
return coords;
};
export const getElementBounds = (
export const getElementBounds = <T extends boolean = false>(
element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => {
return ElementBounds.getBounds(element, elementsMap, nonRotated);
nonRotated: T = false as T,
) => {
return ElementBounds.getBounds<T>(element, elementsMap, nonRotated);
};
export const getCommonBounds = (
@@ -1013,7 +1021,7 @@ export const getCommonBounds = (
elementsMap?: ElementsMap,
): Bounds => {
if (!sizeOf(elements)) {
return [0, 0, 0, 0];
return bounds(0, 0, 0, 0);
}
let minX = Infinity;
@@ -1031,7 +1039,7 @@ export const getCommonBounds = (
maxY = Math.max(maxY, y2);
});
return [minX, minY, maxX, maxY];
return bounds(minX, minY, maxX, maxY);
};
export const getDraggedElementsBounds = (
@@ -1054,12 +1062,12 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints: boolean,
): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [
return bounds(
element.x,
element.y,
element.x + nextWidth,
element.y + nextHeight,
];
);
}
const points = rescalePoints(
@@ -1069,11 +1077,11 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints,
);
let bounds: Bounds;
let _bounds: Bounds;
if (isFreeDrawElement(element)) {
// Free Draw
bounds = getBoundsFromPoints(points);
_bounds = getBoundsFromPoints(points);
} else {
// Line
const gen = rough.generator();
@@ -1085,16 +1093,16 @@ export const getResizedElementAbsoluteCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
bounds = getMinMaxXYFromCurvePathOps(ops);
_bounds = getMinMaxXYFromCurvePathOps(ops);
}
const [minX, minY, maxX, maxY] = bounds;
return [
const [minX, minY, maxX, maxY] = _bounds;
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
);
};
export const getElementPointsCoords = (
@@ -1112,20 +1120,20 @@ export const getElementPointsCoords = (
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
);
};
export const getClosestElementBounds = (
elements: readonly ExcalidrawElement[],
from: { x: number; y: number },
): Bounds => {
): RotatedBounds => {
if (!elements.length) {
return [0, 0, 0, 0];
return bounds(0, 0, 0, 0, 0 as Radians);
}
let minDistance = Infinity;
@@ -1194,7 +1202,9 @@ export const getVisibleSceneBounds = ({
];
};
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
export const getCenterForBounds = (
bounds: Bounds | RotatedBounds,
): GlobalPoint =>
pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2,
bounds[1] + (bounds[3] - bounds[1]) / 2,
@@ -1239,24 +1249,24 @@ export const aabbForElement = (
element.angle,
);
const bounds = [
const _bounds = bounds(
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
] as Bounds;
);
if (offset) {
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
return [
bounds[0] - leftOffset,
bounds[1] - topOffset,
bounds[2] + rightOffset,
bounds[3] + downOffset,
] as Bounds;
return bounds(
_bounds[0] - leftOffset,
_bounds[1] - topOffset,
_bounds[2] + rightOffset,
_bounds[3] + downOffset,
);
}
return bounds;
return _bounds;
};
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
@@ -1265,18 +1275,7 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = (
export const doNonRotatedBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
): boolean => {
@@ -1290,14 +1289,6 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
+105 -22
View File
@@ -1,4 +1,9 @@
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
import {
bounds,
invariant,
isTransparent,
type Bounds,
} from "@excalidraw/common";
import {
curveIntersectLineSegment,
isPointWithinBounds,
@@ -27,10 +32,9 @@ import type {
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isLoopFreeDrawElement } from "./freedraw";
import { isPathALoop } from "./utils";
import {
doBoundsIntersect,
doNonRotatedBoundsIntersect,
elementCenterPoint,
getCenterForBounds,
getCubicBezierCurveBound,
@@ -94,7 +98,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
if (element.type === "freedraw") {
return isDraggableFromInside && isLoopFreeDrawElement(element);
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside || isImageElement(element);
@@ -190,6 +194,48 @@ export const hitElementItself = ({
return result;
};
export function getBoundsCorners(
bounds: Bounds,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians,
): readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint];
export function getBoundsCorners(
bounds: Bounds,
angle: Radians = 0 as Radians,
) {
const [x1, y1, x2, y2] = bounds;
const center = getCenterForBounds(bounds);
const corners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
if (angle === 0) {
return corners;
}
return corners.map((point) => pointRotateRads(point, center, angle)) as [
GlobalPoint,
GlobalPoint,
GlobalPoint,
GlobalPoint,
];
}
export const getBoundsEdges = (
corners: readonly [GlobalPoint, GlobalPoint, GlobalPoint, GlobalPoint],
) =>
[
lineSegment(corners[0], corners[1]),
lineSegment(corners[1], corners[2]),
lineSegment(corners[2], corners[3]),
lineSegment(corners[3], corners[0]),
] as const;
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
@@ -218,6 +264,40 @@ export const hitElementBoundingBox = (
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const doBoundsIntersectElementBoundingBox = (
intersectorBounds: Bounds,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
const [x1, y1, x2, y2] = intersectorBounds;
const intersectorCorners = [
pointFrom<GlobalPoint>(x1, y1),
pointFrom<GlobalPoint>(x2, y1),
pointFrom<GlobalPoint>(x2, y2),
pointFrom<GlobalPoint>(x1, y2),
] as const;
const intersectorEdges = getBoundsEdges(intersectorCorners);
const elementBounds = getElementBounds(element, elementsMap, true);
const elementBoundsCorners = getBoundsCorners(elementBounds, element.angle);
const elementBoundsEdges = getBoundsEdges(elementBoundsCorners);
return (
elementBoundsCorners.some((point) =>
isPointWithinBounds(intersectorCorners[0], point, intersectorCorners[2]),
) ||
intersectorCorners.some((point) =>
isPointInRotatedBounds(point, elementBounds, element.angle),
) ||
intersectorEdges.some((selectionEdge) =>
elementBoundsEdges.some(
(elementBoundsEdge) =>
!!lineSegmentIntersectionPoints(selectionEdge, elementBoundsEdge),
),
)
);
};
export const hitElementBoundingBoxOnly = (
hitArgs: HitTestArgs,
elementsMap: ElementsMap,
@@ -268,9 +348,13 @@ const bindingBorderTest = (
// PERF: Run a cheap test to see if the binding element
// is even close to the element
const t = Math.max(1, tolerance);
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
const elementBounds = getElementBounds(element, elementsMap, true);
if (
!doNonRotatedBoundsIntersect(
bounds(x - t, y - t, x + t, y + t),
elementBounds,
)
) {
return false;
}
@@ -281,6 +365,7 @@ const bindingBorderTest = (
const enclosingFrameBounds = getElementBounds(
enclosingFrame,
elementsMap,
true,
);
if (!pointInsideBounds(p, enclosingFrameBounds)) {
return false;
@@ -430,15 +515,15 @@ export const intersectElementWithLineSegment = (
): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
const intersectorBounds = bounds(
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
);
const elementBounds = getElementBounds(element, elementsMap, true);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
if (!doNonRotatedBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
@@ -497,14 +582,14 @@ const curveIntersections = (
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
const b2 = bounds(
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
);
if (!doBoundsIntersect(b1, b2)) {
if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue;
}
@@ -574,20 +659,18 @@ const intersectLinearOrFreeDrawWithLineSegment = (
for (const c of curves) {
// Optimize by doing a cheap bounding box check first
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
const b2 = [
const b2 = bounds(
Math.min(segment[0][0], segment[1][0]),
Math.min(segment[0][1], segment[1][1]),
Math.max(segment[0][0], segment[1][0]),
Math.max(segment[0][1], segment[1][1]),
] as Bounds;
);
if (!doBoundsIntersect(b1, b2)) {
if (!doNonRotatedBoundsIntersect(b1, b2)) {
continue;
}
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
@@ -755,8 +838,8 @@ export const isPointInElement = (
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) && !isPathALoop(element.points)) ||
(isFreeDrawElement(element) && !isLoopFreeDrawElement(element))
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
+11 -13
View File
@@ -1,6 +1,7 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type {
AppClassProperties,
@@ -17,8 +18,6 @@ import {
getElementLineSegments,
getCommonBounds,
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -100,7 +99,7 @@ export const isElementContainingFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
): boolean => {
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
);
@@ -141,7 +140,7 @@ export const elementOverlapsWithFrame = (
element: ExcalidrawElement,
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
): boolean => {
return (
elementsAreInFrameBounds([element], frame, elementsMap) ||
isElementIntersectingFrame(element, frame, elementsMap) ||
@@ -921,17 +920,16 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return elements.filter(
(el) =>
// exclude elements which are overlapping, but are in a different frame,
return (
elementsOverlappingBBox({
elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
// and thus invisible in target frame
(!el.frameId || el.frameId === frame.id) &&
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
.filter((el) => !el.frameId || el.frameId === frame.id)
);
};
-181
View File
@@ -1,181 +0,0 @@
import {
pointDistance,
vectorCross,
vectorDot,
vectorFromPoint,
} from "@excalidraw/math";
import type { LocalPoint } from "@excalidraw/math";
import { isPathALoop } from "./utils";
import type { ExcalidrawFreeDrawElement } from "./types";
type ZoomValue = NonNullable<Parameters<typeof isPathALoop>[1]>;
type FixedFreeDrawSimplificationProfile = {
minPointDistancePx: number;
maxPointDistance: number;
strokeDistanceFactor: number;
collinearityFactor: number;
minAlignment: number;
zoomScaling: "sqrt" | "none";
};
const FIXED_FREEDRAW_CAPTURE_PROFILE: FixedFreeDrawSimplificationProfile = {
minPointDistancePx: 0.35,
maxPointDistance: 0.85,
strokeDistanceFactor: 0.08,
collinearityFactor: 0.3,
minAlignment: 0.985,
zoomScaling: "sqrt",
};
const hasSyntheticLoopClosure = (
points: readonly LocalPoint[],
): points is readonly [LocalPoint, LocalPoint, ...LocalPoint[]] => {
if (points.length < 3) {
return false;
}
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
return firstPoint[0] === lastPoint[0] && firstPoint[1] === lastPoint[1];
};
const stripSyntheticLoopClosure = (points: readonly LocalPoint[]) =>
hasSyntheticLoopClosure(points) ? points.slice(0, -1) : points;
export const isLoopFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
zoomValue: ZoomValue = 1 as ZoomValue,
) => element.strokeShape !== "fixed" && isPathALoop(element.points, zoomValue);
export const getFixedFreeDrawPoints = (
element: ExcalidrawFreeDrawElement,
): readonly LocalPoint[] => stripSyntheticLoopClosure(element.points);
export const getFixedFreeDrawPointSamplingDistance = (
strokeWidth: number,
zoomValue: ZoomValue = 1 as ZoomValue,
profile: FixedFreeDrawSimplificationProfile = FIXED_FREEDRAW_CAPTURE_PROFILE,
) =>
Math.min(
Math.max(
profile.minPointDistancePx /
(profile.zoomScaling === "sqrt"
? Math.max(1, Math.sqrt(zoomValue))
: 1),
strokeWidth * profile.strokeDistanceFactor,
),
profile.maxPointDistance,
);
const isRedundantFixedFreeDrawPoint = (
previousPoint: LocalPoint,
currentPoint: LocalPoint,
nextPoint: LocalPoint,
strokeWidth: number,
zoomValue: ZoomValue,
profile: FixedFreeDrawSimplificationProfile,
) => {
const previousSegmentLength = pointDistance(previousPoint, currentPoint);
const nextSegmentLength = pointDistance(currentPoint, nextPoint);
if (!previousSegmentLength || !nextSegmentLength) {
return true;
}
const previousVector = vectorFromPoint(currentPoint, previousPoint);
const nextVector = vectorFromPoint(nextPoint, currentPoint);
const alignment =
vectorDot(previousVector, nextVector) /
(previousSegmentLength * nextSegmentLength);
if (alignment < profile.minAlignment) {
return false;
}
const chord = vectorFromPoint(nextPoint, previousPoint);
const chordLength = pointDistance(previousPoint, nextPoint);
if (!chordLength) {
return true;
}
const distanceToChord =
Math.abs(vectorCross(vectorFromPoint(currentPoint, previousPoint), chord)) /
chordLength;
return (
distanceToChord <=
getFixedFreeDrawPointSamplingDistance(strokeWidth, zoomValue, profile) *
profile.collinearityFactor
);
};
export const getFixedFreeDrawPointAction = ({
points,
nextPoint,
strokeWidth,
zoomValue,
isFinalPoint = false,
profile,
}: {
points: readonly LocalPoint[];
nextPoint: LocalPoint;
strokeWidth: number;
zoomValue: ZoomValue;
isFinalPoint?: boolean;
profile?: FixedFreeDrawSimplificationProfile;
}) => {
const simplificationProfile = profile ?? FIXED_FREEDRAW_CAPTURE_PROFILE;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
return "append" as const;
}
if (lastPoint[0] === nextPoint[0] && lastPoint[1] === nextPoint[1]) {
return "discard" as const;
}
const samplingDistance = getFixedFreeDrawPointSamplingDistance(
strokeWidth,
zoomValue,
simplificationProfile,
);
if (points.length === 1) {
return !isFinalPoint &&
pointDistance(lastPoint, nextPoint) < samplingDistance
? ("discard" as const)
: ("append" as const);
}
const previousPoint = points[points.length - 2];
if (
isRedundantFixedFreeDrawPoint(
previousPoint,
lastPoint,
nextPoint,
strokeWidth,
zoomValue,
simplificationProfile,
)
) {
return "replace" as const;
}
if (!isFinalPoint && pointDistance(lastPoint, nextPoint) < samplingDistance) {
return "discard" as const;
}
return "append" as const;
};
export const getRenderableFixedFreeDrawPoints = (
element: ExcalidrawFreeDrawElement,
): readonly LocalPoint[] => getFixedFreeDrawPoints(element);
-1
View File
@@ -69,7 +69,6 @@ export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./freedraw";
export * from "./flowchart";
export * from "./arrows/focus";
export * from "./fractionalIndex";
+4 -3
View File
@@ -21,6 +21,7 @@ import {
invariant,
isShallowEqual,
getFeatureFlag,
bounds,
} from "@excalidraw/common";
import {
@@ -42,7 +43,7 @@ import type {
NullableGridSize,
Zoom,
} from "@excalidraw/excalidraw/types";
import type { Bounds } from "@excalidraw/common";
import type { RotatedBounds } from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -1879,7 +1880,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementsMap: ElementsMap,
elementBounds: Bounds,
elementBounds: RotatedBounds,
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
@@ -2004,7 +2005,7 @@ export class LinearElementEditor {
return LinearElementEditor.getMinMaxXYWithBoundText(
element,
elementsMap,
[x1, y1, x2, y2],
bounds(x1, y1, x2, y2, element.angle),
boundTextElement,
);
}
-27
View File
@@ -1,5 +1,4 @@
import {
DEFAULT_FREE_DRAW_STROKE_SHAPE,
getSizeFromPoints,
randomInteger,
getUpdatedTimestamp,
@@ -19,8 +18,6 @@ import type {
ElementsMap,
ExcalidrawElbowArrowElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
FreeDrawStrokeShape,
NonDeletedSceneElementsMap,
} from "./types";
@@ -180,30 +177,6 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
};
};
export const newFreeDrawElementWithStrokeShape = <
TElement extends ExcalidrawFreeDrawElement,
>(
element: TElement,
strokeShape: FreeDrawStrokeShape,
): TElement => {
if (strokeShape === DEFAULT_FREE_DRAW_STROKE_SHAPE) {
if (!("strokeShape" in element)) {
return element;
}
const nextElement = newElementWith(
element,
{} as ElementUpdate<TElement>,
true,
);
delete (nextElement as Mutable<Partial<TElement>>).strokeShape;
return nextElement;
}
return newElementWith(element, {
strokeShape,
} as ElementUpdate<TElement>);
};
/**
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
*
-2
View File
@@ -445,7 +445,6 @@ export const newFreeDrawElement = (
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"];
strokeShape?: ExcalidrawFreeDrawElement["strokeShape"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
@@ -453,7 +452,6 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
...(opts.strokeShape === "fixed" ? { strokeShape: opts.strokeShape } : {}),
};
};
+2 -13
View File
@@ -417,27 +417,16 @@ const drawElementOnCanvas = (
case "freedraw": {
// Draw directly to canvas
context.save();
context.lineJoin = "round";
context.lineCap = "round";
const shapes = ShapeCache.generateElementShape(element, renderConfig);
const isFixedStroke = element.strokeShape === "fixed";
for (const shape of shapes) {
if (typeof shape === "string") {
const strokeColor =
context.fillStyle =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
if (isFixedStroke) {
context.strokeStyle = strokeColor;
context.lineWidth = element.strokeWidth;
context.stroke(new Path2D(shape));
} else {
context.fillStyle = strokeColor;
context.fill(new Path2D(shape));
}
context.fill(new Path2D(shape));
} else {
rc.draw(shape);
}
+336 -230
View File
@@ -2,8 +2,8 @@ import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
import type {
@@ -13,17 +13,20 @@ import type {
} from "@excalidraw/excalidraw/types";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
getElementLineSegments,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import {
doBoundsIntersectElementBoundingBox,
getBoundsCorners,
getBoundsEdges,
intersectElementWithLineSegment,
isPointInElement,
shouldTestInside,
} from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
@@ -34,38 +37,218 @@ import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
const shouldIgnoreElementFromSelection = (
// Broad-phase only for overlap mode. Rotated closed shapes should not select
// from the empty corners of their axis-aligned bounds. Linear elements and
// freedraw already rely on the outline-specific path below, so exclude them.
const shouldUseRotatedOverlapBroadPhase = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
) =>
element.angle !== 0 &&
!isLinearElement(element) &&
!isFreeDrawElement(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
const clipLineSegmentToBounds = (
segment: LineSegment<GlobalPoint>,
bounds: Bounds,
): LineSegment<GlobalPoint> | null => {
const [minX, minY, maxX, maxY] = bounds;
const [[x1, y1], [x2, y2]] = segment;
const deltaX = x2 - x1;
const deltaY = y2 - y1;
let tMin = 0;
let tMax = 1;
const clip = (p: number, q: number) => {
if (p === 0) {
return q >= 0;
}
const ratio = q / p;
if (p < 0) {
if (ratio > tMax) {
return false;
}
tMin = Math.max(tMin, ratio);
return true;
}
if (ratio < tMin) {
return false;
}
tMax = Math.min(tMax, ratio);
return true;
};
if (
!clip(-deltaX, x1 - minX) ||
!clip(deltaX, maxX - x1) ||
!clip(-deltaY, y1 - minY) ||
!clip(deltaY, maxY - y1)
) {
return null;
}
return lineSegment(
pointFrom<GlobalPoint>(x1 + tMin * deltaX, y1 + tMin * deltaY),
pointFrom<GlobalPoint>(x1 + tMax * deltaX, y1 + tMax * deltaY),
);
};
const isPointWithinAabb = (point: GlobalPoint, bounds: Bounds) =>
point[0] >= bounds[0] &&
point[0] <= bounds[2] &&
point[1] >= bounds[1] &&
point[1] <= bounds[3];
const shouldUsePreciseFilledOverlap = (element: NonDeletedExcalidrawElement) =>
element.type === "ellipse" ||
element.type === "diamond" ||
(element.type === "rectangle" && !!element.roundness);
const shouldSkipElementFromSelection = (element: NonDeletedExcalidrawElement) =>
element.locked || element.type === "selection" || isBoundToContainer(element);
const getFrameBoundsForSelection = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
): Bounds | null => {
if (!element.frameId) {
return null;
}
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame
? (getElementBounds(containingFrame, elementsMap) as Bounds)
: null;
};
const finalizeElementsInSelection = (
elementsInSelection: NonDeletedExcalidrawElement[],
excludeElementsInFrames: boolean,
elementsMap: ElementsMap,
): NonDeletedExcalidrawElement[] => {
elementsInSelection = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
return elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
};
const getVisibleElementOutlineSegments = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
elementsMap: ElementsMap,
) =>
frameBounds
? getElementLineSegments(element, elementsMap).flatMap((segment) => {
const clippedSegment = clipLineSegmentToBounds(segment, frameBounds);
return clippedSegment ? [clippedSegment] : [];
})
: getElementLineSegments(element, elementsMap);
const doesSelectionIntersectElementOutline = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) =>
selectionEdges.some((selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
0,
true,
).some((point) => !frameBounds || isPointWithinAabb(point, frameBounds)),
);
const doesSelectionContainElementOutline = (
outlineSegments: readonly LineSegment<GlobalPoint>[],
selectionBounds: Bounds,
) =>
outlineSegments.length > 0 &&
outlineSegments.every(
(outlineSegment) =>
isPointWithinAabb(outlineSegment[0], selectionBounds) &&
isPointWithinAabb(outlineSegment[1], selectionBounds),
);
const doesSelectionContainElementInterior = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionCorners: readonly GlobalPoint[],
elementsMap: ElementsMap,
) =>
selectionCorners.some(
(selectionCorner) =>
(!frameBounds || isPointWithinAabb(selectionCorner, frameBounds)) &&
isPointInElement(selectionCorner, element, elementsMap),
);
const doesSelectionOverlapFilledElement = (
element: NonDeletedExcalidrawElement,
frameBounds: Bounds | null,
selectionBounds: Bounds,
selectionCorners: readonly GlobalPoint[],
selectionEdges: readonly LineSegment<GlobalPoint>[],
elementsMap: ElementsMap,
) => {
if (
doesSelectionContainElementInterior(
element,
frameBounds,
selectionCorners,
elementsMap,
)
) {
return true;
}
if (
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
return true;
}
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
);
return (
outlineSegments.length > 0 &&
doesSelectionContainElementOutline(outlineSegments, selectionBounds)
);
};
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
@@ -85,14 +268,18 @@ export const excludeElementsInFramesFromSelection = <
}
});
return excludeElementsFromFrames(selectedElements, framesInSelection);
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain",
): NonDeletedExcalidrawElement[] => {
@@ -108,233 +295,152 @@ 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),
),
];
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
if (boxSelectionMode !== "overlap") {
const elementsInSelection: NonDeletedExcalidrawElement[] = [];
for (const element of elements) {
if (shouldIgnoreElementFromSelection(element)) {
continue;
}
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 &&
isElementIntersectingFrame(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);
} else {
elementsInSelection.push(element);
for (const element of elements) {
if (shouldSkipElementFromSelection(element)) {
continue;
}
const elementBounds = getElementBounds(element, elementsMap) as Bounds;
const frameBounds = getFrameBoundsForSelection(element, elementsMap);
let elementX1 = elementBounds[0];
let elementY1 = elementBounds[1];
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
if (
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
) {
elementsInSelection.push(element);
}
}
return finalizeElementsInSelection(
elementsInSelection,
excludeElementsInFrames,
elementsMap,
);
}
const selectionCorners = getBoundsCorners(selectionBounds);
const selectionEdges = getBoundsEdges(selectionCorners);
const elementsInSelection: NonDeletedExcalidrawElement[] = [];
for (const element of elements) {
if (shouldSkipElementFromSelection(element)) {
continue;
}
const elementBounds = getElementBounds(element, elementsMap) as Bounds;
const frameBounds = getFrameBoundsForSelection(element, elementsMap);
let elementX1 = elementBounds[0];
let elementY1 = elementBounds[1];
let elementX2 = elementBounds[2];
let elementY2 = elementBounds[3];
if (frameBounds) {
elementX1 = Math.max(frameBounds[0], elementX1);
elementY1 = Math.max(frameBounds[1], elementY1);
elementX2 = Math.min(frameBounds[2], elementX2);
elementY2 = Math.min(frameBounds[3], elementY2);
}
const isSelectionContainingElement =
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2;
const isSelectionOverlappingElementAabb =
selectionX1 <= elementX2 &&
selectionY1 <= elementY2 &&
selectionX2 >= elementX1 &&
selectionY2 >= elementY1;
const isSelectionOverlappingElement = shouldUseRotatedOverlapBroadPhase(
element,
)
? isSelectionOverlappingElementAabb &&
doBoundsIntersectElementBoundingBox(
selectionBounds,
element,
elementsMap,
)
: isSelectionOverlappingElementAabb;
const shouldSelectFromInside = shouldTestInside(element);
if (shouldSelectFromInside) {
if (
isSelectionOverlappingElement &&
(!shouldUsePreciseFilledOverlap(element) ||
isSelectionContainingElement ||
doesSelectionOverlapFilledElement(
element,
frameBounds,
selectionBounds,
selectionCorners,
selectionEdges,
elementsMap,
))
) {
elementsInSelection.push(element);
}
continue;
}
if (!isSelectionOverlappingElement) {
continue;
}
if (isSelectionContainingElement) {
elementsInSelection.push(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
doesSelectionIntersectElementOutline(
element,
frameBounds,
selectionEdges,
elementsMap,
)
) {
elementsInSelection.push(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
const outlineSegments = getVisibleElementOutlineSegments(
element,
frameBounds,
elementsMap,
);
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
outlineSegments.length > 0 &&
doesSelectionContainElementOutline(outlineSegments, selectionBounds)
) {
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.push(element);
continue;
}
elementsInSelection.push(element);
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
return true;
});
return elementsInSelection;
return finalizeElementsInSelection(
elementsInSelection,
excludeElementsInFrames,
elementsMap,
);
};
export const getVisibleAndNonSelectedElements = (
+9 -236
View File
@@ -1,5 +1,4 @@
import { simplify } from "points-on-curve";
import { DEFAULT_FREE_DRAW_STROKE_SHAPE } from "@excalidraw/common";
import { getStroke } from "perfect-freehand";
import {
@@ -14,7 +13,6 @@ import {
import {
pointFrom,
pointDistance,
round,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
@@ -54,10 +52,6 @@ import {
isIframeLikeElement,
isLinearElement,
} from "./typeChecks";
import {
getRenderableFixedFreeDrawPoints,
isLoopFreeDrawElement,
} from "./freedraw";
import { getCornerRadius, isPathALoop } from "./utils";
import { headingForPointIsHorizontal } from "./heading";
@@ -250,12 +244,7 @@ export const generateRoughOptions = (
}
case "line":
case "freedraw": {
const isLoop =
element.type === "freedraw"
? isLoopFreeDrawElement(element)
: isPathALoop(element.points);
if (isLoop) {
if (isPathALoop(element.points)) {
options.fillStyle = element.fillStyle;
options.fill =
element.backgroundColor === "transparent"
@@ -977,7 +966,7 @@ const _generateElementShape = (
const shapes: ElementShapes[typeof element.type] = [];
// (1) background fill (rc shape), optional
if (isLoopFreeDrawElement(element)) {
if (isPathALoop(element.points)) {
// generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
@@ -1184,13 +1173,6 @@ export const toggleLinePolygonState = (
// NOTE not cached (-> for SVG export)
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
if (element.strokeShape === "fixed") {
return getSvgPathFromFixedFreeDrawPoints(
getRenderableFixedFreeDrawPoints(element),
element.strokeWidth,
) as SVGPathString;
}
return getSvgPathFromStroke(
getFreedrawOutlinePoints(element),
) as SVGPathString;
@@ -1199,243 +1181,34 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
) => {
const strokeShape = element.strokeShape ?? DEFAULT_FREE_DRAW_STROKE_SHAPE;
const isFixedStroke = strokeShape === "fixed";
// If input points are empty (should they ever be?) return a dot
const inputPoints = isFixedStroke
? element.points.length
? element.points
: [[0, 0]]
: element.simulatePressure
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: isFixedStroke ? false : element.simulatePressure,
size: isFixedStroke ? element.strokeWidth : element.strokeWidth * 4.25,
thinning: isFixedStroke ? 0 : 0.6,
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: isFixedStroke ? (t) => t : (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const med = (A: readonly number[], B: readonly number[]) => {
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
const roundPoint = (point: readonly number[]) =>
`${round(point[0], 2)},${round(point[1], 2)} `;
const averagePoint = (A: readonly number[], B: readonly number[]) =>
`${round((A[0] + B[0]) / 2, 2)},${round((A[1] + B[1]) / 2, 2)} `;
const getReadonlyPointDistance = (
pointA: readonly number[],
pointB: readonly number[],
) => Math.hypot(pointA[0] - pointB[0], pointA[1] - pointB[1]);
// Trim SVG path data so number are each two decimal points. This
// improves SVG exports, and prevents rendering errors on points
// with long decimals.
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
const getSvgPathFromPoints = (
points: ReadonlyArray<readonly number[]>,
closed = false,
): string => {
const len = points.length;
if (len < 2) {
return "";
}
const path = points
.slice(1)
.map((point) => `L${roundPoint(point)}`)
.join("");
return `M${roundPoint(points[0])}${path}${closed ? "Z" : ""}`;
};
const FIXED_FREEDRAW_MIN_SMOOTH_ALIGNMENT = 0.6;
const FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH = 0.2;
const FIXED_FREEDRAW_MIN_CORNER_ALIGNMENT = -0.25;
const FIXED_FREEDRAW_MIN_CORNER_ROUNDING = 0.75;
const FIXED_FREEDRAW_MAX_CORNER_ROUNDING = 6;
const FIXED_FREEDRAW_CORNER_ROUNDING_FACTOR = 0.35;
const FIXED_FREEDRAW_CORNER_ROUNDING_WIDTH_FACTOR = 1.5;
const FIXED_FREEDRAW_MIN_TERMINAL_STUB = 0.75;
const FIXED_FREEDRAW_MAX_TERMINAL_STUB = 2.5;
const FIXED_FREEDRAW_TERMINAL_STUB_WIDTH_FACTOR = 1.5;
const shouldSmoothFixedFreeDrawPoint = (
previousPoint: readonly number[],
currentPoint: readonly number[],
nextPoint: readonly number[],
) => {
const previousDeltaX = currentPoint[0] - previousPoint[0];
const previousDeltaY = currentPoint[1] - previousPoint[1];
const nextDeltaX = nextPoint[0] - currentPoint[0];
const nextDeltaY = nextPoint[1] - currentPoint[1];
const previousSegmentLength = Math.hypot(previousDeltaX, previousDeltaY);
const nextSegmentLength = Math.hypot(nextDeltaX, nextDeltaY);
if (
previousSegmentLength < FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH ||
nextSegmentLength < FIXED_FREEDRAW_MIN_SMOOTH_SEGMENT_LENGTH
) {
return false;
}
const alignment =
(previousDeltaX * nextDeltaX + previousDeltaY * nextDeltaY) /
(previousSegmentLength * nextSegmentLength);
return alignment >= FIXED_FREEDRAW_MIN_SMOOTH_ALIGNMENT;
};
const getFixedFreeDrawRoundedCorner = (
previousPoint: readonly number[],
currentPoint: readonly number[],
nextPoint: readonly number[],
strokeWidth: number,
) => {
const previousDeltaX = currentPoint[0] - previousPoint[0];
const previousDeltaY = currentPoint[1] - previousPoint[1];
const nextDeltaX = nextPoint[0] - currentPoint[0];
const nextDeltaY = nextPoint[1] - currentPoint[1];
const previousSegmentLength = Math.hypot(previousDeltaX, previousDeltaY);
const nextSegmentLength = Math.hypot(nextDeltaX, nextDeltaY);
if (!previousSegmentLength || !nextSegmentLength) {
return null;
}
const alignment =
(previousDeltaX * nextDeltaX + previousDeltaY * nextDeltaY) /
(previousSegmentLength * nextSegmentLength);
if (
alignment >= FIXED_FREEDRAW_MIN_SMOOTH_ALIGNMENT ||
alignment <= FIXED_FREEDRAW_MIN_CORNER_ALIGNMENT
) {
return null;
}
const cornerRounding = Math.min(
Math.max(
strokeWidth * FIXED_FREEDRAW_CORNER_ROUNDING_WIDTH_FACTOR,
FIXED_FREEDRAW_MIN_CORNER_ROUNDING,
),
previousSegmentLength * FIXED_FREEDRAW_CORNER_ROUNDING_FACTOR,
nextSegmentLength * FIXED_FREEDRAW_CORNER_ROUNDING_FACTOR,
FIXED_FREEDRAW_MAX_CORNER_ROUNDING,
);
if (!cornerRounding) {
return null;
}
return {
entryPoint: [
currentPoint[0] - (previousDeltaX / previousSegmentLength) * cornerRounding,
currentPoint[1] - (previousDeltaY / previousSegmentLength) * cornerRounding,
] as const,
exitPoint: [
currentPoint[0] + (nextDeltaX / nextSegmentLength) * cornerRounding,
currentPoint[1] + (nextDeltaY / nextSegmentLength) * cornerRounding,
] as const,
};
};
const getFixedFreeDrawTerminalStubThreshold = (strokeWidth: number) =>
Math.min(
Math.max(
strokeWidth * FIXED_FREEDRAW_TERMINAL_STUB_WIDTH_FACTOR,
FIXED_FREEDRAW_MIN_TERMINAL_STUB,
),
FIXED_FREEDRAW_MAX_TERMINAL_STUB,
);
const getSvgPathFromFixedFreeDrawPoints = (
points: ReadonlyArray<readonly number[]>,
strokeWidth: number,
): string => {
const len = points.length;
if (len < 2) {
return "";
}
if (len === 2) {
return `M${roundPoint(points[0])}L${roundPoint(points[1])}`;
}
let path = `M${roundPoint(points[0])}`;
const lastPoint = points[len - 1];
let endsAtLastPoint = false;
for (let index = 1; index < len - 1; index++) {
const previousPoint = points[index - 1];
const currentPoint = points[index];
const nextPoint = points[index + 1];
const isLastCurveSegment = index === len - 2;
const terminalStubThreshold = getFixedFreeDrawTerminalStubThreshold(
strokeWidth,
);
const shouldSmooth = shouldSmoothFixedFreeDrawPoint(
previousPoint,
currentPoint,
nextPoint,
);
const roundedCorner = shouldSmooth
? null
: getFixedFreeDrawRoundedCorner(
previousPoint,
currentPoint,
nextPoint,
strokeWidth,
);
const shouldCollapseSmoothTerminalStub =
isLastCurveSegment &&
shouldSmooth &&
getReadonlyPointDistance(currentPoint, nextPoint) / 2 <=
terminalStubThreshold;
const shouldCollapseRoundedTerminalStub =
isLastCurveSegment &&
!!roundedCorner &&
getReadonlyPointDistance(roundedCorner.exitPoint, lastPoint) <=
terminalStubThreshold;
path += shouldSmooth
? `Q${roundPoint(currentPoint)}${
shouldCollapseSmoothTerminalStub
? roundPoint(lastPoint)
: averagePoint(currentPoint, nextPoint)
}`
: roundedCorner
? `L${roundPoint(roundedCorner.entryPoint)}Q${roundPoint(
currentPoint,
)}${
shouldCollapseRoundedTerminalStub
? roundPoint(lastPoint)
: roundPoint(roundedCorner.exitPoint)
}`
: `L${roundPoint(currentPoint)}`;
endsAtLastPoint =
shouldCollapseSmoothTerminalStub || shouldCollapseRoundedTerminalStub;
}
return endsAtLastPoint ? path : `${path}L${roundPoint(lastPoint)}`;
};
const getSvgPathFromStroke = (
points: ReadonlyArray<readonly number[]>,
): string => {
const getSvgPathFromStroke = (points: number[][]): string => {
if (!points.length) {
return "";
}
-2
View File
@@ -26,7 +26,6 @@ export type PointerType = "mouse" | "pen" | "touch";
export type StrokeRoundness = "round" | "sharp";
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
export type StrokeStyle = "solid" | "dashed" | "dotted";
export type FreeDrawStrokeShape = "variable" | "fixed";
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
@@ -391,7 +390,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeShape?: FreeDrawStrokeShape;
}>;
export type FileId = string & { _brand: "FileId" };
+1 -30
View File
@@ -6,11 +6,7 @@ import type { LocalPoint } from "@excalidraw/math";
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
} from "../src/types";
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
const _ce = ({
x,
@@ -121,31 +117,6 @@ describe("getElementBounds", () => {
expect(y2).toEqual(42.90569415042095);
});
it("fixed freedraw", () => {
const element = {
..._ce({
x: 40,
y: 30,
w: 10,
h: 0,
a: 0,
t: "freedraw",
}),
strokeWidth: 8,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(10, 0)],
pressures: [],
simulatePressure: true,
strokeShape: "fixed",
} as unknown as ExcalidrawFreeDrawElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(36);
expect(y1).toEqual(26);
expect(x2).toEqual(54);
expect(y2).toEqual(34);
});
it("curved line", () => {
const element = {
..._ce({
+2 -4
View File
@@ -1,4 +1,4 @@
import { arrayToMap, reseed } from "@excalidraw/common";
import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,7 +12,6 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
@@ -30,7 +29,7 @@ describe("check rotated elements can be hit:", () => {
] as LocalPoint[],
});
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
point: pointFrom<GlobalPoint>(87, -67),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
@@ -57,7 +56,6 @@ describe("hitElementItself cache", () => {
});
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
-160
View File
@@ -1,160 +0,0 @@
import { pointFrom } from "@excalidraw/math";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { vi } from "vitest";
vi.mock("perfect-freehand", () => ({
getStroke: vi.fn(() => []),
}));
import { getStroke } from "perfect-freehand";
import { getFreedrawOutlinePoints, ShapeCache } from "../src/shape";
describe("freedraw stroke shape", () => {
beforeEach(() => {
vi.mocked(getStroke).mockClear();
ShapeCache.destroy();
});
it("renders fixed strokes from the centerline path", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)],
});
const shapes = ShapeCache.generateElementShape(element, null);
expect(shapes).toEqual([expect.any(String)]);
expect(shapes[0]).not.toContain("Z");
expect(vi.mocked(getStroke)).not.toHaveBeenCalled();
});
it("rounds fixed stroke corners without flattening them", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
strokeWidth: 1,
points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(10, 10)],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toBe("M0,0 L8.5,0 Q10,0 10,1.5 L10,10 ");
});
it("does not round fixed stroke hairpin turns", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
strokeWidth: 1,
points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(9, 1)],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toBe("M0,0 L10,0 L9,1 ");
});
it("curves directly to the endpoint for tiny final smooth segments", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
strokeWidth: 1,
points: [pointFrom(0, 0), pointFrom(0.5, 0.1), pointFrom(0.8, 0.3)],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toBe("M0,0 Q0.5,0.1 0.8,0.3 ");
});
it("curves directly to the endpoint for tiny final rounded corners", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
strokeWidth: 1,
points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(10, 1)],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toBe("M0,0 L9.65,0 Q10,0 10,1 ");
});
it("smooths dense fixed stroke points without dropping them", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
points: [
pointFrom(0, 0),
pointFrom(0.3, 0.1),
pointFrom(0.6, 0.35),
pointFrom(1, 0.9),
],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toContain("Q0.3,0.1 ");
expect(path).toContain("Q0.6,0.35 ");
});
it("drops synthetic loop closure for fixed strokes", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
points: [pointFrom(0, 0), pointFrom(10, 0), pointFrom(0, 0)],
});
const [path] = ShapeCache.generateElementShape(element, null);
expect(path).toBe("M0,0 L10,0 ");
});
it("uses fixed perfect-freehand settings for fixed strokes", () => {
const element = API.createElement({
type: "freedraw",
strokeShape: "fixed",
strokeWidth: 8,
points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)],
});
getFreedrawOutlinePoints(element);
expect(vi.mocked(getStroke)).toHaveBeenCalledWith(
element.points,
expect.objectContaining({
simulatePressure: false,
size: element.strokeWidth,
thinning: 0,
}),
);
});
it("keeps pressure-aware perfect-freehand settings for variable strokes", () => {
const element = API.createElement({
type: "freedraw",
points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)],
});
getFreedrawOutlinePoints({
...element,
simulatePressure: false,
pressures: [0.2, 0.8, 0.4],
});
expect(vi.mocked(getStroke)).toHaveBeenCalledWith(
[
[0, 0, 0.2],
[10, 10, 0.8],
[20, 15, 0.4],
],
expect.objectContaining({
simulatePressure: false,
size: element.strokeWidth * 4.25,
thinning: 0.6,
}),
);
});
});
@@ -2,7 +2,6 @@ import { pointFrom } from "@excalidraw/math";
import { bindOrUnbindBindingElement } from "@excalidraw/element/binding";
import {
isLoopFreeDrawElement,
isValidPolygon,
LinearElementEditor,
newElementWith,
@@ -243,11 +242,7 @@ export const actionFinalize = register<FormData>({
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isLineElement(element)
? isPathALoop(element.points, appState.zoom.value)
: isFreeDrawElement(element)
? isLoopFreeDrawElement(element, appState.zoom.value)
: false;
const isLoop = isPathALoop(element.points, appState.zoom.value);
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
const linePoints = element.points;
@@ -1,5 +1,4 @@
import { queryByTestId } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math";
import {
COLOR_PALETTE,
@@ -11,7 +10,7 @@ import {
import { Excalidraw } from "../index";
import { API } from "../tests/helpers/api";
import { UI } from "../tests/helpers/ui";
import { act, render } from "../tests/test-utils";
import { render } from "../tests/test-utils";
describe("element locking", () => {
beforeEach(async () => {
@@ -81,20 +80,6 @@ describe("element locking", () => {
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
it("should show the active freedraw stroke type", () => {
UI.clickTool("freedraw");
API.setAppState({
currentItemStrokeShape: "fixed",
});
const fixedStrokeShape = queryByTestId(
document.body,
"strokeShape-fixed",
);
expect(fixedStrokeShape).toBeChecked();
});
});
describe("properties when elements selected", () => {
@@ -159,9 +144,6 @@ describe("element locking", () => {
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-medium`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
@@ -177,7 +159,6 @@ describe("element locking", () => {
});
const text = API.createElement({
type: "text",
strokeWidth: STROKE_WIDTH.bold,
fontFamily: FONT_FAMILY["Comic Shanns"],
});
API.setElements([rect, text]);
@@ -188,35 +169,5 @@ describe("element locking", () => {
"active",
);
});
it("should highlight the fixed freedraw stroke type for selected elements", () => {
const freedraw = API.createElement({
type: "freedraw",
strokeShape: "fixed",
points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)],
});
API.setElements([freedraw]);
API.setSelectedElements([freedraw]);
const fixedStrokeShape = queryByTestId(
document.body,
"strokeShape-fixed",
);
expect(fixedStrokeShape).toBeChecked();
});
it("should apply fixed freedraw stroke type to newly drawn strokes", () => {
UI.clickTool("freedraw");
act(() => {
queryByTestId(document.body, "strokeShape-fixed")?.click();
});
const freedraw = UI.createElement("freedraw", {
points: [pointFrom(0, 0), pointFrom(10, 10), pointFrom(20, 15)],
});
expect(freedraw.strokeShape).toBe("fixed");
});
});
});
@@ -3,7 +3,6 @@ import { pointFrom } from "@excalidraw/math";
import { useEffect, useMemo, useRef, useState } from "react";
import {
DEFAULT_FREE_DRAW_STROKE_SHAPE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
DEFAULT_ELEMENT_STROKE_COLOR_PALETTE,
@@ -37,7 +36,6 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
import { newFreeDrawElementWithStrokeShape } from "@excalidraw/element";
import { getArrowheadForPicker } from "@excalidraw/element";
import {
@@ -49,7 +47,6 @@ import {
isArrowElement,
isBoundToContainer,
isElbowArrow,
isFreeDrawElement,
isLinearElement,
isLineElement,
isTextElement,
@@ -73,7 +70,6 @@ import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
@@ -109,11 +105,8 @@ import {
SloppinessArtistIcon,
SloppinessCartoonistIcon,
StrokeWidthBaseIcon,
StrokeWidthMediumIcon,
StrokeWidthBoldIcon,
StrokeWidthExtraBoldIcon,
StrokeShapeFixedIcon,
StrokeShapeVariableIcon,
FontSizeSmallIcon,
FontSizeMediumIcon,
FontSizeLargeIcon,
@@ -194,29 +187,6 @@ export const changeProperty = (
});
};
const getFreeDrawStrokeShape = (item: {
strokeShape?: ExcalidrawFreeDrawElement["strokeShape"];
currentItemStrokeShape?: AppState["currentItemStrokeShape"];
}) =>
item.strokeShape ??
item.currentItemStrokeShape ??
DEFAULT_FREE_DRAW_STROKE_SHAPE;
const getAppStateWithCurrentItemStrokeShape = (
appState: AppState,
strokeShape: NonNullable<ExcalidrawFreeDrawElement["strokeShape"]>,
) => {
// if (strokeShape === DEFAULT_FREE_DRAW_STROKE_SHAPE) {
// const { currentItemStrokeShape, ...nextAppState } = appState;
// return nextAppState;
// }
return {
...appState,
currentItemStrokeShape: strokeShape,
};
};
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
@@ -604,12 +574,6 @@ export const actionChangeStrokeWidth = register<
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.medium,
text: t("labels.medium"),
icon: StrokeWidthMediumIcon,
testId: "strokeWidth-medium",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
@@ -638,60 +602,6 @@ export const actionChangeStrokeWidth = register<
),
});
export const actionChangeStrokeShape = register<
NonNullable<ExcalidrawFreeDrawElement["strokeShape"]>
>({
name: "changeStrokeShape",
label: "labels.strokeShape",
trackEvent: false,
perform: (elements, appState, value) => {
invariant(value, "actionChangeStrokeShape: value must be defined");
return {
elements: changeProperty(elements, appState, (el) =>
isFreeDrawElement(el)
? newFreeDrawElementWithStrokeShape(el, value)
: el,
),
appState: getAppStateWithCurrentItemStrokeShape(appState, value),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.strokeShape")}</legend>
<div className="buttonList">
<RadioSelection
group="stroke-shape"
options={[
{
value: "variable",
text: t("labels.strokeShape_variable"),
icon: StrokeShapeVariableIcon,
testId: "strokeShape-variable",
},
{
value: "fixed",
text: t("labels.strokeShape_fixed"),
icon: StrokeShapeFixedIcon,
testId: "strokeShape-fixed",
},
]}
value={getFormValue(
elements,
app,
(element) =>
getFreeDrawStrokeShape(element as ExcalidrawFreeDrawElement),
isFreeDrawElement,
(hasSelection) =>
hasSelection ? null : getFreeDrawStrokeShape(appState),
)}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
),
});
export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
name: "changeSloppiness",
label: "labels.sloppiness",
@@ -1,5 +1,4 @@
import {
DEFAULT_FREE_DRAW_STROKE_SHAPE,
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
@@ -11,7 +10,6 @@ import {
import { newElementWith } from "@excalidraw/element";
import {
isFreeDrawElement,
hasBoundTextElement,
canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement,
@@ -20,7 +18,6 @@ import {
isExcalidrawElement,
isTextElement,
} from "@excalidraw/element";
import { newFreeDrawElementWithStrokeShape } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -157,17 +154,6 @@ export const actionPasteStyles = register({
});
}
if (
isFreeDrawElement(newElement) &&
isFreeDrawElement(elementStylesToCopyFrom)
) {
newElement = newFreeDrawElementWithStrokeShape(
newElement,
elementStylesToCopyFrom.strokeShape ??
DEFAULT_FREE_DRAW_STROKE_SHAPE,
);
}
if (isFrameLikeElement(element)) {
newElement = newElementWith(newElement, {
roundness: null,
-2
View File
@@ -128,7 +128,6 @@ export const getDefaultAppState = (): Omit<
lockedMultiSelections: {},
activeLockedId: null,
bindMode: "orbit",
boxSelectionMode: "contain",
};
};
@@ -172,7 +171,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemStrokeShape: { 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 },
@@ -394,11 +394,6 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{(appState.activeTool.type === "freedraw" ||
targetElements.some(
(element) => element.type === "freedraw",
)) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
+30 -90
View File
@@ -9,7 +9,6 @@ import {
clamp,
pointFrom,
pointDistance,
round,
vector,
pointRotateRads,
vectorFromPoint,
@@ -120,7 +119,6 @@ import {
getElementAbsoluteCoords,
bindOrUnbindBindingElements,
fixBindingsAfterDeletion,
getFixedFreeDrawPointAction,
getHoveredElementForBinding,
isBindingEnabled,
updateBoundElements,
@@ -248,7 +246,7 @@ import {
bindOrUnbindBindingElement,
mutateElement,
getElementBounds,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
isPointInElement,
maxBindingDistance_simple,
convertToExcalidrawElements,
@@ -617,19 +615,6 @@ const gesture: Gesture = {
initialScale: null,
};
const FREEDRAW_POINT_DECIMALS = 2;
const FREEDRAW_PRESSURE_DECIMALS = 3;
const FREEDRAW_DOT_EPSILON = 1 / 10 ** FREEDRAW_POINT_DECIMALS;
const roundFreeDrawCoordinate = (value: number) =>
round(value, FREEDRAW_POINT_DECIMALS);
const roundFreeDrawPressure = (value: number) =>
round(value, FREEDRAW_PRESSURE_DECIMALS);
const getRoundedFreeDrawPoint = (x: number, y: number) =>
pointFrom<LocalPoint>(roundFreeDrawCoordinate(x), roundFreeDrawCoordinate(y));
class App extends React.Component<AppProps, AppState> {
canvas: AppClassProperties["canvas"];
interactiveCanvas: AppClassProperties["interactiveCanvas"] = null;
@@ -1169,7 +1154,7 @@ class App extends React.Component<AppProps, AppState> {
startBounds &&
endBounds &&
startElement.id !== endElement.id &&
doBoundsIntersect(startBounds, endBounds)
doNonRotatedBoundsIntersect(startBounds, endBounds)
);
}
@@ -2540,7 +2525,6 @@ class App extends React.Component<AppProps, AppState> {
const magicFrameChildren = getElementsOverlappingFrame(
this.scene.getNonDeletedElements(),
magicFrame,
this.scene.getNonDeletedElementsMap(),
).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) {
@@ -8095,7 +8079,7 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING);
let { clientX: lastX, clientY: lastY } = event;
const onPointerMove = ((event: PointerEvent) => {
const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
const deltaX = lastX - event.clientX;
const deltaY = lastY - event.clientY;
lastX = event.clientX;
@@ -8161,7 +8145,7 @@ class App extends React.Component<AppProps, AppState> {
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.removeEventListener(EVENT.POINTER_UP, teardown);
window.removeEventListener(EVENT.BLUR, teardown);
// onPointerMove.flush();
onPointerMove.flush();
}),
);
window.addEventListener(EVENT.BLUR, teardown);
@@ -8271,14 +8255,14 @@ class App extends React.Component<AppProps, AppState> {
isDraggingScrollBar = true;
pointerDownState.lastCoords.x = event.clientX;
pointerDownState.lastCoords.y = event.clientY;
const onPointerMove = (event: PointerEvent) => {
const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
this.handlePointerMoveOverScrollbars(event, pointerDownState);
};
});
const onPointerUp = withBatchedUpdates(() => {
lastPointerUp = null;
isDraggingScrollBar = false;
@@ -8289,7 +8273,7 @@ class App extends React.Component<AppProps, AppState> {
this.savePointer(event.clientX, event.clientY, "up");
window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
// onPointerMove.flush();
onPointerMove.flush();
});
lastPointerUp = onPointerUp;
@@ -8848,13 +8832,10 @@ class App extends React.Component<AppProps, AppState> {
opacity: this.state.currentItemOpacity,
roundness: null,
simulatePressure,
strokeShape: this.state.currentItemStrokeShape,
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [pointFrom<LocalPoint>(0, 0)],
pressures: simulatePressure
? []
: [roundFreeDrawPressure(event.pressure)],
pressures: simulatePressure ? [] : [event.pressure],
});
this.scene.insertElement(element);
@@ -9496,7 +9477,7 @@ class App extends React.Component<AppProps, AppState> {
private onPointerMoveFromPointerDownHandler(
pointerDownState: PointerDownState,
) {
return (event: PointerEvent) => {
return withBatchedUpdatesThrottled((event: PointerEvent) => {
if (this.state.openDialog?.name === "elementLinkSelector") {
return;
}
@@ -10192,36 +10173,20 @@ class App extends React.Component<AppProps, AppState> {
const points = newElement.points;
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
const nextPoint = getRoundedFreeDrawPoint(dx, dy);
const pressure = roundFreeDrawPressure(event.pressure);
const pointAction =
newElement.strokeShape === "fixed"
? getFixedFreeDrawPointAction({
points,
nextPoint,
strokeWidth: newElement.strokeWidth,
zoomValue: this.state.zoom.value,
})
: points.length > 0 &&
points[points.length - 1][0] === nextPoint[0] &&
points[points.length - 1][1] === nextPoint[1]
? "discard"
: "append";
if (pointAction !== "discard") {
const lastPoint = points.length > 0 && points[points.length - 1];
const discardPoint =
lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
if (!discardPoint) {
const pressures = newElement.simulatePressure
? newElement.pressures
: pointAction === "replace"
? [...newElement.pressures.slice(0, -1), pressure]
: [...newElement.pressures, pressure];
: [...newElement.pressures, event.pressure];
this.scene.mutateElement(
newElement,
{
points:
pointAction === "replace"
? [...points.slice(0, -1), nextPoint]
: [...points, nextPoint],
points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures,
},
{
@@ -10379,7 +10344,7 @@ class App extends React.Component<AppProps, AppState> {
});
}
}
};
});
}
// Returns whether the pointer move happened over either scrollbar
@@ -10424,7 +10389,7 @@ class App extends React.Component<AppProps, AppState> {
this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) {
// pointerDownState.eventListeners.onMove.flush();
pointerDownState.eventListeners.onMove.flush();
}
const {
newElement,
@@ -10661,48 +10626,23 @@ class App extends React.Component<AppProps, AppState> {
);
const points = newElement.points;
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
let nextPoint = getRoundedFreeDrawPoint(dx, dy);
let dx = pointerCoords.x - newElement.x;
let dy = pointerCoords.y - newElement.y;
// Allows dots to avoid being flagged as infinitely small
if (nextPoint[0] === points[0][0] && nextPoint[1] === points[0][1]) {
nextPoint = getRoundedFreeDrawPoint(
dx + FREEDRAW_DOT_EPSILON,
dy + FREEDRAW_DOT_EPSILON,
);
if (dx === points[0][0] && dy === points[0][1]) {
dy += 0.0001;
dx += 0.0001;
}
const lastPoint = points[points.length - 1];
const pressure = roundFreeDrawPressure(childEvent.pressure);
const pointAction =
newElement.strokeShape === "fixed"
? getFixedFreeDrawPointAction({
points,
nextPoint,
strokeWidth: newElement.strokeWidth,
zoomValue: this.state.zoom.value,
isFinalPoint: true,
})
: !lastPoint ||
lastPoint[0] !== nextPoint[0] ||
lastPoint[1] !== nextPoint[1]
? "append"
: "discard";
const pressures = newElement.simulatePressure
? []
: [...newElement.pressures, childEvent.pressure];
if (pointAction !== "discard") {
this.scene.mutateElement(newElement, {
points:
pointAction === "replace"
? [...points.slice(0, -1), nextPoint]
: [...points, nextPoint],
pressures: newElement.simulatePressure
? []
: pointAction === "replace"
? [...newElement.pressures.slice(0, -1), pressure]
: [...newElement.pressures, pressure],
});
}
this.scene.mutateElement(newElement, {
points: [...points, pointFrom<LocalPoint>(dx, dy)],
pressures,
});
this.actionManager.executeAction(actionFinalize);
@@ -750,7 +750,7 @@ describe("frame resizing behavior", () => {
x: 0,
y: 0,
width: 100,
height: 103,
height: 100,
});
// Create a rectangle outside the frame
+2 -44
View File
@@ -1164,22 +1164,11 @@ export const StrokeWidthBaseIcon = createIcon(
modifiedTablerIconProps,
);
export const StrokeWidthMediumIcon = createIcon(
<path
d="M5 10h10"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>,
modifiedTablerIconProps,
);
export const StrokeWidthBoldIcon = createIcon(
<path
d="M5 10h10"
stroke="currentColor"
strokeWidth="3.75"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>,
@@ -1190,38 +1179,7 @@ export const StrokeWidthExtraBoldIcon = createIcon(
<path
d="M5 10h10"
stroke="currentColor"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
/>,
modifiedTablerIconProps,
);
export const StrokeShapeVariableIcon = createIcon(
<>
<path
d="M3.5 12.5c1.8-3.5 4.1-5.2 6.8-5.2 2.4 0 4.4 1.3 6.2 3.9"
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.75 10.8c1-.9 2-1.35 3.05-1.35 1.2 0 2.4.57 3.7 1.72"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</>,
modifiedTablerIconProps,
);
export const StrokeShapeFixedIcon = createIcon(
<path
d="M3.5 12h13"
stroke="currentColor"
strokeWidth="2.5"
strokeWidth="3.75"
strokeLinecap="round"
strokeLinejoin="round"
/>,
@@ -437,15 +437,16 @@ const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const boxSelectionMode = appState.boxSelectionMode ?? "contain";
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={appState.boxSelectionMode}
value={boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value,
boxSelectionMode: value === "contain" ? undefined : value,
});
}}
choices={[
+1 -7
View File
@@ -6,7 +6,6 @@ import {
MIME_TYPES,
cloneJSON,
SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
@@ -50,7 +49,6 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean,
) => {
elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection =
exportSelectionOnly &&
@@ -73,11 +71,7 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame(
elements,
exportingFrame,
elementsMap,
);
exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
} else if (exportedElements.length > 1) {
exportedElements = getSelectedElements(
elements,
-19
View File
@@ -96,13 +96,6 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const normalizeFreeDrawStrokeShape = (
strokeShape: unknown,
): AppState["currentItemStrokeShape"] =>
strokeShape === "fixed" || strokeShape === "stable"
? "fixed"
: undefined;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
@@ -419,12 +412,10 @@ export const restoreElement = (
return element;
case "freedraw": {
const strokeShape = normalizeFreeDrawStrokeShape(element.strokeShape);
return restoreElementWithProperties(element, {
points: element.points,
simulatePressure: element.simulatePressure,
pressures: element.pressures,
...(strokeShape ? { strokeShape } : {}),
});
}
case "image":
@@ -953,16 +944,6 @@ export const restoreAppState = (
return {
...nextAppState,
...(normalizeFreeDrawStrokeShape(
appState.currentItemStrokeShape ?? localAppState?.currentItemStrokeShape,
)
? {
currentItemStrokeShape: normalizeFreeDrawStrokeShape(
appState.currentItemStrokeShape ??
localAppState?.currentItemStrokeShape,
),
}
: {}),
cursorButton: localAppState?.cursorButton || "up",
// reset on fresh restore so as to hide the UI button if penMode not active
penDetected:
+2 -2
View File
@@ -2,7 +2,7 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import {
computeBoundTextPosition,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
getBoundTextElement,
getElementBounds,
getElementLineSegments,
@@ -219,7 +219,7 @@ const eraserTest = (
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
if (!doNonRotatedBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { type Bounds } from "@excalidraw/common";
import {
computeBoundTextPosition,
doBoundsIntersect,
doNonRotatedBoundsIntersect,
getBoundTextElement,
getElementBounds,
intersectElementWithLineSegment,
@@ -66,7 +66,7 @@ export const getLassoSelectedElementIds = (input: {
const elementBounds = getElementBounds(element, elementsMap);
if (
doBoundsIntersect(lassoBounds, elementBounds) &&
doNonRotatedBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) &&
!enclosedElements.has(element.id)
) {
-3
View File
@@ -30,9 +30,6 @@
"changeBackground": "Change background color",
"fill": "Fill",
"strokeWidth": "Stroke width",
"strokeShape": "Stroke type",
"strokeShape_variable": "Variable",
"strokeShape_fixed": "Fixed",
"strokeStyle": "Stroke style",
"strokeStyle_solid": "Solid",
"strokeStyle_dashed": "Dashed",
+4 -4
View File
@@ -11,10 +11,10 @@ export const withBatchedUpdates = <
TFunction extends ((event: any) => void) | (() => void),
>(
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
) => func;
// ((event) => {
// unstable_batchedUpdates(func as TFunction, event);
// }) as TFunction;
) =>
((event) => {
unstable_batchedUpdates(func as TFunction, event);
}) as TFunction;
/**
* barches React state updates and throttles the calls to a single call per
+6 -14
View File
@@ -376,11 +376,6 @@ const renderElementToSvg = (
}
case "freedraw": {
const wrapper = svgRoot.ownerDocument.createElementNS(SVG_NS, "g");
const isFixedStroke = element.strokeShape === "fixed";
const strokeColor =
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const shapes = ShapeCache.generateElementShape(element, renderConfig);
// always ordered as [background, stroke]
@@ -389,15 +384,12 @@ const renderElementToSvg = (
// stroke (SVGPathString)
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
if (isFixedStroke) {
path.setAttribute("fill", "none");
path.setAttribute("stroke", strokeColor);
path.setAttribute("stroke-width", `${element.strokeWidth}`);
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
} else {
path.setAttribute("fill", strokeColor);
}
path.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
path.setAttribute("d", shape);
wrapper.appendChild(path);
} else {
+1 -5
View File
@@ -157,11 +157,7 @@ const prepareElementsForRender = ({
let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) {
nextElements = getElementsOverlappingFrame(
elements,
exportingFrame,
arrayToMap(elements),
);
nextElements = getElementsOverlappingFrame(elements, exportingFrame);
} else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode,
@@ -13,7 +13,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -1087,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1302,7 +1300,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1634,7 +1631,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1966,7 +1962,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2181,7 +2176,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2423,7 +2417,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2722,7 +2715,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3095,7 +3087,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3589,7 +3580,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3913,7 +3903,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4237,7 +4226,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4649,7 +4637,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -5867,7 +5854,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -7136,7 +7122,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -7826,7 +7811,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -8818,7 +8802,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": {
"items": [
@@ -13,7 +13,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -647,7 +646,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1209,7 +1207,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1570,7 +1567,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1933,7 +1929,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2197,7 +2192,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2651,7 +2645,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2955,7 +2948,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3275,7 +3267,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3570,7 +3561,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3857,7 +3847,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4093,7 +4082,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4351,7 +4339,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4623,7 +4610,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4853,7 +4839,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5083,7 +5068,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5331,7 +5315,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5588,7 +5571,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5847,7 +5829,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6177,7 +6158,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6605,7 +6585,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6980,7 +6959,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7293,7 +7271,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7586,7 +7563,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7817,7 +7793,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8170,7 +8145,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8523,7 +8497,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8930,7 +8903,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9210,7 +9182,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9475,7 +9446,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9741,7 +9711,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9974,7 +9943,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10272,7 +10240,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10590,7 +10557,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10827,7 +10793,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11753,7 +11718,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12014,7 +11978,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12250,7 +12213,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12488,7 +12450,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12880,7 +12841,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13091,7 +13051,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13299,7 +13258,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13601,7 +13559,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13900,7 +13857,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14146,7 +14102,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14384,7 +14339,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14622,7 +14576,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14870,7 +14823,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15202,7 +15154,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15373,7 +15324,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15658,7 +15608,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -15922,7 +15871,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16076,7 +16024,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16359,7 +16306,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -16522,7 +16468,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -17271,7 +17216,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -17918,7 +17862,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -18565,7 +18508,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -19315,7 +19257,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -20084,7 +20025,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -20565,7 +20505,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -21077,7 +21016,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -21537,7 +21475,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13,7 +13,6 @@ exports[`given element A and group of elements B and given both are selected whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -440,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -857,7 +855,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1424,7 +1421,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -1632,7 +1628,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2017,7 +2012,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2263,7 +2257,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2444,7 +2437,6 @@ exports[`regression tests > can drag element that covers another element, while
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -2770,7 +2762,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3026,7 +3017,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3268,7 +3258,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3505,7 +3494,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -3764,7 +3752,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4079,7 +4066,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4516,7 +4502,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -4800,7 +4785,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5077,7 +5061,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5286,7 +5269,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5487,7 +5469,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -5881,7 +5862,6 @@ exports[`regression tests > drags selected elements from point inside common bou
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6179,7 +6159,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -6968,7 +6947,6 @@ exports[`regression tests > given a group of selected elements with an element t
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7303,7 +7281,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7583,7 +7560,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -7819,7 +7795,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8060,7 +8035,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8241,7 +8215,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8422,7 +8395,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8603,7 +8575,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -8836,7 +8807,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9067,7 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9260,7 +9229,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9493,7 +9461,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9674,7 +9641,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -9905,7 +9871,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10086,7 +10051,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10279,7 +10243,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10460,7 +10423,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -10992,7 +10954,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11273,7 +11234,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11397,7 +11357,6 @@ exports[`regression tests > shift click on selected element should deselect it o
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11598,7 +11557,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -11918,7 +11876,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12348,7 +12305,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -12989,7 +12945,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13116,7 +13071,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -13748,7 +13702,6 @@ exports[`regression tests > switches from group of selected elements to another
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14088,7 +14041,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14353,7 +14305,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14477,7 +14428,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14842,7 +14792,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -14966,7 +14915,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
@@ -160,40 +160,6 @@ describe("restoreElements", () => {
});
});
it("should restore fixed freedraw element correctly", () => {
const freedrawElement = API.createElement({
type: "freedraw",
id: "id-freedraw02",
strokeShape: "fixed",
points: [pointFrom(0, 0), pointFrom(10, 10)],
});
const restoredFreedraw = restore.restoreElements(
[freedrawElement],
null,
)[0] as ExcalidrawFreeDrawElement;
expect(restoredFreedraw.strokeShape).toBe("fixed");
});
it("should restore legacy stable freedraw element as fixed", () => {
const restoredFreedraw = restore.restoreElements(
[
{
...API.createElement({
type: "freedraw",
id: "id-freedraw03",
points: [pointFrom(0, 0), pointFrom(10, 10)],
}),
strokeShape: "stable",
} as any,
],
null,
)[0] as ExcalidrawFreeDrawElement;
expect(restoredFreedraw.strokeShape).toBe("fixed");
});
it("should restore line and draw elements correctly", () => {
const lineElement = API.createElement({ type: "line", id: "id-line01" });
@@ -675,26 +641,6 @@ describe("restoreAppState", () => {
expect(restoredAppState.name).toBe(stubImportedAppState.name);
});
it("should restore fixed freedraw stroke type from app state", () => {
const restoredAppState = restore.restoreAppState(
{ currentItemStrokeShape: "fixed" } as ImportedDataState["appState"],
null,
);
expect(restoredAppState.currentItemStrokeShape).toBe("fixed");
});
it("should restore legacy stable freedraw stroke type from app state as fixed", () => {
const restoredAppState = restore.restoreAppState(
{
currentItemStrokeShape: "stable",
} as unknown as ImportedDataState["appState"],
null,
);
expect(restoredAppState.currentItemStrokeShape).toBe("fixed");
});
it("should return local app state when imported data state is null", () => {
const stubLocalAppState = getDefaultAppState();
stubLocalAppState.cursorButton = "down";
@@ -1,78 +0,0 @@
import React from "react";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Pointer, UI } from "./helpers/ui";
import { render } from "./test-utils";
describe("freedraw", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("rounds stored points and drops duplicates after rounding", () => {
const mouse = new Pointer("mouse");
UI.clickTool("freedraw");
mouse.downAt(10, 20);
mouse.moveTo(10.1234, 20.5678);
mouse.moveTo(10.1249, 20.5681);
mouse.upAt(20.9999, 30.0001);
const freedraw = window.h.elements.at(-1);
expect(freedraw).toEqual(expect.objectContaining({ type: "freedraw" }));
expect((freedraw as any).points).toEqual([
[0, 0],
[0.12, 0.57],
[11, 10],
]);
});
it("does not snap fixed strokes closed when ending near the start point", () => {
const mouse = new Pointer("mouse");
API.setAppState({ currentItemStrokeShape: "fixed" });
UI.clickTool("freedraw");
mouse.downAt(10, 10);
mouse.moveTo(40, 10);
mouse.moveTo(30, 30);
mouse.upAt(12, 12);
const freedraw = window.h.elements.at(-1) as any;
expect(freedraw.points[0]).toEqual([0, 0]);
expect(freedraw.points.at(-1)).not.toEqual([0, 0]);
});
it("coalesces nearly straight fixed freedraw points at the tip", () => {
const mouse = new Pointer("mouse");
API.setAppState({ currentItemStrokeShape: "fixed" });
UI.clickTool("freedraw");
mouse.downAt(10, 10);
[
[10.2, 10.01],
[10.4, 10.03],
[10.6, 10.02],
[10.8, 10.04],
[11, 10.03],
[11.2, 10.05],
[11.4, 10.04],
[11.6, 10.06],
[11.8, 10.05],
].forEach(([x, y]) => {
mouse.moveTo(x, y);
});
mouse.upAt(12, 10.05);
const freedraw = window.h.elements.at(-1) as any;
expect(freedraw.points.length).toBeLessThan(5);
expect(freedraw.points).toEqual([
[0, 0],
[2, 0.05],
]);
});
});
-4
View File
@@ -201,9 +201,6 @@ export class API {
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
strokeShape?: T extends "freedraw"
? ExcalidrawFreeDrawElement["strokeShape"]
: never;
locked?: boolean;
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@@ -321,7 +318,6 @@ export class API {
type: type as "freedraw",
simulatePressure: true,
points: rest.points,
strokeShape: rest.strokeShape ?? appState.currentItemStrokeShape,
...base,
});
break;
+1 -4
View File
@@ -28,7 +28,6 @@ import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
import type { TransformHandleType } from "@excalidraw/element";
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
ExcalidrawArrowElement,
@@ -433,10 +432,8 @@ type DrawingToolName = Exclude<
"lock" | "selection" | "eraser" | "lasso"
>;
type Element<T extends DrawingToolName> = T extends "line"
type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement
: T extends "freedraw"
? ExcalidrawFreeDrawElement
: T extends "arrow"
? ExcalidrawArrowElement
: T extends "text"
+1 -1
View File
@@ -314,7 +314,7 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, isDeleted: true }),
]);
mouse.downAt(-10, -10);
mouse.downAt(0, 0);
mouse.moveTo(25, 25);
mouse.moveTo(50, 50);
mouse.upAt(50, 50);
@@ -468,7 +468,7 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.restorePosition(...end);
mouse.up();
expect(h.elements.length).toBe(3);
@@ -519,7 +519,7 @@ describe("regression tests", () => {
mouse.reset();
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.restorePosition(...end);
mouse.up();
for (const element of h.elements) {
@@ -537,7 +537,7 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down();
mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.restorePosition(...end);
mouse.up();
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+4 -4
View File
@@ -348,10 +348,10 @@ describe("box-selection overlap mode", () => {
);
const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect);
expect(outlineX1).toBeGreaterThan(boundsX1 - 1);
expect(outlineY1).toBeGreaterThan(boundsY1 - 1);
expect(outlineX2).toBeLessThan(boundsX2 + 1);
expect(outlineY2).toBeLessThan(boundsY2 + 1);
expect(outlineX1).toBeGreaterThan(boundsX1);
expect(outlineY1).toBeGreaterThan(boundsY1);
expect(outlineX2).toBeLessThan(boundsX2);
expect(outlineY2).toBeLessThan(boundsY2);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
+2 -4
View File
@@ -33,7 +33,6 @@ import type {
ExcalidrawNonSelectionElement,
BindMode,
ExcalidrawTextElement,
FreeDrawStrokeShape,
} from "@excalidraw/element/types";
import type {
@@ -311,7 +310,7 @@ export interface AppState {
*/
isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode: BoxSelectionMode;
boxSelectionMode?: BoxSelectionMode;
/** user arrow binding preference */
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
@@ -359,7 +358,6 @@ export interface AppState {
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidth: number;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemStrokeShape?: FreeDrawStrokeShape;
currentItemRoughness: number;
currentItemOpacity: number;
currentItemFontFamily: FontFamilyValues;
@@ -890,7 +888,7 @@ export type PointerDownState = Readonly<{
// We need to have these in the state so that we can unsubscribe them
eventListeners: {
// It's defined on the initial pointer down event
onMove: null | ((event: PointerEvent) => void);
onMove: null | ReturnType<typeof throttleRAF>;
// It's defined on the initial pointer down event
onUp: null | ((event: PointerEvent) => void);
// It's defined on the initial pointer down event
+5 -39
View File
@@ -137,17 +137,8 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
[t0, s0]: [number, number],
l: LineSegment<Point>,
c: Curve<Point>,
tolerance: number = 1e-2,
iterLimit: number = 4,
) => {
const solution = solveWithAnalyticalJacobian(
c,
l,
t0,
s0,
tolerance,
iterLimit,
);
const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4);
if (!solution) {
return null;
@@ -167,43 +158,18 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
*/
export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint,
>(
c: Curve<Point>,
l: LineSegment<Point>,
opts?: {
tolerance?: number;
iterLimit?: number;
},
): Point[] {
let solution = calculate(
initial_guesses[0],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
let solution = calculate(initial_guesses[0], l, c);
if (solution) {
return [solution];
}
solution = calculate(
initial_guesses[1],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
solution = calculate(initial_guesses[1], l, c);
if (solution) {
return [solution];
}
solution = calculate(
initial_guesses[2],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
solution = calculate(initial_guesses[2], l, c);
if (solution) {
return [solution];
}
+11
View File
@@ -1,3 +1,14 @@
//
// Generic markers
//
/**
* Can be used for any type of point-likes to mark them as rotated to enlist
* the type checker to weed out subtle bugs due to rotated and non-rotated
* versions of the same data point.
*/
export type Rotated<T> = T & { _brand_rotated: "excalimath_rotated" };
//
// Measurements
//
+21 -14
View File
@@ -1,4 +1,9 @@
import { arrayToMap, type Bounds } from "@excalidraw/common";
import {
arrayToMap,
bounds,
type RotatedBounds,
type Bounds,
} from "@excalidraw/common";
import { getElementBounds } from "@excalidraw/element";
import {
isArrowElement,
@@ -90,7 +95,7 @@ const getMinMaxPoints = (points: Points) => {
return ret;
};
const getRotatedBBox = (element: Element): Bounds => {
const getRotatedBBox = (element: Element): RotatedBounds => {
const points = getElementRelativePoints(element);
const { cx, cy } = getMinMaxPoints(points);
@@ -101,12 +106,13 @@ const getRotatedBBox = (element: Element): Bounds => {
);
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
return [
return bounds(
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
element.angle,
);
};
export const isElementInsideBBox = (
@@ -160,12 +166,12 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
export const elementsOverlappingBBox = ({
elements,
bounds,
bounds: _bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds | ExcalidrawElement;
bounds: RotatedBounds | ExcalidrawElement;
/** safety offset. Defaults to 0. */
errorMargin?: number;
/**
@@ -175,15 +181,16 @@ export const elementsOverlappingBBox = ({
**/
type: "overlap" | "contain" | "inside";
}) => {
if (isExcalidrawElement(bounds)) {
bounds = getElementBounds(bounds, arrayToMap(elements));
if (isExcalidrawElement(_bounds)) {
_bounds = getElementBounds(_bounds, arrayToMap(elements));
}
const adjustedBBox: Bounds = [
bounds[0] - errorMargin,
bounds[1] - errorMargin,
bounds[2] + errorMargin,
bounds[3] + errorMargin,
];
const adjustedBBox = bounds(
_bounds[0] - errorMargin,
_bounds[1] - errorMargin,
_bounds[2] + errorMargin,
_bounds[3] + errorMargin,
_bounds[4],
);
const includedElementSet = new Set<string>();
@@ -13,7 +13,6 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"bindMode": "orbit",
"bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {},
"contextMenu": null,
"croppingElementId": null,
-7
View File
@@ -90,13 +90,6 @@ module.exports.woff2BrowserPlugin = () => {
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"