Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle b92f585ce7 fix(test): attempt to fix test flake in collision.test.tsx 2026-03-25 21:03:20 +01:00
54 changed files with 188 additions and 2309 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 }}
+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";
+12 -35
View File
@@ -168,14 +168,12 @@ 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,
minX + element.x,
minY + element.y,
maxX + element.x,
maxY + element.y,
];
} else if (isLinearElement(element)) {
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
@@ -682,9 +680,8 @@ export const getMinMaxXYFromCurvePathOps = (
return [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 +695,17 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y);
}
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
return [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];
};
@@ -1265,17 +1261,6 @@ 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 = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1290,21 +1275,13 @@ 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,
xOffset: number = 0,
yOffset: number = 0,
) => {
if (isLinearElement(element) || isFreeDrawElement(element)) {
if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+18 -32
View File
@@ -27,7 +27,6 @@ import type {
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isLoopFreeDrawElement } from "./freedraw";
import { isPathALoop } from "./utils";
import {
doBoundsIntersect,
@@ -94,7 +93,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
if (element.type === "freedraw") {
return isDraggableFromInside && isLoopFreeDrawElement(element);
return isDraggableFromInside && isPathALoop(element.points);
}
return isDraggableFromInside || isImageElement(element);
@@ -155,11 +154,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds(
point,
bounds,
element.angle,
threshold,
const hitBounds = isPointWithinBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
pointRotateRads(
point,
getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
@@ -190,32 +192,18 @@ export const hitElementItself = ({
return result;
};
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 0,
) => {
const bounds = getElementBounds(element, elementsMap, true);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
};
export const hitElementBoundingBoxOnly = (
@@ -585,9 +573,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue;
}
const hits = curveIntersectLineSegment(c, segment, {
iterLimit: 10,
});
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
@@ -755,8 +741,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;
+9 -11
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";
@@ -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";
-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);
}
+36 -260
View File
@@ -1,32 +1,15 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import type {
AppState,
BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
import {
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { isElementInViewport } from "./sizeHelpers";
import {
isArrowElement,
isBoundToContainer,
isFrameLikeElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "./typeChecks";
@@ -34,38 +17,19 @@ 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 = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/**
* 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,243 +49,55 @@ 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[] => {
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
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[] = [];
let elementsInSelection = elements.filter((element) => {
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
element,
elementsMap,
);
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,
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
elementsMap,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
}
// 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;
return (
element.locked === false &&
element.type !== "selection" &&
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
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);
continue;
}
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
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).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
}
// 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 = excludeElementsInFrames
? excludeElementsInFramesFromSelection(elementsInSelection)
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
+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({
+9 -12
View File
@@ -1,8 +1,7 @@
import { arrayToMap, reseed } from "@excalidraw/common";
import { arrayToMap, ROUNDNESS } 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";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
@@ -10,30 +9,29 @@ import * as distance from "../src/distance";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
const element = API.createElement({
type: "arrow",
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
endArrowhead: "arrow",
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
const elementsMap = arrayToMap([element]);
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(88, -68),
element: window.h.elements[0],
element,
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
elementsMap,
});
expect(hit).toBe(true);
});
@@ -57,7 +55,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,
-3
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 },
@@ -195,7 +193,6 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: {
@@ -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),
+42 -121
View File
@@ -9,7 +9,6 @@ import {
clamp,
pointFrom,
pointDistance,
round,
vector,
pointRotateRads,
vectorFromPoint,
@@ -28,7 +27,6 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -120,7 +118,6 @@ import {
getElementAbsoluteCoords,
bindOrUnbindBindingElements,
fixBindingsAfterDeletion,
getFixedFreeDrawPointAction,
getHoveredElementForBinding,
isBindingEnabled,
updateBoundElements,
@@ -617,19 +614,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;
@@ -2540,7 +2524,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) {
@@ -7256,14 +7239,6 @@ class App extends React.Component<AppProps, AppState> {
this.interactiveCanvas,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
);
} else if (
!event[KEYS.CTRL_OR_CMD] &&
this.isHittingCommonBoundingBoxOfSelectedElements(
scenePointer,
selectedElements,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") {
@@ -7755,24 +7730,17 @@ class App extends React.Component<AppProps, AppState> {
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const shouldForceLassoReselect =
event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
!pointerDownState.resize.handleType;
const shouldStartLassoSelection =
shouldForceLassoReselect ||
(!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement);
if (shouldStartLassoSelection) {
if (!this.lassoTrail.hasCurrentTrail) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
}
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
@@ -8095,7 +8063,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 +8129,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 +8239,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 +8257,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;
@@ -8761,14 +8729,12 @@ class App extends React.Component<AppProps, AppState> {
DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
1,
);
const boundsPadding =
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value;
const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
return (
point.x > x1 - boundsPadding - threshold &&
point.x < x2 + boundsPadding + threshold &&
point.y > y1 - boundsPadding - threshold &&
point.y < y2 + boundsPadding + threshold
point.x > x1 - threshold &&
point.x < x2 + threshold &&
point.y > y1 - threshold &&
point.y < y2 + threshold
);
}
@@ -8848,13 +8814,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 +9459,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 +10155,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,
},
{
@@ -10320,7 +10267,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.selectionElement,
this.scene.getNonDeletedElementsMap(),
false,
this.state.boxSelectionMode,
)
: [];
@@ -10379,7 +10325,7 @@ class App extends React.Component<AppProps, AppState> {
});
}
}
};
});
}
// Returns whether the pointer move happened over either scrollbar
@@ -10424,7 +10370,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 +10607,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);
@@ -26,16 +26,13 @@
background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
width: 32px;
height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off);
@@ -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,7 +2,7 @@
.excalidraw {
.dropdown-menu {
max-width: 20rem;
max-width: 16rem;
z-index: 1;
&--placement-top {
@@ -1,5 +1,4 @@
import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
@@ -13,7 +12,6 @@ type Props<T> = {
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
icon?: React.ReactNode;
};
const DropdownMenuItemContentRadio = <T,>({
@@ -23,17 +21,13 @@ const DropdownMenuItemContentRadio = <T,>({
choices,
children,
name,
icon,
}: Props<T>) => {
const editorInterface = useEditorInterface();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<label className="dropdown-menu-item__text">{children}</label>
<RadioGroup
name={name}
value={value}
+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"
/>,
@@ -39,13 +39,7 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -433,39 +427,6 @@ const PreferencesToggleToolLockItem = () => {
);
};
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={appState.boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value,
});
}}
choices={[
{
value: "contain",
label: t("labels.boxSelectionContain"),
ariaLabel: t("labels.boxSelectionContain"),
},
{
value: "overlap",
label: t("labels.boxSelectionOverlap"),
ariaLabel: t("labels.boxSelectionOverlap"),
},
]}
>
{t("labels.boxSelectionMode")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@@ -607,7 +568,6 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || (
<>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem />
@@ -625,7 +585,6 @@ export const Preferences = ({
};
Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+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,
-25
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":
@@ -945,24 +936,8 @@ export const restoreAppState = (
: defaultValue;
}
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
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:
-6
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",
@@ -188,9 +185,6 @@
"shapeSwitch": "Switch shape",
"preferences": "Preferences",
"preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints"
},
+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,
@@ -4370,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 1006504105,
"versionNonce": 760410951,
"width": 20,
"x": -10,
"y": 0,
@@ -4395,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 400692809,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 5,
"versionNonce": 289600103,
"versionNonce": 1006504105,
"width": 20,
"x": 20,
"y": 30,
@@ -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": [
@@ -6878,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1723083209,
"versionNonce": 747212839,
"width": 10,
"x": -10,
"y": 0,
@@ -6905,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 400692809,
"seed": 238820263,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 760410951,
"versionNonce": 1723083209,
"width": 10,
"x": 12,
"y": 0,
@@ -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 }, () => {
+1 -508
View File
@@ -1,9 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { KEYS, reseed } from "@excalidraw/common";
import { SHAPES } from "../components/shapes";
@@ -14,7 +12,6 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import {
act,
render,
fireEvent,
mockBoundingClientRect,
@@ -42,19 +39,6 @@ const { h } = window;
const mouse = new Pointer("mouse");
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const points = getElementLineSegments(sceneElement, elementsMap).flat();
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
] as const;
};
describe("box-selection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
@@ -124,497 +108,6 @@ describe("box-selection", () => {
assertSelectedElements([]);
});
it("should not select an element when the selection box only partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
mouse.downAt(25, -20);
mouse.move(-1000, -1000);
mouse.moveTo(75, 70);
mouse.up();
assertSelectedElements([]);
});
});
describe("lasso reselection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
const rectB = API.createElement({
type: "rectangle",
x: 220,
y: 0,
width: 100,
height: 100,
backgroundColor: "blue",
fillStyle: "solid",
});
API.setElements([rectA, rectB]);
mouse.select([rectA, rectB]);
act(() => {
h.app.setActiveTool({ type: "lasso" });
});
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
mouse.downAt(110, 50);
mouse.moveTo(50, -20);
expect(h.app.lassoTrail.hasCurrentTrail).toBe(true);
mouse.moveTo(-20, 50);
mouse.moveTo(50, 120);
mouse.moveTo(110, 50);
mouse.up();
});
assertSelectedElements([rectA.id]);
});
});
describe("box-selection overlap mode", () => {
const boxSelect = (
startX: number,
startY: number,
endX: number,
endY: number,
) => {
mouse.downAt(startX, startY);
mouse.move(-1000, -1000);
mouse.moveTo(endX, endY);
mouse.up();
};
const boxSelectTopLeftAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
};
const boxSelectTopRightAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
};
const boxSelectTopLeftRotatedLocalBoundsCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
const [cornerX, cornerY] = pointRotateRads(
pointFrom(x1, y1),
center,
sceneElement.angle,
);
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
};
beforeEach(async () => {
await render(
<Excalidraw
initialData={{ appState: { boxSelectionMode: "overlap" } }}
/>,
);
});
it("should select an element when the selection box partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 75, 75);
assertSelectedElements([]);
});
it("should select a transparent rectangle when the selection box crosses its outline", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 125, 75);
assertSelectedElements([rect1.id]);
});
it("should not select a rotated transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(40, 40, 60, 60);
assertSelectedElements([]);
});
it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 180,
angle: Math.PI / 6,
backgroundColor: "transparent",
fillStyle: "solid",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
roughness: 0,
});
API.setElements([rect]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds(
sceneRect,
elementsMap,
);
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);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
outlineY1 - (outlineY1 - boundsY1) / 2,
outlineX2 + (boundsX2 - outlineX2) / 2,
outlineY2 + (boundsY2 - outlineY2) / 2,
);
assertSelectedElements([rect.id]);
});
it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
boxSelectTopLeftAabbCorner(rect);
assertSelectedElements([]);
});
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopRightAabbCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopRightAabbCorner(diamond);
assertSelectedElements([]);
});
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
assertSelectedElements([]);
});
it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => {
const text = API.createElement({
type: "text",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
text: "test",
});
API.setElements([text]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => {
const image = API.createElement({
type: "image",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
fileId: "file_A",
status: "saved",
});
API.setElements([image]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
mouse.clickAt(50, 50);
assertSelectedElements([rect.id]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneRect, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a line when the selection box only overlaps its bounds", () => {
const line = API.createElement({
type: "line",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
});
API.setElements([line]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
pointFrom<LocalPoint>(0, 100),
pointFrom<LocalPoint>(0, 0),
],
});
API.setElements([freedraw]);
const sceneFreedraw = API.getElement(freedraw);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a freedraw when the selection box only overlaps its bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(100, 100),
],
});
API.setElements([freedraw]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 10,
width: 100,
height: 80,
frameId: frame.id,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(60, 20, 90, 60);
assertSelectedElements([]);
});
});
describe("inner box-selection", () => {
+1 -7
View File
@@ -33,7 +33,6 @@ import type {
ExcalidrawNonSelectionElement,
BindMode,
ExcalidrawTextElement,
FreeDrawStrokeShape,
} from "@excalidraw/element/types";
import type {
@@ -270,8 +269,6 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"];
};
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@@ -310,8 +307,6 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt)
*/
isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode: BoxSelectionMode;
/** user arrow binding preference */
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
@@ -359,7 +354,6 @@ export interface AppState {
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidth: number;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemStrokeShape?: FreeDrawStrokeShape;
currentItemRoughness: number;
currentItemOpacity: number;
currentItemFontFamily: FontFamilyValues;
@@ -890,7 +884,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];
}
@@ -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"