Compare commits

..

2 Commits

Author SHA1 Message Date
Mark Tolmacs 10854002dc fix: Vercel.json
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-22 13:32:11 +00:00
Mark Tolmacs 435b4a1684 feat: Rounding coordinates
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-06-22 14:39:39 +02:00
75 changed files with 1191 additions and 3880 deletions
+1 -4
View File
@@ -1014,10 +1014,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
+2 -121
View File
@@ -1,131 +1,13 @@
import { Footer } from "@excalidraw/excalidraw/index";
import {
getCommonBounds,
getSelectedElements,
getVisibleSceneBounds,
} from "@excalidraw/element";
import React, { useCallback, useState } from "react";
import type {
ExcalidrawImperativeAPI,
ScrollConstraints,
} from "@excalidraw/excalidraw/types";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
const ScrollConstraintsDebugFooter = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
}) => {
const [activeLock, setActiveLock] = useState<ScrollConstraints | null>(null);
const [tolerance, setTolerance] = useState(0);
const setLock = useCallback(
(nextLock: ScrollConstraints) => {
// pass an empty target so the constraints are applied without scrolling
excalidrawAPI?.scrollToContent([], { scrollConstraints: nextLock });
setActiveLock(nextLock);
},
[excalidrawAPI],
);
const toggleScrollLock = useCallback(() => {
if (!excalidrawAPI) {
return;
}
if (activeLock) {
excalidrawAPI.scrollToContent([], { scrollConstraints: null });
setActiveLock(null);
return;
}
const selectedElements = getSelectedElements(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
{
includeBoundTextElement: true,
includeElementsInFrames: true,
},
);
const [x1, y1, x2, y2] = selectedElements.length
? getCommonBounds(
selectedElements,
excalidrawAPI.getSceneElementsMapIncludingDeleted(),
)
: getVisibleSceneBounds(excalidrawAPI.getAppState());
setLock({
x: x1,
y: y1,
width: x2 - x1,
height: y2 - y1,
tolerance,
});
}, [activeLock, excalidrawAPI, setLock, tolerance]);
const updateTolerance = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const nextTolerance = Number(event.target.value) || 0;
setTolerance(nextTolerance);
if (activeLock) {
setLock({ ...activeLock, tolerance: nextTolerance });
}
},
[activeLock, setLock],
);
return (
<div
style={{
display: "flex",
gap: ".35rem",
alignItems: "center",
padding: "0 .35rem",
fontSize: 12,
}}
>
<button
className="ToolIcon_type_button"
type="button"
onClick={toggleScrollLock}
>
{activeLock ? "disable scroll lock" : "lock scroll"}
</button>
<label
style={{
display: "flex",
gap: ".25rem",
alignItems: "center",
}}
>
tolerance
<input
type="number"
min={0}
step={1}
value={tolerance}
onChange={updateTolerance}
style={{ width: 56 }}
/>
</label>
</div>
);
};
export const AppFooter = React.memo(
({
excalidrawAPI,
onChange,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
onChange: () => void;
}) => {
({ onChange }: { onChange: () => void }) => {
return (
<Footer>
<div
@@ -135,7 +17,6 @@ export const AppFooter = React.memo(
alignItems: "center",
}}
>
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
+2 -1
View File
@@ -11,6 +11,7 @@
*/
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import { stringifyWithPrecision } from "@excalidraw/excalidraw/data/json";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
@@ -89,7 +90,7 @@ const saveDataStateToLocalStorage = (
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(getNonDeletedElements(elements)),
stringifyWithPrecision(getNonDeletedElements(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+2 -1
View File
@@ -1,6 +1,7 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import { stringifyWithPrecision } from "@excalidraw/excalidraw/data/json";
import {
encryptData,
decryptData,
@@ -94,7 +95,7 @@ const encryptElements = async (
key: string,
elements: readonly ExcalidrawElement[],
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
const json = JSON.stringify(elements);
const json = stringifyWithPrecision(elements);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(key, encoded);
-7
View File
@@ -82,13 +82,6 @@ export default defineConfig(({ mode }) => {
"../packages/fractional-indexing/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
},
],
},
build: {
+1 -2
View File
@@ -57,8 +57,7 @@
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
"build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+4 -43
View File
@@ -404,47 +404,11 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
export const STROKE_WIDTH = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
@@ -459,7 +423,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeWidth: 2,
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
@@ -550,6 +514,3 @@ export const BIND_MODE_TIMEOUT = 700; // ms
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
+141 -2
View File
@@ -1,4 +1,4 @@
import { average } from "@excalidraw/math";
import { average, round } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math";
@@ -204,6 +204,135 @@ export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4);
};
const easeOutInterpolate = (from: number, to: number, progress: number) => {
return (to - from) * easeOut(progress) + from;
};
/**
* Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* Executes the `onStep` callback on each step with the interpolated values.
* Returns a function that can be called to cancel the animation.
*
* @example
* // Example usage:
* const fromValues = { x: 0, y: 0 };
* const toValues = { x: 100, y: 200 };
* const onStep = ({x, y}) => {
* setState(x, y)
* };
* const onCancel = () => {
* console.log("Animation canceled");
* };
*
* const cancelAnimation = easeToValuesRAF({
* fromValues,
* toValues,
* onStep,
* onCancel,
* });
*
* // To cancel the animation:
* cancelAnimation();
*/
export const easeToValuesRAF = <
T extends Record<keyof T, number>,
K extends keyof T,
>({
fromValues,
toValues,
onStep,
duration = 250,
interpolateValue,
onStart,
onEnd,
onCancel,
}: {
fromValues: T;
toValues: T;
/**
* Interpolate a single value.
* Return undefined to be handled by the default interpolator.
*/
interpolateValue?: (
fromValue: number,
toValue: number,
/** no easing applied */
progress: number,
key: K,
) => number | undefined;
onStep: (values: T) => void;
duration?: number;
onStart?: () => void;
onEnd?: () => void;
onCancel?: () => void;
}) => {
let canceled = false;
let frameId = 0;
let startTime: number;
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
onStart?.();
}
const elapsed = Math.min(timestamp - startTime, duration);
const factor = easeOut(elapsed / duration);
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as keyof T;
const result = ((toValues[_key] - fromValues[_key]) * factor +
fromValues[_key]) as T[keyof T];
newValues[_key] = result;
});
onStep(newValues);
if (elapsed < duration) {
const progress = elapsed / duration;
const newValues = {} as T;
Object.keys(fromValues).forEach((key) => {
const _key = key as K;
const startValue = fromValues[_key];
const endValue = toValues[_key];
let result;
result = interpolateValue
? interpolateValue(startValue, endValue, progress, _key)
: easeOutInterpolate(startValue, endValue, progress);
if (result == null) {
result = easeOutInterpolate(startValue, endValue, progress);
}
newValues[_key] = result as T[K];
});
onStep(newValues);
frameId = window.requestAnimationFrame(step);
} else {
onStep(toValues);
onEnd?.();
}
}
frameId = window.requestAnimationFrame(step);
return () => {
onCancel?.();
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>(
array: readonly T[],
@@ -300,11 +429,21 @@ export const viewportCoordsToSceneCoords = (
scrollX: number;
scrollY: number;
},
decimals: number = 2,
) => {
const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y } as GlobalCoord;
if (decimals === 0) {
return toBrandedType<GlobalCoord>({ x, y });
}
const precision = Math.pow(10, decimals);
return toBrandedType<GlobalCoord>({
x: round(x, precision),
y: round(y, precision),
});
};
export const sceneCoordsToViewportCoords = (
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
-2
View File
@@ -38,8 +38,6 @@ export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "arrow" ||
type === "line";
export const hasFreedrawMode = (type: ElementOrToolType) => type === "freedraw";
export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" ||
type === "iframe" ||
-6
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN,
randomInteger,
randomId,
@@ -445,7 +444,6 @@ export const newFreeDrawElement = (
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -454,10 +452,6 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
};
};
+51 -38
View File
@@ -889,10 +889,8 @@ export const renderElement = (
case "embeddable": {
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
@@ -914,49 +912,64 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {
// Draw arrow directly as vector and clear label hole separately.
// This avoids temp-canvas bitmap blit which introduces resampling blur.
const tempCanvas = document.createElement("canvas");
const tempCanvasContext = tempCanvas.getContext("2d")!;
// Take max dimensions of arrow canvas so that when canvas is rotated
// the arrow doesn't get clipped
const maxDim = Math.max(distance(x1, x2), distance(y1, y2));
const padding = getCanvasPadding(element);
tempCanvas.width =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvas.height =
maxDim * appState.exportScale + padding * 10 * appState.exportScale;
tempCanvasContext.translate(
tempCanvas.width / 2,
tempCanvas.height / 2,
);
tempCanvasContext.scale(appState.exportScale, appState.exportScale);
// Shift the canvas to left most point of the arrow
shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1);
context.save();
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
tempCanvasContext.rotate(element.angle);
const tempRc = rough.canvas(tempCanvas);
tempCanvasContext.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
tempCanvasContext.translate(shiftX, shiftY);
tempCanvasContext.rotate(-element.angle);
// Shift the canvas to center of bound text
const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords(
boundTextElement,
elementsMap,
);
const holeX =
boundTextCx -
centerX -
boundTextElement.width / 2 -
BOUND_TEXT_PADDING;
const holeY =
boundTextCy -
centerY -
boundTextElement.height / 2 -
BOUND_TEXT_PADDING;
const holeWidth = boundTextElement.width + BOUND_TEXT_PADDING * 2;
const holeHeight = boundTextElement.height + BOUND_TEXT_PADDING * 2;
const boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
const isTransparentHole =
"viewBackgroundColor" in appState &&
(appState.viewBackgroundColor === "transparent" ||
!appState.viewBackgroundColor);
if (!isTransparentHole) {
context.save();
context.fillStyle = applyDarkModeFilter(
renderConfig.canvasBackgroundColor,
renderConfig.theme === THEME.DARK,
);
context.fillRect(holeX, holeY, holeWidth, holeHeight);
context.restore();
} else {
context.clearRect(holeX, holeY, holeWidth, holeHeight);
}
// Clear the bound text area
tempCanvasContext.clearRect(
-boundTextElement.width / 2,
-boundTextElement.height / 2,
boundTextElement.width,
boundTextElement.height,
);
context.scale(1 / appState.exportScale, 1 / appState.exportScale);
context.drawImage(
tempCanvas,
-tempCanvas.width / 2,
-tempCanvas.height / 2,
tempCanvas.width,
tempCanvas.height,
);
} else {
context.rotate(element.angle);
+7 -69
View File
@@ -1,6 +1,5 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
type GeometricShape,
@@ -25,7 +24,6 @@ import {
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -1173,87 +1171,27 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString;
};
/**
* Freedraw stroke geometry tuning constants.
*
* These factors are not derived analytically — they were tuned empirically by
* visually comparing rendered strokes until they matched the desired feel.
* Treat them as magic numbers backed by visual verification.
*/
const VARIABLE_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for pressure-sensitive strokes. */
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
* perfect-freehand. This is the original Excalidraw freedraw look.
*/
const getVariableWidthFreedrawOutline = (
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
) => {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING,
streamline: getFreedrawStreamline(element),
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
last: true,
}) as [number, number][];
};
const createLaserPointer = (element: ExcalidrawFreeDrawElement) =>
new LaserPointer({
size: element.strokeWidth * CONSTANT_WIDTH_FREEDRAW.SIZE_FACTOR,
streamline: getFreedrawStreamline(element),
simplify: 0,
sizeMapping: (details) => Math.max(0.1, details.pressure),
});
/**
* Uniform (constant width) freedraw outline, rendered with the laser-pointer
* geometry. Pressure is pinned to 1 so the stroke keeps a constant width.
*/
const getConstantWidthFreedrawOutline = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
const laserPointer = createLaserPointer(element);
element.points.map(([x, y]) => laserPointer.addPoint([x, y, 1]));
return laserPointer
.getStrokeOutline()
.map(([x, y]) => [x, y] as [number, number]);
};
export const getFreedrawOutlinePoints = (
element: ExcalidrawFreeDrawElement,
): [number, number][] => {
// Unknown/absent variability falls back to the original variable rendering.
return element.strokeOptions?.variability === "constant"
? getConstantWidthFreedrawOutline(element)
: getVariableWidthFreedrawOutline(element);
};
const med = (A: number[], B: number[]) => {
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
};
-8
View File
@@ -384,20 +384,12 @@ export type ExcalidrawElbowArrowElement = Merge<
}
>;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>;
export type FileId = string & { _brand: "FileId" };
+300 -300
View File
@@ -72,123 +72,123 @@ describe("aligning", () => {
it("aligns two objects correctly to the top", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_UP);
});
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
});
it("aligns two objects correctly to the bottom", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_DOWN);
});
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(110);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(110);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
});
it("aligns two objects correctly to the left", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_LEFT);
});
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
});
it("aligns two objects correctly to the right", () => {
createAndSelectTwoRectangles();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
Keyboard.keyPress(KEYS.ARROW_RIGHT);
});
expect(API.getSelectedElements()[0].x).toEqual(110);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(110);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
});
it("centers two objects with different sizes correctly vertically", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
API.executeAction(actionAlignVerticallyCentered);
// Check if x position did not change
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(60);
expect(API.getSelectedElements()[1].y).toEqual(55);
expect(API.getSelectedElements()[0].y).toBeCloseTo(60);
expect(API.getSelectedElements()[1].y).toBeCloseTo(55);
});
it("centers two objects with different sizes correctly horizontally", () => {
createAndSelectTwoRectanglesWithDifferentSizes();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(110);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(110);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(60);
expect(API.getSelectedElements()[1].x).toEqual(55);
expect(API.getSelectedElements()[0].x).toBeCloseTo(60);
expect(API.getSelectedElements()[1].x).toBeCloseTo(55);
// Check if y position did not change
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(110);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(110);
});
const createAndSelectGroupAndRectangle = () => {
@@ -226,85 +226,85 @@ describe("aligning", () => {
it("aligns a group with another element correctly to the top", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
});
it("aligns a group with another element correctly to the bottom", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
});
it("aligns a group with another element correctly to the left", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
});
it("aligns a group with another element correctly to the right", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
});
it("centers a group with another element correctly vertically", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
});
it("centers a group with another element correctly horizontally", () => {
createAndSelectGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
});
const createAndSelectTwoGroups = () => {
@@ -354,97 +354,97 @@ describe("aligning", () => {
it("aligns two groups correctly to the top", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[3].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
expect(API.getSelectedElements()[3].y).toBeCloseTo(100);
});
it("aligns two groups correctly to the bottom", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(200);
expect(API.getSelectedElements()[1].y).toEqual(300);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(200);
expect(API.getSelectedElements()[1].y).toBeCloseTo(300);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
});
it("aligns two groups correctly to the left", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[3].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
expect(API.getSelectedElements()[3].x).toBeCloseTo(100);
});
it("aligns two groups correctly to the right", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(200);
expect(API.getSelectedElements()[1].x).toEqual(300);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(200);
expect(API.getSelectedElements()[1].x).toBeCloseTo(300);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
});
it("centers two groups correctly vertically", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[3].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
expect(API.getSelectedElements()[3].y).toBeCloseTo(200);
});
it("centers two groups correctly horizontally", () => {
createAndSelectTwoGroups();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[3].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
expect(API.getSelectedElements()[3].x).toBeCloseTo(200);
});
const createAndSelectNestedGroupAndRectangle = () => {
@@ -497,97 +497,97 @@ describe("aligning", () => {
it("aligns nested group and other element correctly to the top", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(0);
});
it("aligns nested group and other element correctly to the bottom", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(300);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
});
it("aligns nested group and other element correctly to the left", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(0);
});
it("aligns nested group and other element correctly to the right", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(300);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
});
it("centers nested group and other element correctly vertically", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[3].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
expect(API.getSelectedElements()[3].y).toBeCloseTo(300);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(250);
expect(API.getSelectedElements()[3].y).toEqual(150);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toBeCloseTo(250);
expect(API.getSelectedElements()[3].y).toBeCloseTo(150);
});
it("centers nested group and other element correctly horizontally", () => {
createAndSelectNestedGroupAndRectangle();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[3].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
expect(API.getSelectedElements()[3].x).toBeCloseTo(300);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(250);
expect(API.getSelectedElements()[3].x).toEqual(150);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toBeCloseTo(250);
expect(API.getSelectedElements()[3].x).toBeCloseTo(150);
});
const createGroupAndSelectInEditGroupMode = () => {
@@ -622,68 +622,68 @@ describe("aligning", () => {
it("aligns elements within a group while in group edit mode correctly to the top", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
});
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
});
it("aligns elements within a group while in group edit mode correctly to the left", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
});
it("aligns elements within a group while in group edit mode correctly to the right", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
});
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
});
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
createGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
});
const createNestedGroupAndSelectInEditGroupMode = () => {
@@ -735,80 +735,80 @@ describe("aligning", () => {
it("aligns element and nested group while in group edit mode correctly to the top", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
});
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
});
it("aligns element and nested group while in group edit mode correctly to the left", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
});
it("aligns element and nested group while in group edit mode correctly to the right", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
});
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
});
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
createNestedGroupAndSelectInEditGroupMode();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
});
const createAndSelectSingleGroup = () => {
@@ -834,68 +834,68 @@ describe("aligning", () => {
it("aligns elements within a single-selected group correctly to the top", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(0);
});
it("aligns elements within a single-selected group correctly to the bottom", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
});
it("aligns elements within a single-selected group correctly to the left", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(0);
});
it("aligns elements within a single-selected group correctly to the right", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
});
it("aligns elements within a single-selected group correctly to the vertical center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
});
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
createAndSelectSingleGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
});
const createAndSelectSingleGroupWithNestedGroup = () => {
@@ -934,79 +934,79 @@ describe("aligning", () => {
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignTop);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(0);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignBottom);
expect(API.getSelectedElements()[0].y).toEqual(100);
expect(API.getSelectedElements()[1].y).toEqual(200);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(100);
expect(API.getSelectedElements()[1].y).toBeCloseTo(200);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignLeft);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(0);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(0);
});
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignRight);
expect(API.getSelectedElements()[0].x).toEqual(100);
expect(API.getSelectedElements()[1].x).toEqual(200);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(100);
expect(API.getSelectedElements()[1].x).toBeCloseTo(200);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
});
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(100);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(100);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(actionAlignVerticallyCentered);
expect(API.getSelectedElements()[0].y).toEqual(50);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(100);
expect(API.getSelectedElements()[0].y).toBeCloseTo(50);
expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toBeCloseTo(100);
});
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
createAndSelectSingleGroupWithNestedGroup();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(100);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(100);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(actionAlignHorizontallyCentered);
expect(API.getSelectedElements()[0].x).toEqual(50);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(100);
expect(API.getSelectedElements()[0].x).toBeCloseTo(50);
expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toBeCloseTo(100);
});
});
+24 -24
View File
@@ -76,53 +76,53 @@ describe("distributing", () => {
it("should distribute selected elements horizontally", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(10);
expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(150);
expect(API.getSelectedElements()[2].x).toEqual(300);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(150);
expect(API.getSelectedElements()[2].x).toBeCloseTo(300);
});
it("should distribute selected elements vertically", async () => {
createAndSelectThreeRectanglesWithGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(10);
expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(150);
expect(API.getSelectedElements()[2].y).toEqual(300);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(150);
expect(API.getSelectedElements()[2].y).toBeCloseTo(300);
});
it("should distribute selected elements horizontally based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(10);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(10);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
API.executeAction(distributeHorizontally);
expect(API.getSelectedElements()[0].x).toEqual(0);
expect(API.getSelectedElements()[1].x).toEqual(50);
expect(API.getSelectedElements()[2].x).toEqual(200);
expect(API.getSelectedElements()[0].x).toBeCloseTo(0);
expect(API.getSelectedElements()[1].x).toBeCloseTo(50);
expect(API.getSelectedElements()[2].x).toBeCloseTo(200);
});
it("should distribute selected elements vertically with based on their centers", async () => {
createAndSelectThreeRectanglesWithoutGap();
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(10);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(10);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
API.executeAction(distributeVertically);
expect(API.getSelectedElements()[0].y).toEqual(0);
expect(API.getSelectedElements()[1].y).toEqual(50);
expect(API.getSelectedElements()[2].y).toEqual(200);
expect(API.getSelectedElements()[0].y).toBeCloseTo(0);
expect(API.getSelectedElements()[1].y).toBeCloseTo(50);
expect(API.getSelectedElements()[2].y).toBeCloseTo(200);
});
});
@@ -1318,8 +1318,8 @@ describe("Test Linear Elements", () => {
expect(arrow.endBinding?.elementId).toBe(rect.id);
expect(arrow.width).toBeCloseTo(404);
expect(rect.x).toBe(400);
expect(rect.y).toBe(0);
expect(rect.x).toBeCloseTo(400);
expect(rect.y).toBeCloseTo(0);
expect(
wrapText(
textElement.originalText,
@@ -1340,9 +1340,8 @@ describe("Test Linear Elements", () => {
expect(rect.x).toBe(200);
expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
h.elements[0],
h.elements[1],
h.app.scene,
"nw",
false,
);
expect(
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
@@ -5,7 +5,6 @@ import {
VERTICAL_ALIGN,
arrayToMap,
getFontString,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
getOriginalContainerHeightFromCache,
@@ -250,10 +249,7 @@ export const actionWrapTextInContainer = register({
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: getStrokeWidthByKey(
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeWidth: appState.currentItemStrokeWidth,
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
+28 -42
View File
@@ -45,7 +45,6 @@ import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { constrainScrollState } from "../scroll";
import { getShortcutKey } from "../shortcut";
import { register } from "./register";
@@ -139,20 +138,19 @@ export const actionZoomIn = register({
icon: ZoomInIcon,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
const nextState = {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
userToFollow: null,
};
return {
appState: { ...nextState, ...constrainScrollState(nextState) },
appState: {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
},
appState,
),
userToFollow: null,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -181,20 +179,19 @@ export const actionZoomOut = register({
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
const nextState = {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
userToFollow: null,
};
return {
appState: { ...nextState, ...constrainScrollState(nextState) },
appState: {
...appState,
...getStateForZoom(
{
viewportX: appState.width / 2 + appState.offsetLeft,
viewportY: appState.height / 2 + appState.offsetTop,
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
},
appState,
),
userToFollow: null,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -222,8 +219,6 @@ export const actionResetZoom = register({
icon: ZoomResetIcon,
viewMode: true,
trackEvent: { category: "canvas" },
// resetting to 100% may violate active scroll constraints
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -248,8 +243,6 @@ export const actionResetZoom = register({
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
// keep the zoom level visible, but non-resettable while constrained
disabled={!!appState.scrollConstraints}
onClick={() => {
updateData(null);
}}
@@ -258,8 +251,7 @@ export const actionResetZoom = register({
</ToolButton>
</Tooltip>
),
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
@@ -404,7 +396,6 @@ export const actionZoomToFitSelectionInViewport = register({
label: "labels.zoomToFitViewport",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
@@ -419,8 +410,7 @@ export const actionZoomToFitSelectionInViewport = register({
},
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.TWO &&
event.shiftKey &&
!event.altKey &&
@@ -432,7 +422,6 @@ export const actionZoomToFitSelection = register({
label: "helpDialog.zoomToSelection",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
@@ -446,8 +435,7 @@ export const actionZoomToFitSelection = register({
});
},
// NOTE this action should use shift-2 per figma, alas
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.THREE &&
event.shiftKey &&
!event.altKey &&
@@ -460,7 +448,6 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
predicate: (elements, appState) => !appState.scrollConstraints,
perform: (elements, appState, _, app) =>
zoomToFit({
targetElements: elements,
@@ -471,8 +458,7 @@ export const actionZoomToFit = register({
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
keyTest: (event, appState) =>
!appState.scrollConstraints &&
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
!event.altKey &&
@@ -1,9 +1,8 @@
import { fireEvent, queryByTestId } from "@testing-library/react";
import { queryByTestId } from "@testing-library/react";
import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY,
STROKE_WIDTH,
} from "@excalidraw/common";
@@ -129,62 +128,6 @@ describe("element locking", () => {
expect(thinStrokeWidthButton).toBeChecked();
});
it("should highlight common stroke width key across freedraw and non-freedraw elements", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.medium,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
});
it("should apply stroke width by element type", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const freedraw = API.createElement({
type: "freedraw",
strokeWidth: FREEDRAW_STROKE_WIDTH.thin,
});
API.setElements([rect, freedraw]);
API.setSelectedElements([rect, freedraw]);
const boldStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-bold`,
);
expect(boldStrokeWidthButton).not.toBe(null);
fireEvent.click(boldStrokeWidthButton!);
const selectedElements = API.getSelectedElements();
const selectedRect = selectedElements.find(
(element) => element.type === "rectangle",
);
const selectedFreedraw = selectedElements.find(
(element) => element.type === "freedraw",
);
expect(selectedRect?.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(selectedFreedraw?.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should create new elements with stroke width by element type", () => {
API.setAppState({ currentItemStrokeWidthKey: "bold" });
const rect = API.createElement({ type: "rectangle" });
const freedraw = API.createElement({ type: "freedraw" });
expect(rect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(freedraw.strokeWidth).toBe(FREEDRAW_STROKE_WIDTH.bold);
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -192,7 +135,7 @@ describe("element locking", () => {
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
strokeWidth: STROKE_WIDTH.bold,
});
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
@@ -202,17 +145,17 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-medium`),
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.medium,
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
@@ -221,7 +164,7 @@ describe("element locking", () => {
API.setElements([rect, text]);
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
+19 -128
View File
@@ -12,7 +12,7 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH_KEYS,
STROKE_WIDTH,
VERTICAL_ALIGN,
KEYS,
randomInteger,
@@ -20,11 +20,9 @@ import {
getFontFamilyString,
getLineHeight,
isTransparent,
getStrokeWidthByKey,
reduceToCommonValue,
invariant,
FONT_SIZES,
type StrokeWidthKey,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -72,11 +70,9 @@ import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
StrokeVariability,
TextAlign,
VerticalAlign,
} from "@excalidraw/element/types";
@@ -87,7 +83,6 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker";
@@ -136,8 +131,6 @@ import {
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
strokeVariabilityConstantIcon,
strokeVariabilityVariableIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -197,11 +190,7 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
/**
* input value (usually the element attribute value,
* but depends on what the action's PanelComponent input expects)
*/
getValue: (element: ExcalidrawElement) => T,
getAttribute: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
@@ -211,7 +200,7 @@ export const getFormValue = function <T extends Primitive>(
let ret: T | null = null;
if (editingTextElement) {
ret = getValue(editingTextElement);
ret = getAttribute(editingTextElement);
}
if (!ret) {
@@ -225,7 +214,7 @@ export const getFormValue = function <T extends Primitive>(
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getValue) ??
reduceToCommonValue(targetElements, getAttribute) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
@@ -555,37 +544,20 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
},
});
const getStrokeWidthKeyForElement = (
element: ExcalidrawElement,
): StrokeWidthKey | null => {
return (
STROKE_WIDTH_KEYS.find(
(key) => getStrokeWidthByKey(element.type, key) === element.strokeWidth,
) ?? null
);
};
const getStrokeWidthForElement = (
element: ExcalidrawElement,
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return getStrokeWidthByKey(element.type, strokeWidthKey);
};
export const actionChangeStrokeWidth = register<StrokeWidthKey>({
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
invariant(value, "actionChangeStrokeWidth: value must be defined");
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeWidth: getStrokeWidthForElement(el, value),
strokeWidth: value,
}),
),
appState: { ...appState, currentItemStrokeWidthKey: value },
appState: { ...appState, currentItemStrokeWidth: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
@@ -593,35 +565,35 @@ export const actionChangeStrokeWidth = register<StrokeWidthKey>({
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList">
<RadioSelection<StrokeWidthKey>
<RadioSelection
group="stroke-width"
options={[
{
value: "thin",
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: "medium",
text: t("labels.medium"),
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-medium",
testId: "strokeWidth-bold",
},
{
value: "bold",
text: t("labels.bold"),
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-bold",
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
app,
getStrokeWidthKeyForElement,
(element) => element.strokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidthKey,
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
@@ -684,87 +656,6 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
),
});
export const actionChangeFreedrawMode = register<StrokeVariability>({
name: "changeFreedrawMode",
label: "labels.pressure",
trackEvent: false,
perform: (elements, appState, value) => {
const variability = value || "constant";
return {
elements: changeProperty(elements, appState, (el) => {
if (el.type !== "freedraw") {
return el;
}
return newElementWith(el, {
strokeOptions: {
...el.strokeOptions,
variability,
},
}) as ExcalidrawElement;
}),
appState: { ...appState, currentItemStrokeVariability: variability },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
const strokeVariability =
getFormValue(
elements,
app,
(element) =>
(element as ExcalidrawFreeDrawElement).strokeOptions?.variability,
(element) => element.type === "freedraw",
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeVariability,
) ?? appState.currentItemStrokeVariability;
// in the compact UI the pressure setting is rendered as a single button
// that cycles between the two variability modes on click
if (data?.cycle) {
const isVariable = strokeVariability === "variable";
return (
<ToolButton
type="button"
icon={
isVariable
? strokeVariabilityVariableIcon
: strokeVariabilityConstantIcon
}
title={t("labels.pressure")}
aria-label={t("labels.pressure")}
onClick={() => updateData(isVariable ? "constant" : "variable")}
/>
);
}
return (
<fieldset>
<legend>{t("labels.pressure")}</legend>
<div className="buttonList">
<RadioSelection<StrokeVariability>
group="strokeOptions.variability"
options={[
{
value: "constant",
text: t("labels.pressure_constant"),
icon: strokeVariabilityConstantIcon,
},
{
value: "variable",
text: t("labels.pressure_variable"),
icon: strokeVariabilityVariableIcon,
},
]}
value={strokeVariability}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
);
},
});
export const actionChangeStrokeStyle = register<
ExcalidrawElement["strokeStyle"]
>({
-1
View File
@@ -13,7 +13,6 @@ export {
actionChangeStrokeWidth,
actionChangeFillStyle,
actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity,
actionChangeFontSize,
actionChangeFontFamily,
-1
View File
@@ -68,7 +68,6 @@ export type ActionName =
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
+2 -11
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
@@ -35,13 +34,12 @@ export const getDefaultAppState = (): Omit<
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemOpacity: DEFAULT_ELEMENT_PROPS.opacity,
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStrokeVariability: "constant",
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
@@ -91,7 +89,6 @@ export const getDefaultAppState = (): Omit<
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
scrollConstraints: null,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
@@ -170,15 +167,10 @@ const APP_STATE_STORAGE_CONF = (<
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStrokeVariability: {
browser: true,
export: false,
server: false,
},
currentItemStartArrowhead: { browser: true, export: false, server: false },
currentItemStrokeColor: { browser: true, export: false, server: false },
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidthKey: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
@@ -227,7 +219,6 @@ const APP_STATE_STORAGE_CONF = (<
scrolledOutside: { browser: true, export: false, server: false },
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
scrollConstraints: { browser: false, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
hoveredElementIds: { browser: false, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
+3 -1
View File
@@ -25,6 +25,8 @@ import type {
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { stringifyWithPrecision } from "./data/json";
import { ExcalidrawError } from "./errors";
import {
createFile,
@@ -188,7 +190,7 @@ export const serializeAsClipboardJSON = ({
files: files ? _files : undefined,
};
return JSON.stringify(contents);
return stringifyWithPrecision(contents);
};
export const copyToClipboard = async (
+11 -46
View File
@@ -41,7 +41,6 @@ import {
canHaveArrowheads,
getTargetElements,
hasBackground,
hasFreedrawMode,
hasStrokeStyle,
hasStrokeWidth,
} from "../scene";
@@ -202,9 +201,9 @@ export const SelectedShapeActions = ({
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) &&
renderAction("changeFreedrawMode")}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
@@ -395,17 +394,6 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{
/* in compact UI the freedraw pressure setting is rendered as a
standalone cycle button in the compact actions list; we render
it in the combined properties popup as well for clarity
*/
(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")
}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
@@ -838,14 +826,6 @@ export const CompactShapeActions = ({
</div>
)}
{/* Freedraw pressure: standalone button cycling the variability mode */}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) && (
<div className="compact-action-item">
{renderAction("changeFreedrawMode", { cycle: true })}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
@@ -1074,11 +1054,6 @@ export const ShapesSwitcher = ({
const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact";
// a pen detected on a tool button's pointer-down, to be applied (enabling
// pen mode) only after the tap's `change` has committed — see the tool
// button handlers below
const pendingPenDetectionRef = useRef(false);
const SELECTION_TOOLS = [
{
type: "selection",
@@ -1177,13 +1152,8 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
// Detect the pen here (pointerType is reliable on pointer-down)
// but DON'T enable pen mode yet: calling setState mid-gesture
// re-renders the controlled radio and, on iOS/iPadOS, aborts
// the ensuing click so the tool isn't selected on the first pen
// tap. Defer it until the tap's `change` has committed (below).
if (!app.state.penDetected && pointerType === "pen") {
pendingPenDetectionRef.current = true;
app.togglePenMode(true);
}
if (value === "selection") {
@@ -1194,21 +1164,16 @@ export const ShapesSwitcher = ({
}
}
}}
onChange={() => {
onChange={({ pointerType }) => {
if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
app.setActiveTool({ type: value });
// Apply the pen detection captured on pointer-down now that the
// tool is selected. rAF keeps the resulting re-render out of the
// `change` event itself. We rely on the pointer-down detection
// rather than this handler's pointerType because the latter is
// unreliable on iOS (its backing ref is cleared before the
// delayed click fires).
if (pendingPenDetectionRef.current) {
pendingPenDetectionRef.current = false;
requestAnimationFrame(() => app.togglePenMode(true));
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
+152 -155
View File
@@ -27,8 +27,6 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -77,9 +75,11 @@ import {
updateObject,
updateActiveTool,
isTransparent,
easeToValuesRAF,
muteFSAbortError,
isTestEnv,
isDevEnv,
easeOut,
updateStable,
addEventListener,
normalizeEOL,
@@ -109,7 +109,6 @@ import {
setDesktopUIMode,
isSelectionLikeTool,
oneOf,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
@@ -201,6 +200,7 @@ import {
cropElement,
wrapText,
isElementLink,
parseElementLinkFromURL,
isMeasureTextSupported,
normalizeText,
measureText,
@@ -261,7 +261,6 @@ import {
getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
parseElementLinkFromURL,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -329,7 +328,7 @@ import {
actionToggleCropEditor,
} from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { actionToggleHandTool } from "../actions/actionCanvas";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { actionPaste } from "../actions/actionClipboard";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { actionUnlockAllElements } from "../actions/actionElementLock";
@@ -411,14 +410,6 @@ import {
isGridModeEnabled,
} from "../snapping";
import { Renderer } from "../scene/Renderer";
import {
type ScrollToContentOptions,
SCROLL_TO_CONTENT_ANIMATION_KEY,
scrollToElements,
constrainScrollState,
animateToConstraints,
isViewportOverscrolled,
} from "../scroll";
import {
setEraserCursor,
setCursor,
@@ -431,12 +422,16 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars";
import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
import { tryParseSpreadsheet } from "../charts";
import { AnimationController } from "../renderer/animation";
import ConvertElementTypePopup, {
getConversionTypeFromElements,
@@ -611,10 +606,6 @@ const YOUTUBE_VIDEO_STATES = new Map<
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
/** how long after the last pan/zoom we animate the rubberband back into the
* scroll constraints box */
const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -4143,7 +4134,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roundness: null,
roughness: this.state.currentItemRoughness,
@@ -4314,9 +4305,6 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: force ?? !prevState.penMode,
penDetected: true,
currentItemStrokeVariability: !prevState.penDetected
? "variable"
: prevState.currentItemStrokeVariability,
};
});
};
@@ -4347,70 +4335,148 @@ class App extends React.Component<AppProps, AppState> {
});
};
private cancelInProgressAnimation: (() => void) | null = null;
scrollToContent = (
target?:
/**
* target to scroll to
*
* - string - id of element or group, or url containing elementLink
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
*/
target:
| string
| ExcalidrawElement
| readonly NonDeletedExcalidrawElement[],
opts?: ScrollToContentOptions,
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: (
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%)
*/
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
) => {
let elements: readonly NonDeleted<ExcalidrawElement>[];
if (typeof target === "string") {
const id = isElementLink(target)
? parseElementLinkFromURL(target)
: target;
elements = id ? this.scene.getElementsFromId(id) : [];
} else if (Array.isArray(target)) {
elements = target;
} else if (target) {
elements = [target as NonDeleted<ExcalidrawElement>];
} else {
elements = this.scene.getNonDeletedElements();
}
const scrollConstraints = opts?.scrollConstraints ?? null;
if (scrollConstraints || this.state.scrollConstraints) {
flushSync(() => {
this.setState((prevState) => ({
scrollConstraints,
...constrainScrollState({ ...prevState, scrollConstraints }),
}));
});
}
if (!elements.length) {
if (typeof target === "string" && isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
let id: string | null;
if (isElementLink(target)) {
id = parseElementLinkFromURL(target);
} else {
id = target;
}
if (id) {
const elements = this.scene.getElementsFromId(id);
if (elements?.length) {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
} else if (isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
}
return;
}
// Navigating to an element by id or element-link defaults to zooming the
// element into view, animated — matching the historical element-link
// behavior — unless the caller opts out.
const resolvedOpts =
typeof target === "string"
? {
...opts,
fitToViewport: undefined,
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
}
: opts;
this.cancelInProgressAnimation?.();
scrollToElements(
this.state,
elements,
this.setState.bind(this),
resolvedOpts,
);
// convert provided target into ExcalidrawElement[] if necessary
const targetElements = Array.isArray(target) ? target : [target];
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
});
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targetElements, this.state);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
};
private maybeUnfollowRemoteUser = () => {
@@ -4423,43 +4489,11 @@ class App extends React.Component<AppProps, AppState> {
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
this.setState({ shouldCacheIgnoreZoom: false });
this.cancelInProgressAnimation?.();
this.maybeUnfollowRemoteUser();
this.setState(state);
// with rubberband tolerance, allow a soft overscroll while interacting and
// animate back to the box once the interaction settles; otherwise hard-clamp
const tolerance = this.state.scrollConstraints?.tolerance ?? 0;
this.constrainViewportToScrollConstraints(tolerance);
if (tolerance > 0) {
this.snapBackToScrollConstraintsDebounced();
}
};
/** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
* unconstrained). Runs as a queued update, so it sees the preceding change.
* `tolerance` (screen px) relaxes the bounds for rubberbanding. */
private constrainViewportToScrollConstraints = (tolerance = 0) => {
this.setState((prevState) =>
prevState.scrollConstraints
? constrainScrollState(prevState, tolerance)
: null,
);
};
/** animates an overscrolled viewport back inside the constraint box */
private snapBackToScrollConstraints = () => {
if (!this.unmounted) {
animateToConstraints(this.state, (viewport) => this.setState(viewport));
}
};
private snapBackToScrollConstraintsDebounced = debounce(
this.snapBackToScrollConstraints,
SCROLL_CONSTRAINTS_SNAP_BACK_DELAY,
);
setToast = (toast: AppState["toast"]) => {
this.setState({ toast });
};
@@ -5638,15 +5672,9 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// while rubberband-overscrolled past the scroll constraints, suppress
// zooming until the viewport has snapped back inside the box
if (isViewportOverscrolled(this.state)) {
return;
}
const initialScale = gesture.initialScale;
if (initialScale) {
this.translateCanvas((state) => ({
this.setState((state) => ({
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
@@ -6276,7 +6304,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -6894,11 +6922,7 @@ class App extends React.Component<AppProps, AppState> {
? 1
: distance / gesture.initialDistance;
// while rubberband-overscrolled past the scroll constraints, pin the zoom
// (still allowing the pan below) until the viewport has snapped back
const nextZoom = isViewportOverscrolled(this.state)
? this.state.zoom.value
: scaleFactor
const nextZoom = scaleFactor
? getNormalizedZoom(initialScale * scaleFactor)
: this.state.zoom.value;
@@ -7750,7 +7774,6 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: true,
penDetected: true,
currentItemStrokeVariability: "variable",
};
});
}
@@ -8969,8 +8992,6 @@ class App extends React.Component<AppProps, AppState> {
const simulatePressure = event.pressure === 0.5;
const strokeVariability = this.state.currentItemStrokeVariability;
const element = newFreeDrawElement({
type: elementType,
x: gridX,
@@ -8978,24 +8999,15 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("freedraw"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
roundness: null,
simulatePressure,
strokeOptions: {
variability: strokeVariability,
streamline:
event.pointerType !== "mouse"
? DEFAULT_STROKE_STREAMLINE_PRECISE
: DEFAULT_STROKE_STREAMLINE,
},
locked: false,
frameId: topLayerFrame ? topLayerFrame.id : null,
points: [pointFrom<LocalPoint>(0, 0)],
// pressures are only consumed when rendering a real-pressure stroke, so
// skip persisting them while pressure is being simulated
pressures: simulatePressure ? [] : [event.pressure],
});
@@ -9046,7 +9058,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("iframe"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("iframe"),
@@ -9099,7 +9111,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("embeddable"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("embeddable"),
@@ -9146,7 +9158,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth("image"),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: null,
@@ -9324,7 +9336,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9351,7 +9363,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9488,13 +9500,6 @@ class App extends React.Component<AppProps, AppState> {
: null;
}
private getCurrentItemStrokeWidth(elementType: ExcalidrawElement["type"]) {
return getStrokeWidthByKey(
elementType,
this.state.currentItemStrokeWidthKey,
);
}
private createGenericElementOnPointerDown = (
elementType: ExcalidrawGenericElement["type"] | "embeddable",
pointerDownState: PointerDownState,
@@ -9518,7 +9523,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -12792,12 +12797,6 @@ class App extends React.Component<AppProps, AppState> {
const { deltaX, deltaY } = event;
// note that event.ctrlKey is necessary to handle pinch zooming
if (event.metaKey || event.ctrlKey) {
// while rubberband-overscrolled past the scroll constraints, suppress
// zooming until the viewport has snapped back inside the box
if (isViewportOverscrolled(this.state)) {
return;
}
const sign = Math.sign(deltaY);
const MAX_STEP = ZOOM_STEP * 100;
const absDelta = Math.abs(deltaY);
@@ -12951,8 +12950,6 @@ class App extends React.Component<AppProps, AppState> {
cb && cb();
},
);
// a smaller viewport may push the min zoom up / shrink the pan range
this.constrainViewportToScrollConstraints();
}
};
@@ -120,24 +120,6 @@
}
}
// on tablet, the pen mode button is rendered as a separate floating button
// below the compact actions menu (see LayerUI.tsx)
.App-menu_top__left > .ToolIcon__penMode {
justify-self: center;
.ToolIcon__icon {
width: var(--lg-button-size);
height: var(--lg-button-size);
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
}
// no shadow while pen mode is active (the active fill is enough)
.ToolIcon_type_checkbox:checked + .ToolIcon__icon {
box-shadow: none;
}
}
.disable-view-mode {
display: flex;
justify-content: center;
+10 -30
View File
@@ -235,6 +235,8 @@ const LayerUI = ({
);
const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel;
return (
<Section
heading="selectedShapeActions"
@@ -242,7 +244,7 @@ const LayerUI = ({
"transition-left": appState.zenModeEnabled,
})}
>
{isCompactStylesPanel ? (
{isCompactMode ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
@@ -310,23 +312,6 @@ const LayerUI = ({
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
{/* in compact UI the pen mode button lives outside the toolbar, as
a separate floating button below the compact actions menu
(same as we render it on mobile); shown alongside the compact
actions island, i.e. when a drawing tool or elements are
selected */}
{isCompactStylesPanel &&
!appState.viewModeEnabled &&
shouldRenderSelectedShapeActions && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
)}
</Stack.Col>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
@@ -358,18 +343,13 @@ const LayerUI = ({
/>
{heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}>
{/* in compact UI the pen mode button is rendered
as a separate floating button below the compact
actions menu */}
{!isCompactStylesPanel && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
)}
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
-68
View File
@@ -1249,74 +1249,6 @@ export const SloppinessCartoonistIcon = createIcon(
modifiedTablerIconProps,
);
export const strokeVariabilityConstantIcon = createIcon(
<g>
<path
d="M4 12 C 5 8, 6 8, 8 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 12 C 9 16, 10 16, 12 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 12 C 14 8, 15 8, 16 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 12 C 17 16, 18 16, 19 12"
fill="none"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
tablerIconProps,
);
export const strokeVariabilityVariableIcon = createIcon(
<g>
<path
d="M4 12 C 5 8, 6 8, 8 12"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 12 C 9 16, 10 16, 12 12"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 12 C 14 8, 15 8, 16 12"
fill="none"
strokeWidth="2.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 12 C 17 16, 18 16, 19 12"
fill="none"
strokeWidth="3.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>,
tablerIconProps,
);
export const EdgeSharpIcon = createIcon(
<svg strokeWidth="1.5">
<path d="M3.33334 9.99998V6.66665C3.33334 6.04326 3.33403 4.9332 3.33539 3.33646C4.95233 3.33436 6.06276 3.33331 6.66668 3.33331H10" />
+37 -2
View File
@@ -22,6 +22,41 @@ import type {
ImportedLibraryData,
} from "./types";
const SCALAR_ROUNDED_KEYS = new Set(["x", "y", "width", "height"]);
// JSON.stringify encodes \x00 as \u0000 (6-char literal sequence) in the output
// string. We use this as a sentinel so we can strip the surrounding quotes
// afterward, emitting raw number tokens without a float round-trip.
const PRECISION_SENTINEL = "\x00";
const PRECISION_SENTINEL_RE = /"\\u0000([^"]+)\\u0000"/g;
export const stringifyWithPrecision = (
value: unknown,
precision = 2,
space?: number | string,
): string => {
const fmt = (n: number) =>
`${PRECISION_SENTINEL}${n.toFixed(precision)}${PRECISION_SENTINEL}`;
return JSON.stringify(
value,
(key, val) => {
if (SCALAR_ROUNDED_KEYS.has(key) && typeof val === "number") {
return fmt(val);
}
if (key === "points" && Array.isArray(val)) {
return (val as number[][]).map((pt) =>
Array.isArray(pt)
? pt.map((n) => (typeof n === "number" ? fmt(n) : n))
: pt,
);
}
return val;
},
space,
).replace(PRECISION_SENTINEL_RE, "$1");
};
export type JSONExportData = {
elements: readonly NonDeleted<ExcalidrawElement>[];
appState: AppState;
@@ -71,7 +106,7 @@ export const serializeAsJSON = (
undefined,
};
return JSON.stringify(data, null, 2);
return stringifyWithPrecision(data, 2, 2);
};
export const saveAsJSON = async ({
@@ -141,7 +176,7 @@ export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
source: getExportSource(),
libraryItems,
};
return JSON.stringify(data, null, 2);
return stringifyWithPrecision(data, 2, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
+33 -86
View File
@@ -3,7 +3,6 @@ import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
DEFAULT_FONT_FAMILY,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
FONT_FAMILY,
@@ -19,9 +18,6 @@ import {
getSizeFromPoints,
normalizeLink,
getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS,
type StrokeWidthKey,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -74,7 +70,6 @@ import type {
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
StrokeVariability,
StrokeRoundness,
} from "@excalidraw/element/types";
@@ -101,38 +96,7 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_LINEAR_PX = 75_000;
// Last resort fix for extremely large linear elements (lines / arrows), which
// would otherwise freeze the editor while rendering — e.g. a dotted or dashed
// stroke spanning a huge distance generates an enormous dash array.
// https://github.com/excalidraw/excalidraw/issues/11497
const handleOversizedLinearElements = <T extends ExcalidrawLinearElement>(
element: T,
): T => {
if (element.width <= MAX_LINEAR_PX && element.height <= MAX_LINEAR_PX) {
return element;
}
const label =
element.type === "arrow"
? `${isElbowArrow(element) ? "elbow" : "simple"} arrow`
: element.type;
console.error(
`Removing extremely large ${label} ${element.id} (width: ${element.width}, height: ${element.height}, x: ${element.x}, y: ${element.y})`,
);
return {
...element,
x: 0,
y: 0,
width: 100,
height: 100,
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
isDeleted: true,
};
};
const MAX_ARROW_PX = 75_000;
const restoreLinearElementPoints = (
points: unknown,
@@ -224,43 +188,6 @@ export type RestoredDataState = {
files: BinaryFiles;
};
const ALLOWED_STROKE_VARIABILITIES = new Set<StrokeVariability>([
"constant",
"variable",
]);
const restoreStrokeVariability = (
variability: unknown,
defaultValue: StrokeVariability,
): StrokeVariability => {
return typeof variability === "string" &&
ALLOWED_STROKE_VARIABILITIES.has(variability as StrokeVariability)
? (variability as StrokeVariability)
: defaultValue;
};
const getStrokeWidthKey = (strokeWidth: unknown): StrokeWidthKey | null => {
return isFiniteNumber(strokeWidth)
? STROKE_WIDTH_KEYS.find((key) => STROKE_WIDTH[key] === strokeWidth) ?? null
: null;
};
const restoreFreedrawStrokeOptions = (
strokeOptions: unknown,
): { variability: StrokeVariability; streamline: number } => {
const options =
strokeOptions && typeof strokeOptions === "object"
? (strokeOptions as { variability?: unknown; streamline?: unknown })
: null;
return {
variability: restoreStrokeVariability(options?.variability, "variable"),
streamline: isFiniteNumber(options?.streamline)
? options?.streamline
: DEFAULT_STROKE_STREAMLINE,
};
};
const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
if (Object.keys(FONT_FAMILY).includes(fontFamilyName)) {
return FONT_FAMILY[
@@ -556,7 +483,6 @@ export const restoreElement = (
return restoreElementWithProperties(element, {
points,
simulatePressure: element.simulatePressure,
strokeOptions: restoreFreedrawStrokeOptions(element.strokeOptions),
pressures,
});
}
@@ -591,7 +517,7 @@ export const restoreElement = (
} as ExcalidrawLinearElement));
}
const restoredLine = restoreElementWithProperties(element, {
return restoreElementWithProperties(element, {
type: "line",
startBinding: null,
endBinding: null,
@@ -609,8 +535,6 @@ export const restoreElement = (
: {}),
...getSizeFromPoints(points),
});
return handleOversizedLinearElements(restoredLine);
case "arrow": {
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
@@ -677,7 +601,37 @@ export const restoreElement = (
),
};
return handleOversizedLinearElements(normalizedRestoredElement);
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
}
// generic elements
@@ -1102,13 +1056,6 @@ export const restoreAppState = (
nextAppState.boxSelectionMode = boxSelectionMode;
}
// legacy
if ((appState as any).currentItemStrokeWidth !== undefined) {
nextAppState.currentItemStrokeWidthKey =
getStrokeWidthKey((appState as any).currentItemStrokeWidth) ??
defaultAppState.currentItemStrokeWidthKey;
}
return {
...nextAppState,
cursorButton: localAppState?.cursorButton || "up",
+1 -2
View File
@@ -10,7 +10,6 @@ import {
applyDarkModeFilter,
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
getStrokeWidthByKey,
isShallowEqual,
} from "@excalidraw/common";
@@ -451,4 +450,4 @@ export function useExcalidrawStateValue(
export { _useOnAppStateChange as useOnExcalidrawStateChange };
export { applyDarkModeFilter, getStrokeWidthByKey };
export { applyDarkModeFilter };
-3
View File
@@ -35,9 +35,6 @@
"strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness",
"pressure": "Pressure",
"pressure_constant": "Constant",
"pressure_variable": "Variable",
"opacity": "Opacity",
"textAlign": "Text align",
"edges": "Edges",
@@ -123,9 +123,4 @@ export class AnimationController {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
static reset() {
AnimationController.animations.clear();
AnimationController.cancelScheduledFrame();
}
}
-1
View File
@@ -9,7 +9,6 @@ export {
hasBackground,
hasStrokeWidth,
hasStrokeStyle,
hasFreedrawMode,
canHaveArrowheads,
canChangeRoundness,
} from "@excalidraw/element";
-366
View File
@@ -1,366 +0,0 @@
import { easeOut, MAX_ZOOM, MIN_ZOOM } from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { zoomToFit } from "./actions/actionCanvas";
import { AnimationController } from "./renderer/animation";
import { getNormalizedZoom } from "./scene";
import { calculateScrollCenter } from "./scene/scroll";
import type {
AppState,
NormalizedZoomValue,
Offsets,
ScrollConstraints,
} from "./types";
export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
/** default duration of the scroll/zoom animation, in milliseconds */
const DEFAULT_ANIMATION_DURATION = 500;
export type ScrollToContentOptions = (
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%) */
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
scrollConstraints?: ScrollConstraints | null;
};
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
/**
* The smallest zoom at which the constraint box still fully covers the viewport.
* Below this, the viewport would extend past the box on its binding dimension,
* so it becomes the enforced minimum zoom. When the box is smaller than the
* viewport this is > 1, forcing a zoom-in (best-effort fit).
*/
export const getMinZoomForConstraints = (
constraints: ScrollConstraints,
viewport: { width: number; height: number },
): number =>
clamp(
Math.max(
viewport.width / constraints.width,
viewport.height / constraints.height,
),
MIN_ZOOM,
MAX_ZOOM,
);
/**
* Clamps a single scroll axis so the visible scene span stays inside the box.
* The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within
* `[boxStart, boxStart + boxSize]`, expanded by `startExpand` at the low edge
* and `endExpand` at the high edge (rubberband overscroll plus any padding).
* When the box can't cover the viewport on this axis (only at the MAX_ZOOM cap
* for a tiny box) we center the box instead.
*/
const constrainScrollAxis = (
scroll: number,
boxStart: number,
boxSize: number,
visibleSize: number,
startExpand: number,
endExpand: number,
): number => {
const max = -boxStart + startExpand;
const min = visibleSize - (boxStart + boxSize) - endExpand;
return min > max ? (min + max) / 2 : clamp(scroll, min, max);
};
/**
* Clamps a proposed scroll/zoom so that, when `scrollConstraints` is set, the
* viewport cannot pan or zoom out of the box. Returns the input scroll/zoom
* unchanged when there are no constraints. Because the whole viewport is kept
* inside the box, any zoom anchor (which lives within the viewport) is also
* guaranteed to stay inside the box.
*
* `tolerance` is the rubberband overscroll allowance, in screen pixels: the
* viewport may pan past the box edges (and zoom out below the fit zoom) until
* each edge has crossed the box by up to `tolerance` pixels on screen,
* independent of the current zoom. Pass `0` (default) for a hard clamp.
*
* An explicit `minZoom`/`maxZoom` on the constraints takes precedence over the
* zoom the box would otherwise enforce: `minZoom` replaces the fit zoom (so the
* viewport may zoom out past the box) and `maxZoom` replaces the global
* `MAX_ZOOM` cap.
*
* `padding` ([top, right, bottom, left], screen px) permanently extends the
* scrollable area past each box edge by that many on-screen pixels, so the
* viewport can reveal that much empty space beyond the box. Unlike `tolerance`
* it does not snap back.
*/
export const constrainScrollState = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
tolerance = 0,
): Viewport => {
const { scrollConstraints, width, height } = state;
if (!scrollConstraints) {
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
}
tolerance = Math.max(tolerance, 0);
// zoom bounds: an explicit min/maxZoom on the constraints takes precedence
// over the box-enforced fit zoom (lower) and the global MAX_ZOOM (upper).
// Without a `minZoom`, relax the fit zoom by `tolerance` so the user can
// briefly zoom out past it (rubberbanding), letting each viewport edge cross
// the box by up to `tolerance` screen px.
const minZoom =
scrollConstraints.minZoom ??
getMinZoomForConstraints(scrollConstraints, {
width: width - 2 * tolerance,
height: height - 2 * tolerance,
});
const maxZoom = scrollConstraints.maxZoom ?? MAX_ZOOM;
const zoomValue = getNormalizedZoom(
clamp(state.zoom.value, minZoom, maxZoom),
);
// `tolerance`/`padding` are screen px; express them in scene coords at the
// current zoom so the on-screen amount stays the same regardless of zoom.
const overscroll = tolerance / zoomValue;
const [padTop, padRight, padBottom, padLeft] = (
scrollConstraints.padding ?? [0, 0, 0, 0]
).map((px) => px / zoomValue);
return {
scrollX: constrainScrollAxis(
state.scrollX,
scrollConstraints.x,
scrollConstraints.width,
width / zoomValue,
overscroll + padLeft,
overscroll + padRight,
),
scrollY: constrainScrollAxis(
state.scrollY,
scrollConstraints.y,
scrollConstraints.height,
height / zoomValue,
overscroll + padTop,
overscroll + padBottom,
),
zoom: { value: zoomValue },
};
};
/**
* Whether the viewport currently sits outside the hard constraint bounds, i.e.
* the user is rubberband-overscrolled (panned past an edge or zoomed out below
* the fit zoom). Always `false` when there are no constraints. Used to suppress
* zooming until the viewport has settled back inside the box.
*/
export const isViewportOverscrolled = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
): boolean => {
if (!state.scrollConstraints) {
return false;
}
const target = constrainScrollState(state); // hard clamp (tolerance 0)
return (
target.scrollX !== state.scrollX ||
target.scrollY !== state.scrollY ||
target.zoom.value !== state.zoom.value
);
};
/**
* Rubberband snap-back: animates the viewport from its current (possibly
* overscrolled) position back inside the constraint box via the shared
* AnimationController. No-op when already within the hard bounds (or when there
* are no constraints).
*/
export const animateToConstraints = (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
>,
onFrame: (
viewport: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
>,
) => void,
duration = DEFAULT_ANIMATION_DURATION,
) => {
const target = constrainScrollState(state); // hard clamp (tolerance 0)
if (
target.scrollX === state.scrollX &&
target.scrollY === state.scrollY &&
target.zoom.value === state.zoom.value
) {
return;
}
animateToViewport(state, target, duration, onFrame);
};
/**
* Scrolls (and optionally zooms) the viewport so that the given target is in
* view, optionally animating the transition.
*/
export const scrollToElements = (
state: AppState,
target: readonly ExcalidrawElement[],
onFrame: (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
>,
) => void,
opts?: ScrollToContentOptions,
) => {
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
const viewport = getTargetViewport(state, target, opts);
if (opts?.animate) {
animateToViewport(
state,
viewport,
opts.duration ?? DEFAULT_ANIMATION_DURATION,
onFrame,
);
} else {
// no animation: jump straight to the target. Re-enable zoom caching in
// case we just cancelled an in-flight animation that had suppressed it.
onFrame({ ...viewport, shouldCacheIgnoreZoom: false });
}
};
/** Computes the viewport (scroll + zoom) that brings the target elements into
* view, based on the requested fit behavior. */
const getTargetViewport = (
state: AppState,
targetElements: readonly ExcalidrawElement[],
opts?: ScrollToContentOptions,
): Viewport => {
let viewport: Viewport;
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements,
appState: state,
fitToViewport: !!opts.fitToViewport,
viewportZoomFactor: opts.viewportZoomFactor,
minZoom: opts.minZoom,
maxZoom: opts.maxZoom,
});
viewport = {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
};
} else {
// keep the current zoom, only recenter the viewport on the target
const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
viewport = { scrollX, scrollY, zoom: state.zoom };
}
// keep the target inside any active scroll constraints (no-op otherwise)
return constrainScrollState({ ...state, ...viewport });
};
/**
* Interpolates the viewport from `from` to `target` at the (already-eased)
* blend amount `factor` (0 = `from`, 1 = `target`).
*
* Zoom is interpolated geometrically (so it feels uniform), and rather than
* tweening scrollX/scrollY directly we tween the *focal point* the scene
* point under the viewport center and derive scroll from it. Mixing a linear
* scroll with a geometric zoom makes the focal point swoop sideways
* mid-animation (most visible when zooming out); gliding the focal point keeps
* it steady. `width/2/zoom - scroll` is the inverse of `centerScrollOn` without
* offsets, so factor 0/1 land exactly on `from`/`target`.
*/
export const interpolateViewport = ({
from,
target,
factor,
}: {
from: Pick<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">;
target: Viewport;
factor: number;
}): Viewport => {
const zoom = (from.zoom.value *
Math.pow(
target.zoom.value / from.zoom.value,
factor,
)) as NormalizedZoomValue;
const fromCenterX = from.width / 2 / from.zoom.value - from.scrollX;
const fromCenterY = from.height / 2 / from.zoom.value - from.scrollY;
const toCenterX = from.width / 2 / target.zoom.value - target.scrollX;
const toCenterY = from.height / 2 / target.zoom.value - target.scrollY;
const centerX = fromCenterX + (toCenterX - fromCenterX) * factor;
const centerY = fromCenterY + (toCenterY - fromCenterY) * factor;
return {
scrollX: from.width / 2 / zoom - centerX,
scrollY: from.height / 2 / zoom - centerY,
zoom: { value: zoom },
};
};
/** Eases the viewport from its current position to `target` over `duration`,
* driving the transition through the shared AnimationController so it doesn't
* slow down other processes. */
const animateToViewport = (
from: Pick<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">,
target: Viewport,
duration: number,
onFrame: (
state: Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
>,
) => void,
) => {
AnimationController.start<{ elapsed: number }>(
SCROLL_TO_CONTENT_ANIMATION_KEY,
({ deltaTime, state }) => {
const elapsed = (state?.elapsed ?? 0) + deltaTime;
const progress = Math.min(elapsed / duration, 1);
const factor = easeOut(clamp(progress, 0, 1));
onFrame({
...interpolateViewport({ from, target, factor }),
shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
});
return progress < 1 ? { elapsed } : null;
},
);
};
@@ -904,8 +904,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -962,7 +961,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1105,8 +1103,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1160,7 +1157,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -1321,8 +1317,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1376,7 +1371,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1470,7 +1464,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"versionNonce": 493213705,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -1522,7 +1516,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -1654,8 +1648,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1709,7 +1702,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1803,7 +1795,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"versionNonce": 493213705,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -1855,7 +1847,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -1987,8 +1979,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2042,7 +2033,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -2203,8 +2193,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2258,7 +2247,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2446,8 +2434,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2501,10 +2488,9 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"scrolledOutside": true,
"searchMatches": null,
"selectedElementIds": {
"id3": true,
@@ -2746,8 +2732,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2803,7 +2788,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2870,7 +2854,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"versionNonce": 915032327,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -2956,7 +2940,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -3120,8 +3104,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "dotted",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "bold",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3175,7 +3158,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3232,14 +3214,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"seed": 1278240551,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1349943049,
"versionNonce": 1402203177,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -3261,14 +3243,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 406373543,
"seed": 1898319239,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 10,
"versionNonce": 1402203177,
"version": 9,
"versionNonce": 941653321,
"width": 20,
"x": 20,
"y": 30,
@@ -3277,7 +3259,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of elements 1`] = `2`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `17`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] number of renders 1`] = `16`;
exports[`contextMenu element > selecting 'Paste styles' in context menu pastes styles > [end of test] redo stack 1`] = `[]`;
@@ -3323,7 +3305,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -3477,11 +3459,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"strokeWidth": 4,
"strokeStyle": "dotted",
"version": 7,
},
"inserted": {
"strokeWidth": 2,
"strokeStyle": "solid",
"version": 6,
},
},
@@ -3502,11 +3484,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"strokeStyle": "dotted",
"roughness": 2,
"version": 8,
},
"inserted": {
"strokeStyle": "solid",
"roughness": 1,
"version": 7,
},
},
@@ -3527,11 +3509,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"roughness": 2,
"opacity": 60,
"version": 9,
},
"inserted": {
"roughness": 1,
"opacity": 100,
"version": 8,
},
},
@@ -3539,31 +3521,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"id": "id17",
},
{
"appState": AppStateDelta {
"delta": Delta {
"deleted": {},
"inserted": {},
},
},
"elements": {
"added": {},
"removed": {},
"updated": {
"id3": {
"deleted": {
"opacity": 60,
"version": 10,
},
"inserted": {
"opacity": 100,
"version": 9,
},
},
},
},
"id": "id19",
},
{
"appState": AppStateDelta {
"delta": Delta {
@@ -3591,7 +3548,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 2,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"version": 4,
},
"inserted": {
@@ -3601,13 +3557,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"version": 3,
},
},
},
},
"id": "id21",
"id": "id19",
},
]
`;
@@ -3642,8 +3597,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3697,7 +3651,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3791,7 +3744,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"versionNonce": 2019559783,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -3843,7 +3796,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -3967,8 +3920,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4022,7 +3974,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4116,7 +4067,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"versionNonce": 2019559783,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -4168,7 +4119,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -4292,8 +4243,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4349,7 +4299,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4412,7 +4361,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"versionNonce": 1006504105,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -4496,7 +4445,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"version": 3,
"width": 20,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -5579,8 +5528,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5636,7 +5584,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5699,7 +5646,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"versionNonce": 1150084233,
"width": 10,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -5731,7 +5678,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"versionNonce": 23633383,
"width": 10,
"x": 12,
"y": 0,
"y": "0.00000",
}
`;
@@ -5783,7 +5730,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"version": 3,
"width": 10,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -5837,7 +5784,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"version": 3,
"width": 10,
"x": 12,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -6798,8 +6745,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6855,7 +6801,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6922,7 +6867,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"versionNonce": 1723083209,
"width": 10,
"x": -10,
"y": 0,
"y": "0.00000",
}
`;
@@ -6956,7 +6901,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"versionNonce": 760410951,
"width": 10,
"x": 12,
"y": 0,
"y": "0.00000",
}
`;
@@ -7008,7 +6953,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"version": 3,
"width": 10,
"x": -10,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -7062,7 +7007,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"version": 3,
"width": 10,
"x": 12,
"y": 0,
"y": "0.00000",
},
"inserted": {
"isDeleted": true,
@@ -7757,8 +7702,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7815,7 +7759,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8759,8 +8702,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8814,7 +8756,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -9752,8 +9693,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9810,7 +9750,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`export > export svg-embedded scene > svg-embdedded scene export output 1`] = `
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTS27bMFx1MDAxMN33XHUwMDE0grItXHUwMDEy2UW68C7NXHUwMDA3zVwiXdRcdTAwMDW6KLpgxLE0ME1cdTAwMTLkKLZrXHUwMDE4yDG661x1MDAxNXOEXGZpVTTlRFx1MDAwMlxi8M3vzZvh7kNRlLS1UM6KXHUwMDEyNrVQKJ1Yl1x1MDAxZlx1MDAwM/5cdTAwMDTOo9Fsmsa7N52ro2dLZGdcdTAwMTdcdTAwMTfKcEBrPM0+VVV1XGJcdTAwMDJcdTAwMDUr0OTZ7Vx1MDAxN9+LYlx1MDAxN0+2oFxmoVfRLVx1MDAwMv/rXHUwMDEybCihXHUwMDFihqrhts1ua5TUMjL5PEAtYNNSjlx03SjIXHUwMDAyPTmzhGujjFx1MDAwYlx1MDAxNc8mXHUwMDEw/lT0UdTLxplOy8GHnNDeXG7HzSS/XHUwMDA1KjWnbczOerBa5ajGz57idIS/XHUwMDE3xUWbVoNcdTAwMGaCTVx1MDAwNtRYUSOF5idV6lwiMLT3Mmr7O3FyYlx1MDAwNfdBXFzdKTXAqCVsxmBssa+WXHUwMDE5PIDMXHUwMDE4pOGfYN+MrnN50d/w3CmmWFxi5SFcdFx1MDAxYlxu3qadyIp2VlxuXHUwMDFh1VWol2M/3rPlXHUwMDFiuePesKIv//4+XHUwMDFmjchomuOfQHBaZeidWKFcbppeZimuXHUwMDE0NqHPUsHiaNTcLCHv92AmY5O15nxcdTAwMDI1uFPhjcNcdTAwMDa1UD/epCc6Mt/BXHUwMDFmXGKS6+C4c/g6bPP59DJcdTAwMWH2fMZZl8LaObFebD28Kd5cdTAwMDeUo1ZcdTAwMGZcdTAwMTiBTW1G6MFIuNXiUY11LJ9cdTAwMTDWX07X/2xcdTAwMTG/nng/godOXHUwMDExznnUNfFcdTAwMWWEee5cdTAwMDK/feTHb1x1MDAwM3po/1xua2IoWiJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
"<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36"><!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nHVTTW/bMFxmve9XXHUwMDE47rVonVx1MDAxNN0ht36iPbSHZsBcdTAwMGXDXHUwMDBlqsXYRFx1MDAxNEmQ6CZZXHUwMDEwYD9jt/3F/YRRimvZTmtcdTAwMDNcdTAwMDb8SJGPj0+7L1mW09ZCPsty2JRCoXRinZ9cdTAwMDb8XHKcR6M5NI3/3jSujJk1kZ2dnyvDXHUwMDA3auNpdlFcdTAwMTTF4Vx1MDAxMChYgSbPaT/4P8t28ctcdTAwMTGU4ehVTIvAe1+CXHIldMNQcVZcdTAwMTRcdTAwMWSwXHUwMDFkXHUwMDAza5RUMzj52kdrwKqmI1joSoUuXHTx5MxcdTAwMTJujDIudD+ZQHhcdTAwMTOBV1EuK2dcdTAwMWEtu1x1MDAxY3JCeytcdTAwMWNcdTAwMGaW8lx1MDAxNqjUnLaxOmvDyuWjXHUwMDFl31ui01x1MDAxMf7ZKW5a1Vx1MDAxYXxcdTAwMTBv0qHGilx1MDAxMimoMOnNXHUwMDE1XHUwMDE42kdcdTAwMTl1/pk4ObGCxyC0bpTqYNRcdTAwMTI2YzCO2HZcdTAwMWJcdTAwMDQ8gFx1MDAxYzBIRjjCno0uh/Kiv2VcdTAwMGZQLLFcdTAwMTDKQ1x1MDAxMjY0vEv+XHUwMDE4NG2sXHUwMDE0NOqrUC/Heey55Vx1MDAwN7Wjh1jRf3///O6tyGia469AcFpcZtB7sUJcdTAwMTU0vVx1MDAxY5S4UliFOXNcdTAwMDWL3qp5WEL2elx1MDAxNyZjU7Tkelx1MDAwMjW4Y+GNw1xutVDfPqQnXHUwMDFhMi/gXHUwMDBmXHUwMDA0yTXQn1x1MDAxY1x1MDAxZTpDn00vY2DP37jrXFxYOyfWi6OH+8V+QDlcdTAwMWH1gFx1MDAxMdg0ZoSejIQ7LV7VWMf8XHJhfX1s/5NFfFri7Vxunlx1MDAxYUU451WXxD5cYvvcXHUwMDA1fvvIj+9cdTAwMDa00P4/o1sqkiJ9<!-- payload-end --></metadata><defs><style class="style-fonts">
</style></defs><rect x="0" y="0" width="36" height="36" fill="#ffffff"></rect><g transform="translate(10 10) rotate(0 8 8)" data-id="A"><text x="0" y="17.619999999999997" font-family="Excalifont, Xiaolai, sans-serif, Segoe UI Emoji" font-size="20px" fill="#1e1e1e" text-anchor="start" style="white-space: pre;" direction="ltr" dominant-baseline="alphabetic">😀</text></g></svg>"
`;
File diff suppressed because it is too large Load Diff
@@ -128,8 +128,8 @@ exports[`move element > rectangles with binding arrow 5`] = `
"version": 4,
"versionNonce": 760410951,
"width": 100,
"x": 0,
"y": 0,
"x": "0.00000",
"y": "0.00000",
}
`;
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import React from "react";
import { CODES, STROKE_WIDTH } from "@excalidraw/common";
import { CODES } from "@excalidraw/common";
import { copiedStyles } from "../actions/actionStyles";
import { Excalidraw } from "../index";
@@ -78,7 +78,7 @@ describe("actionStyles", () => {
expect(firstRect.strokeColor).toBe("#e03131");
expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
@@ -1,7 +1,7 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, STROKE_WIDTH, reseed } from "@excalidraw/common";
import { KEYS, reseed } from "@excalidraw/common";
import { setDateTimeForTests } from "@excalidraw/common";
@@ -378,7 +378,7 @@ describe("contextMenu element", () => {
expect(firstRect.strokeColor).toBe("#e03131");
expect(firstRect.backgroundColor).toBe("#a5d8ff");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
@@ -240,12 +240,8 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"seed": Any<Number>,
"simulatePressure": true,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "variable",
},
"strokeStyle": "solid",
"strokeWidth": 1,
"strokeWidth": 2,
"type": "freedraw",
"updated": 1,
"version": 2,
@@ -193,53 +193,6 @@ describe("restoreElements", () => {
expect(restoredFreedraw.pressures).toEqual([0.1, 0.4]);
});
it("should restore freedraw stroke variability", () => {
const freedrawElement = API.createElement({
type: "freedraw",
id: "id-freedraw-mode",
points: [pointFrom(0, 0), pointFrom(10, 10)],
});
const [missing, bogusString, bogusNumber, valid, variable] =
restore.restoreElements(
[
{ ...freedrawElement, id: "missing", strokeOptions: undefined },
{
...freedrawElement,
id: "bogusString",
strokeOptions: { variability: "scribble" },
},
{
...freedrawElement,
id: "bogusNumber",
strokeOptions: { variability: 42 },
},
{
...freedrawElement,
id: "valid",
strokeOptions: { variability: "constant", streamline: 0.8 },
},
{
...freedrawElement,
id: "variable",
strokeOptions: { variability: "variable", streamline: 0.8 },
},
] as any,
null,
) as ExcalidrawFreeDrawElement[];
expect(missing.strokeOptions?.variability).toBe("variable");
expect(bogusString.strokeOptions?.variability).toBe("variable");
expect(bogusNumber.strokeOptions?.variability).toBe("variable");
expect(valid.strokeOptions?.variability).toBe("constant");
expect(variable.strokeOptions?.variability).toBe("variable");
expect(missing.strokeOptions?.streamline).toBe(0.5);
expect(bogusString.strokeOptions?.streamline).toBe(0.5);
expect(bogusNumber.strokeOptions?.streamline).toBe(0.5);
expect(valid.strokeOptions?.streamline).toBe(0.8);
expect(variable.strokeOptions?.streamline).toBe(0.8);
});
it("should restore line and draw elements correctly", () => {
const lineElement = API.createElement({ type: "line", id: "id-line01" });
@@ -526,53 +479,6 @@ describe("restoreElements", () => {
]);
});
it("should mark extremely large linear elements as deleted to avoid freezing", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
// a degenerate line with astronomical coordinates (see #11497)
const hugeLine: any = API.createElement({
type: "line",
x: 419048829414166,
y: 8484,
});
hugeLine.points = [
[0, 0],
[-302985021938436, 0],
[-838097658820234, 30],
];
const hugeArrow: any = API.createElement({ type: "arrow" });
hugeArrow.points = [
[0, 0],
[900000, 0],
];
const normalLine: any = API.createElement({ type: "line" });
normalLine.points = [
[0, 0],
[100, 200],
];
const [restoredLine, restoredArrow, restoredNormal] =
restore.restoreElements([hugeLine, hugeArrow, normalLine], null);
expect(restoredLine.isDeleted).toBe(true);
expect(restoredLine.width).toBe(100);
expect(restoredLine.height).toBe(100);
expect(restoredArrow.isDeleted).toBe(true);
expect(restoredArrow.width).toBe(100);
expect(restoredArrow.height).toBe(100);
expect(restoredNormal.isDeleted).toBe(false);
expect(restoredNormal.width).toBe(100);
expect(restoredNormal.height).toBe(200);
consoleError.mockRestore();
});
it("when the number of points of a line is greater or equal 2", () => {
const lineElement_0 = API.createElement({
type: "line",
@@ -734,21 +640,6 @@ describe("restoreElements", () => {
});
describe("restoreAppState", () => {
it("should restore freedraw mode app state values", () => {
expect(
restore.restoreAppState(
{ currentItemStrokeVariability: "constant" } as any,
null,
).currentItemStrokeVariability,
).toBe("constant");
expect(
restore.restoreAppState(
{ currentItemStrokeVariability: "variable" } as any,
null,
).currentItemStrokeVariability,
).toBe("variable");
});
it("when appState is null it should return the local app state property", () => {
const stubLocalAppState = getDefaultAppState();
stubLocalAppState.cursorButton = "down";
@@ -797,21 +688,6 @@ describe("restoreAppState", () => {
expect(restoredAppState.name).toBe(stubImportedAppState.name);
});
it("should migrate legacy current item stroke width to stroke width key", () => {
const stubImportedAppState = {
...getDefaultAppState(),
currentItemStrokeWidth: 4,
currentItemStrokeWidthKey: undefined,
} as any;
const restoredAppState = restore.restoreAppState(
stubImportedAppState,
null,
);
expect(restoredAppState.currentItemStrokeWidthKey).toBe("bold");
});
it("should restore with current app state when imported data state is undefined", () => {
const stubImportedAppState = {
...getDefaultAppState(),
+20 -137
View File
@@ -1,62 +1,20 @@
import React from "react";
import { vi } from "vitest";
import { Excalidraw } from "../index";
import { AnimationController } from "../renderer/animation";
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../scroll";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
const { h } = window;
/**
* The scroll/zoom animation is driven by `AnimationController`. With render
* throttling enabled (see the `beforeEach` below) it schedules frames via
* `requestAnimationFrame`, advancing the easing based on elapsed wall-clock
* time. We use a very long animation `duration` (see `LONG_ANIMATION_DURATION`)
* so it can never complete while we sample it, and let a few frames pass
* between samples so the easing makes observable (but partial) progress.
*/
const LONG_ANIMATION_DURATION = 1_000_000;
const waitForAnimationProgress = (frames = 4) => {
const waitForNextAnimationFrame = () => {
return act(
() =>
new Promise<void>((resolve) => {
let remaining = frames;
const step = () => {
if (--remaining <= 0) {
resolve();
} else {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}),
);
};
/**
* Polls until the scroll/zoom animation has removed itself from the
* `AnimationController` (i.e. it ran to completion), or until `maxFrames`
* elapses as a safety net so a regression can't hang the suite.
*/
const waitForAnimationToStop = (maxFrames = 200) => {
return act(
() =>
new Promise<void>((resolve) => {
let remaining = maxFrames;
const check = () => {
if (
!AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) ||
--remaining <= 0
) {
resolve();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
}),
);
};
@@ -119,34 +77,6 @@ describe("fitToContent", () => {
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should default to fitToContent when scrolling to an element by id", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
act(() => {
// navigating by element id (a string target) should zoom-to-fit by
// default, even though no `fitToContent` option was passed
h.app.scrollToContent(rectElement.id, { animate: false });
});
// element is 10x taller than the viewport, so fit-to-content should
// drop the zoom to <= 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should scroll the viewport to the selected element", async () => {
await render(<Excalidraw />);
@@ -179,16 +109,11 @@ describe("fitToContent", () => {
describe("fitToContent animated", () => {
beforeEach(() => {
// pace the animation via requestAnimationFrame instead of a tight
// setTimeout(0) loop, which would otherwise starve the test's own timers
window.EXCALIDRAW_THROTTLE_RENDER = true;
vi.spyOn(window, "requestAnimationFrame");
});
afterEach(() => {
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
// stop any in-flight scroll/zoom animation so it doesn't keep ticking on
// the unmounted component and leak into the next test via the singleton
AnimationController.reset();
vi.restoreAllMocks();
});
it("should ease scroll the viewport to the selected element", async () => {
@@ -205,18 +130,17 @@ describe("fitToContent animated", () => {
});
act(() => {
h.app.scrollToContent(rectElement, {
animate: true,
duration: LONG_ANIMATION_DURATION,
});
h.app.scrollToContent(rectElement, { animate: true });
});
// the animation hasn't progressed yet, so we're still at the origin
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the scroll values change at 50ms and 100ms
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
// Since this is an animation, we expect values to change through time.
await waitForAnimationProgress();
await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -224,7 +148,7 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForAnimationProgress();
await waitForNextAnimationFrame();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
@@ -247,14 +171,12 @@ describe("fitToContent animated", () => {
expect(h.state.scrollY).toBe(0);
act(() => {
h.app.scrollToContent(rectElement, {
animate: true,
fitToContent: true,
duration: LONG_ANIMATION_DURATION,
});
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
});
await waitForAnimationProgress();
expect(window.requestAnimationFrame).toHaveBeenCalled();
await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -262,48 +184,9 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForAnimationProgress();
await waitForNextAnimationFrame();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
it("should stop ticking and settle on the target once complete", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
act(() => {
// a short duration so the animation completes within a few frames
h.app.scrollToContent(rectElement, { animate: true, duration: 10 });
});
await waitForAnimationToStop();
// the animation must remove itself from the controller rather than keep
// ticking forever after reaching the target
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
// it should have settled on the target viewport (moved off the origin)
const settledScrollX = h.state.scrollX;
const settledScrollY = h.state.scrollY;
expect(settledScrollX).not.toBe(0);
expect(settledScrollY).not.toBe(0);
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
// further frames must not move the viewport (no perpetual re-rendering)
await waitForAnimationProgress();
expect(h.state.scrollX).toBe(settledScrollX);
expect(h.state.scrollY).toBe(settledScrollY);
});
});
@@ -1,57 +0,0 @@
import type { ExcalidrawFreeDrawElement } from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { UI } from "./helpers/ui";
import { act, fireEvent, render, screen } from "./test-utils";
const { h } = window;
describe("freedraw mode action", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
afterEach(async () => {
// https://github.com/floating-ui/floating-ui/issues/1908#issuecomment-1301553793
await act(async () => {});
});
it("applies currentItemStrokeVariability to newly drawn freedraw elements", () => {
// default app state draws constant-width strokes
expect(h.state.currentItemStrokeVariability).toBe("constant");
UI.createElement("freedraw", { x: 0, y: 0 });
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("constant");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
});
it("toggling the radio updates both the selected element and the default", () => {
const element = UI.createElement("freedraw", { x: 0, y: 0 });
API.setSelectedElements([element.get()]);
fireEvent.click(screen.getByTitle("Variable"));
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("variable");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
expect(h.state.currentItemStrokeVariability).toBe("variable");
fireEvent.click(screen.getByTitle("Constant"));
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.variability,
).toBe("constant");
expect(
(h.elements[0] as ExcalidrawFreeDrawElement).strokeOptions?.streamline,
).toBe(0.5);
expect(h.state.currentItemStrokeVariability).toBe("constant");
});
});
+2 -13
View File
@@ -4,12 +4,7 @@ import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import {
DEFAULT_VERTICAL_ALIGN,
ROUNDNESS,
assertNever,
getStrokeWidthByKey,
} from "@excalidraw/common";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import {
newArrowElement,
@@ -205,9 +200,6 @@ export class API {
? ExcalidrawTextElement["containerId"]
: never;
points?: T extends "arrow" | "line" | "freedraw" ? readonly LocalPoint[] : never;
strokeOptions?: T extends "freedraw"
? ExcalidrawFreeDrawElement["strokeOptions"]
: never;
locked?: boolean;
fileId?: T extends "image" ? string : never;
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@@ -266,9 +258,7 @@ export class API {
backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor,
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
strokeWidth:
rest.strokeWidth ??
getStrokeWidthByKey(type, appState.currentItemStrokeWidthKey),
strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
roundness: (
rest.roundness === undefined
@@ -327,7 +317,6 @@ export class API {
type: type as "freedraw",
simulatePressure: true,
points: rest.points,
strokeOptions: rest.strokeOptions,
...base,
});
break;
+4 -4
View File
@@ -108,8 +108,8 @@ describe("move element", () => {
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([200, 0]);
expect([[rectA.x, rectA.y]]).toCloselyEqualPoints([[0, 0]]);
expect([[rectB.x, rectB.y]]).toCloselyEqualPoints([[200, 0]]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106.00000000000001, 55.6867741935484]],
0,
@@ -130,8 +130,8 @@ describe("move element", () => {
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(3);
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[rectA.x, rectA.y]]).toCloselyEqualPoints([[0, 0]]);
expect([[rectB.x, rectB.y]]).toCloselyEqualPoints([[201, 2]]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints(
[[106, 55.6867741935484]],
0,
@@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -240,7 +240,7 @@ exports[`exportToSvg > with elements that have a link 1`] = `
`;
exports[`exportToSvg > with exportEmbedScene 1`] = `
"<!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1WW2vbMFx1MDAxOH3vrzDaa1llJ2nSvGXrLoWxwTIorOxBtT7bwrLkSnIuXHUwMDBi+e+T5MVyvLLnNYtcdTAwMDOG7350dD6c3UVcdTAwMTQhs61cdTAwMDHNI1x1MDAwNJuUcEZcdTAwMTVZo0vnX4HSTFxuXHUwMDFiSrytZaNSn1lcdTAwMThTz6+uuLRcdTAwMDWF1GY+wlx1MDAxOLdFwKFcdTAwMDJhtE17sHZcdTAwMTTt/NtGXHUwMDE4daWre/X0ZZGVTNDkKa2mn25cdTAwMTdcdTAwMWa++1KftLE543jc2Vs3fTTt7DWjprC+XHUwMDE4485XXHUwMDAwy1x1MDAwYjNwXHUwMDEykXOHNXi0UbKEt5JL5YC8wv5cdKNcdTAwMWZJWuZKNoKGnHhCyGNcdTAwMTZyMsb50mx5y1x1MDAwMkmLRlx1MDAwMVx1MDAxYUy4P0BcdTAwMWP4uzotLcuhyo7MXHUwMDBiXHUwMDAxWlx1MDAxZtXImqTMbFx1MDAwN6dy+Oo76tn9XHUwMDExUClSwZ2jVzSc91x1MDAxYlx1MDAwYvq78VHAclx1MDAwZo5oRHrH11x1MDAwMNRPXHUwMDFix9eT6VxynnWRoIM4wUPvZym8JuJ4NsN4nEyvw1x1MDAxOH1r1WB824xwXHKBaofsXVDKXHUwMDExuqampC1cbmxwJsphnlVf+Uzvg5opI5VcdTAwMTRcdTAwMTR5//7yrMV/XYvx6WpcdTAwMTE4Z7WGl6HFXHUwMDE43O//1mJyulo0sDG9i5PCLNlPXHUwMDE36Z3Bed+TinHH8yS0cKW2hVQsZ4Lw6LjXwf3t72nOWnCWO+JcdTAwMTCHrFx1MDAxN7LcXHUwMDE5Zv9TdGEj61x1MDAxME0tKsJcdTAwMDSoP6/U8lx1MDAwMFx1MDAxZju5v05cdTAwMDJm0lx1MDAxOPlcdTAwMTV0e0TPyHlcdF/IXHUwMDEyjs5L2C1hXHUwMDAwfkpLaN9eKIjU9dJYYm24XUm0YrB+84zoM/+4L6lfYSd6cLe021/sf1x1MDAwMVSoRdMifQ==<!-- payload-end --></metadata><defs><style class="style-fonts">
"<!-- svg-source:excalidraw --><metadata><!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1WW2vbMFx1MDAxOH3vrzDaa2llJ2myvGXrLoWxwTIorPRBtT7bwrLkSnIuXHUwMDBi+e+V5MVy0rL3ZHHAoPNddHR0PpzNRVx1MDAxNCGzrlx1MDAwMU0jXHUwMDA0q5RwRlx1MDAxNVmiS4cvQGkmhVxyJX6tZaNSn1lcdTAwMThTT6+vubRcdTAwMDWF1GY6wFx1MDAxOLdFwKFcdTAwMDJhtE17sOso2vi3jTDqSlx1MDAxN/fq+ccsK5mgyXNajb/dzr789qU+aWVzhvHwXG7jXHUwMDBlWjtcdTAwMDKDcVx1MDAxZloyalxuXHUwMDBix1x1MDAxOPfhXHUwMDAyWF6Y1zhcdTAwMTE5d7xcdTAwMDOijZIlfJRcXCpH6lx1MDAxZPZPoPFE0jJXslx1MDAxMTTkxCNCnrKQkzHO52bNW0VIWjRcbtDBXHUwMDBl9zuiXHUwMDA3eFenpVU8VNkt80KA1ns1siYpM+v2YFx1MDAxZOr41XfUK/1cdTAwMThYKVLBnZNaNJz3XHUwMDFiXHUwMDBi+rfxXsDeXHUwMDAzONFcdTAwMTHpXHUwMDFkX1x1MDAwM1C/2zC+XHUwMDE5jd/jSVx1MDAxN1x0nohcdTAwMTN8iH6XwvsjjidcdTAwMTOMh8n4Jmyjb60zjG+bXHUwMDExriFI7Zh9XG6u2WPX1JS0RUFccs5EeZhnnVi+0XvnbMpIJVx1MDAwNUVcdTAwMWXfXp59eUy+jE/Xl8A5qzVcdTAwMWOfL2Nwv//bl8np+tLAyvQuTlxuM2d/XFykd1x1MDAwNod+Jlx1MDAxNeNO51Fo4UptXHUwMDBiqVjOXHUwMDA04dF+r1x1MDAxZPzr32luNeMsd8IhXHUwMDBlWS9ktTPM/u/owkbWIZpaVoRcdFCvr9TqXHUwMDAwXzvHXyWBM2mM/Fx0uj2iV+Q8kEc4kIPzQHZcdTAwMDNcdTAwMTmIn9JA2rc3XG5cInU9N1ZYXHUwMDFibsdcdTAwMTMtXHUwMDE4LD+8YfrMP+5cdTAwMGLrx9mZXHUwMDFl3C1ttlx1MDAxN9tcdTAwMTcwdk6zIn0=<!-- payload-end --></metadata><defs><style class="style-fonts">
@font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAAAf0AA4AAAAADbQAAAegAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbgjgcNAZgAHwRCAqPYItcCxoAATYCJAMwBCAFgxgHIBujClGUblKX7Edh3Pg8OJbt5MlDFPwWk6FmJ08terdayeDx9O8Fa5eunTJ1ykAUGuAvgOF5bPfuH0nXrvQrNkFoNlERBLIQDd/mj2kP7fdg8avL4M/sm4cbu4L2i1VQVjShz/+90/YE3IJFmOC+f0Z1Xhs3bVxiJZZGa1IgY5sHCJ2EM2iBndkLWsZvFxCAhRBGCknKEOQenHCpmE2fVQTl1atzWyjvzs3aQPnWd20PJQZeG3mQp1lnLimFBAhOKFIQSrGa+vOB69gLCRkjLr4NIjd5rXiz8hhu+LIe9DZCgkJjmZEy34MkhXNBJ8GhNMMnJUgmSYA0AowAwT2zRor05iZAeN/80c4UkYlgSTOaKgOQCUOSE5F+z6+DXqndDTk4CEHP2v3/aKw7lXQXgAUAQP5+kgIpTYMHT9MEjIyWxJ5V5jAzgnrNtNBKWx101tXee86rA5O1f1fiIzEtJsS4GBMjYlikIhEvwwXDjYgiApUFdjgCFAA1m2GorwgdA5a6fzxxX0ei+Eqw6MspHfbmte7eLSh2FSEREgnySedVN+VfsbKAVQygCrGALXgDQoSA0kZqfB2OiyyaZolc/i3tnXswySlmifL9eo1ReyWQcYadAK/DD9c80uaI4pamykJM2hp/MF2uj+teZm9LiRlrltgBlcVYlWVWf+WPTpVJj40Kz9Gvi5p21Zcov8bKEL+5Uirz+j2Hjzr+99zosad2XtbpfsbKgyIQ3RJHxrTH1ysQQAjTUaii13B+OTtxrFoLubjpMYtit2uul6DdSPvKaQl/9FkppthYCXEvjoEdStVVjti2fUQf34q9YjychmIsxCAjKkj0GW4mIhlX1snPOEVRVq4UMRQwkzJNhK143IsBOlghz9meJAGf4apbAmYRMj2hsj5X1OGDPuIKXoOyN8yIMYbeusXTa9f4a+1UvYG9FgQxzoQBUoSPD5/ezAuQIP8s8IyVrUGrwSxC95WPAdlx3EkkQq5yNjcaKAAZ4LEHheIMvgHV3fe6X41/6rzCiMBwSxrnvl9RcR0gK5fy4jDVigBySy99iwlppT9aTlWIMSMKBx9WkbupSbZXNC/CKUC6z53jA0mS7340zruutV7xYguW6p/01tYHOQZQ7ptAagFiLAzFWqyWUwN8/eNGSyn2elPguvF9At0XUNlNv4t202nq+EnkKvdW6aLVlJUxhpXYUqMsn5svp9eoTpvKKf6BY8QwAkaJWYSggEuximEYiCVE0TjN5ojkVoznAP0GytsVT1SesrOn/s8LsMpVpXtSc6To4sT6XIl1q0jH96rd5/g5vmi35+y6eeYPNqjBGHtX0nKFGywfPr19aO9Gh/Q1sWZGuqckOoOJmlK2pSPTSgm/46h389ap+VmOnxsPdspP82Xyy2l7u6i2908pVjRkDbEddOKOprOPJ/94eJ59fi6jDtB6VbzuwXb5puJcqcKnUeM72fFnIlssAv/Z6uMq2s1Jd8qpd7DZo538w5PG7cHm2TU2TD41PjYiaUSikYspa/kjJ84AdgqXsT226/L+fW6j99QmJZlWN99tfcdbUPO17tLu8+gVw73h3OfmurdS9BCa5twNfokJhx7/agK3fHC9rXVewbT0kZG9vXqRBOtS6xGOZ5dC3pwleZIyidL1le+xC3btOlwJkXqGESosoDUG2TqcuE/6OcolnOykkpKdD+5l7+JODWBpAycPke+STDMRWidsWCLvfQJbKM7JpnkLG1WV73qjg6eZ1EMVF1NgG5rTaZF2r7tJNs2VzabNrTQfhSOtgxRxRLP5mldJB6/b6R5LWk9ss9nNUGyTTx8iytdCS9xlAlvtN/1hTrYObLVe5T8zLWNk2oko/bq0dv1cvjXrGC5k+pfYm+L1MtXCFsSzKDTY/dhu66JJWqlfkIxw7T5ufjDBa4pCy7ObPC0cojzlZMlCh2VjKwfX8qy5cpyRt1hp6DeKs/UZjc6zfGxC0rJHl1IUtIm2p7Tg+6VKn8vbfWR9fHljiebwTXPXTQ13HmoSbMssDsa32NnSfmmoH92xuU/E7U2qS8vWYFD9D73VJodqvWU8l7tB+b9ZdPiXvC2qoboerfoamG78+oOX7uWn1/WJy87rJPSHcVmUO7sN0ypzcG5zdfzU8k/NSh5OCbHvUSJlOk2xGrkmubA+nV7YtUCdoJk2vZ/dNSvrAkFnE0toKlC7rHllSrrOzt4Yb91LaLga9TtY+etg/uCpE8rVr604yv7YaE2x450wAACIfUvVNvFrrWXyNylLvwSAR70CtQDwePGbPuLQ/32Y10wCAClKi8AXKzralCj+/e0Dwri9RW2ArkwEqBW+cYJzghDkKyuPEB0FElKG8DIXEQkASS4s/2PSN2DGs1Bi6eh9EhVoZDAH0NblyEbw9tsoChtttEDDbQxvDTZWKm+YcYcDOj01qtdWK8110F5XIQq+nMJuwnpSopmb7KJVWbUgQqhwkyQxlOd76aglFYEKoxAJ8OeFkYPjBPTuQejsbXStQgY5klp1cr1LoQHSUa8StKqVbKnrXAqNAgg8wm1EB6RBL7ejLWnSmSI9hGJQZdAW2trTXRJoBsJm6M5VNQlFM3yJ/7EAAAA=); }
@font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAAAHcAA0AAAAAA9gAAAGMAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cNAZgAAQRCAoAKgsEAAE2AiQDBAQgBYMYByAbHQPIrgp4MjSeIQLqpl4jnPFQwveqm205gmrZevZ2/8kKGaJ6ictBuCwMMnsKo8hC4RwaLBrJ7dXaFrFk0Re1e/Dk3t9wCUVrYAiZkgiZ5rGDVFdY04wBF4APOCzXHr/ljrNIDqLAY/slkQf0SyjxLNBaAq8Z1AJqYW5j0GjbckRLQhoJCgwYoyfY3q33QS5DrSAwYB4hFruNve4WoN2On4P29GEFWsC+nVM/UvSlVwIcsWycI/5gbqfUhE/9Q2P7UppTOAVeIR4TYIocELVKIEACZJgXCJ5GXl0o+esbFP3g+763DcDPx6/60MrnBaBPAsGPUDRC/8+FGIqGUSBA+NIJOwTWe13HRCIrgEvfRhj1RjLiE42eG7I5DIpVNuqkNNhxwaItTI2srRz4dfHGjhZoO0O8nb2pilFYQKhenFaycLUxsYcoAQRyoRBEnPvcgfysPq+npClt8UxLyvHWjaudqbGJA+TCckNECBGBGFforuTs0M4CUMbCAvp+P4in4o864XECREBtFQAAAAA=); }
@font-face { font-family: Nunito; src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgp4Mt6IIcJZFNCfWmZY6KzqazTt6AiVHCFawzJ7V0CQBKoSQQEpFKrCVxiSFbqqqsaTevKs/q7s6uBYmujMyA9wxb6a7XnEOcNnDLgG4nW8PoPzHzYpS2uqp51pL3eB/xZoxIllCQc80B9o4j/4xMbxQB+j+SC3hsm6JmI8RMaHj+aJApW6ZbkXlg4vXSE5FECg0og6LzxP9pOarug4tF1RLpbHeZqLX0pIt2mfy3pNG6eyGaRIjrnrr/gv2c//yGdjpJ/7DuJLin5eIZRLaObBMM/NpYpuXJ8z3SE088mEFANcCARESfwChCioAwESsVxBgeyxp6+vZ3Xzv8uz7Ae8tRk+p6RUTPOR7BmlEgh+STsAimNuKibilyluhBNe5/wCACSBKrcykVfgyb+RYcK/TGq9yMyCt3bOskSnR1FqTLMSVBsMGLQVltDKKw2IYA+wcGxHJCINY9mDOCaN4IZEo1ChY4xNg8DaB0RxDqnbMNjXJJRzF9iInqahtm67M8dOHFvXaYbn+wrGxKG3YZI+2V4CW58ovdfV1tFHXFJJiPH7T1FAJxEgYo5BKkA5iDIVQluOqZYWhQapGF6TAFhaFAAoTBIZsCFHi/0oV3gpVKwbAA==); }
@@ -1,528 +0,0 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
import { Excalidraw } from "../index";
import {
actionResetZoom,
actionZoomToFit,
actionZoomToFitSelection,
} from "../actions/actionCanvas";
import { getNormalizedZoom } from "../scene";
import {
animateToConstraints,
constrainScrollState,
getMinZoomForConstraints,
isViewportOverscrolled,
SCROLL_TO_CONTENT_ANIMATION_KEY,
} from "../scroll";
import { AnimationController } from "../renderer/animation";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
waitFor,
} from "./test-utils";
import type { AppState, ScrollConstraints } from "../types";
const { h } = window;
// `mockBoundingClientRect()` makes the viewport 200 x 100
const VIEWPORT = { width: 200, height: 100 };
const makeState = (
overrides: Partial<AppState> & {
scrollConstraints: ScrollConstraints | null;
},
): Pick<
AppState,
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
> => ({
scrollX: 0,
scrollY: 0,
zoom: { value: getNormalizedZoom(1) },
width: VIEWPORT.width,
height: VIEWPORT.height,
...overrides,
});
describe("constrainScrollState (pure)", () => {
it("returns scroll/zoom unchanged when there are no constraints", () => {
const state = makeState({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
scrollConstraints: null,
});
expect(constrainScrollState(state)).toEqual({
scrollX: 123,
scrollY: -45,
zoom: { value: getNormalizedZoom(0.5) },
});
});
describe("box larger than the viewport", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("clamps scroll so the viewport can't leave the box", () => {
// pan past the box's top-left corner → clamp to the corner (scroll 0)
const corner = constrainScrollState(
makeState({ scrollX: 100, scrollY: 100, scrollConstraints: box }),
);
expect(corner.scrollX).toBeCloseTo(0);
expect(corner.scrollY).toBeCloseTo(0);
// pan past the box's far edges → clamp to width/height - boxSize
const farEdge = constrainScrollState(
makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: box }),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width); // -800
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height); // -900
});
it("enforces a minimum zoom so the box keeps covering the viewport", () => {
const minZoom = getMinZoomForConstraints(box, VIEWPORT);
expect(minZoom).toBeCloseTo(0.2); // max(200/1000, 100/1000)
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: box,
}),
);
expect(zoom.value).toBeCloseTo(minZoom);
});
it("leaves an in-bounds viewport untouched", () => {
const inBounds = makeState({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
scrollConstraints: box,
});
expect(constrainScrollState(inBounds)).toMatchObject({
scrollX: -100,
scrollY: -100,
zoom: { value: getNormalizedZoom(1) },
});
});
});
describe("box smaller than the viewport (best-effort fit)", () => {
it("forces a zoom-in and removes pan room on the binding axis", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 50, height: 50 };
// minZoom = max(200/50, 100/50) = 4
expect(getMinZoomForConstraints(box, VIEWPORT)).toBeCloseTo(4);
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollY: 999,
zoom: { value: getNormalizedZoom(1) },
scrollConstraints: box,
}),
);
expect(result.zoom.value).toBeCloseTo(4);
// width binds: visible width (200/4=50) == box width → no x pan room
expect(result.scrollX).toBeCloseTo(0);
// height has slack (100/4=25 < 50) → clamped to the top edge here
expect(result.scrollY).toBeCloseTo(0);
});
it("centers the box when it can't cover the viewport even at MAX_ZOOM", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1, height: 1 };
expect(getMinZoomForConstraints(box, VIEWPORT)).toBe(MAX_ZOOM);
const result = constrainScrollState(
makeState({ scrollX: 1000, scrollY: 1000, scrollConstraints: box }),
);
expect(result.zoom.value).toBe(MAX_ZOOM);
// centered: (min + max) / 2, with min = w/zoom - (x+w), max = -x
const centeredX = (VIEWPORT.width / MAX_ZOOM - 1 + 0) / 2;
expect(result.scrollX).toBeCloseTo(centeredX);
});
});
});
describe("scrollToContent scrollConstraints (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
});
it("snaps the current viewport inside the box when constraints are set", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
// start well outside the future box
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
});
});
// viewport must now be within [-800, 0] x [-900, 0]
expect(h.state.scrollX).toBeLessThanOrEqual(0);
expect(h.state.scrollX).toBeGreaterThanOrEqual(-800);
expect(h.state.scrollY).toBeLessThanOrEqual(0);
expect(h.state.scrollY).toBeGreaterThanOrEqual(-900);
expect(h.state.zoom.value).toBeGreaterThanOrEqual(0.2);
});
it("prevents keyboard panning out of the box and restores freedom on clear", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 300, height: 200 },
});
});
// hammer page-down (pans down) far past the box; scroll must stay clamped
for (let i = 0; i < 20; i++) {
Keyboard.keyPress(KEYS.PAGE_DOWN);
}
expect(h.state.scrollY).toBeGreaterThanOrEqual(h.state.height - 200);
// clearing constraints lets it scroll freely again
React.act(() => {
h.app.scrollToContent([], { scrollConstraints: null });
});
const before = h.state.scrollY;
Keyboard.keyPress(KEYS.PAGE_DOWN);
expect(h.state.scrollY).toBeLessThan(before);
});
it("clamps the scroll target into the box when scrolling to content", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
// an element well outside the future constraint box
const rect = API.createElement({
type: "rectangle",
x: 2000,
y: 2000,
width: 100,
height: 100,
});
API.setElements([rect]);
React.act(() => {
h.app.scrollToContent(rect, {
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
animate: false,
});
});
// recentering on the element would scroll way past the box; it must be
// clamped to the far edges instead (scrollX -800, scrollY -900 at zoom 1)
expect(h.state.scrollX).toBeCloseTo(-800);
expect(h.state.scrollY).toBeCloseTo(-900);
});
it("disables reset-zoom and zoom-to-fit actions while constrained", async () => {
await render(<Excalidraw />);
await waitFor(() => expect(h.state.width).toBe(200));
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true);
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true);
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: { x: 0, y: 0, width: 500, height: 500 },
});
});
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false);
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(false);
expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe(
false,
);
});
});
describe("explicit minZoom / maxZoom (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("lets minZoom override the box-enforced fit zoom (zoom out past the box)", () => {
// without minZoom the box forces a fit zoom of 0.2; minZoom 0.1 takes over
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.1 },
}),
);
expect(zoom.value).toBeCloseTo(0.1);
});
it("still clamps zoom out at minZoom", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: { ...box, minZoom: 0.5 },
}),
);
expect(zoom.value).toBeCloseTo(0.5);
});
it("lets maxZoom cap zoom-in below the global MAX_ZOOM", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
scrollConstraints: { ...box, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(3);
});
it("leaves an in-range zoom untouched", () => {
const { zoom } = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(1.5) },
scrollConstraints: { ...box, minZoom: 0.5, maxZoom: 3 },
}),
);
expect(zoom.value).toBeCloseTo(1.5);
});
});
describe("padding (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("extends the scrollable area past each edge by `[top, right, bottom, left]`", () => {
const padding: [number, number, number, number] = [10, 20, 30, 40];
// pan past the top-left corner → clamp to the padded corner
const topLeft = constrainScrollState(
makeState({
scrollX: 999,
scrollY: 999,
scrollConstraints: { ...box, padding },
}),
);
expect(topLeft.scrollX).toBeCloseTo(40); // left padding
expect(topLeft.scrollY).toBeCloseTo(10); // top padding
// pan past the far edges → clamp to width/height - boxSize - far padding
const farEdge = constrainScrollState(
makeState({
scrollX: -5000,
scrollY: -5000,
scrollConstraints: { ...box, padding },
}),
);
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width - 20); // -820 (right)
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height - 30); // -930 (bottom)
});
it("keeps the padding a fixed screen distance regardless of zoom", () => {
// 40 screen px of top padding at zoom 2 → 20 scene px
const result = constrainScrollState(
makeState({
scrollY: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: { ...box, padding: [40, 0, 0, 0] },
}),
);
expect(result.scrollY).toBeCloseTo(20);
});
it("defaults to no extra room", () => {
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: box }),
);
expect(result.scrollX).toBeCloseTo(0);
});
it("stacks with the rubberband tolerance", () => {
const tolerance = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
scrollConstraints: { ...box, tolerance, padding: [0, 0, 0, 40] },
}),
tolerance,
);
// left padding (40) + tolerance overscroll (30) at zoom 1
expect(result.scrollX).toBeCloseTo(70);
});
});
describe("rubberband tolerance (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("allows scroll overscroll up to `tolerance` screen pixels", () => {
const tolerance = 30; // screen px
// hard max for scrollX is -box.x = 0; soft adds tolerance / zoom scene px
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }),
tolerance,
);
expect(result.scrollX).toBeCloseTo(tolerance / 1); // 30 (zoom 1)
});
it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
const tolerance = 30; // screen px
const result = constrainScrollState(
makeState({
scrollX: 999,
zoom: { value: getNormalizedZoom(2) },
scrollConstraints: { ...box, tolerance },
}),
tolerance,
);
// 30 screen px at zoom 2 -> 15 scene px of overscroll
expect(result.scrollX).toBeCloseTo(tolerance / 2); // 15
});
it("relaxes the minimum zoom by the `tolerance` screen pixels", () => {
const tolerance = 25; // screen px
// relaxed min zoom lets the box shrink within the viewport by `tolerance`
// px on each side: max((200-50)/1000, (100-50)/1000) = 0.15
const expected = getMinZoomForConstraints(box, {
width: VIEWPORT.width - 2 * tolerance,
height: VIEWPORT.height - 2 * tolerance,
});
const result = constrainScrollState(
makeState({
zoom: { value: getNormalizedZoom(0.01) },
scrollConstraints: { ...box, tolerance },
}),
tolerance,
);
expect(result.zoom.value).toBeCloseTo(expected); // 0.15
});
it("hard-clamps when tolerance is 0 (default)", () => {
const result = constrainScrollState(
makeState({ scrollX: 999, scrollConstraints: box }),
);
expect(result.scrollX).toBeCloseTo(0);
});
});
describe("isViewportOverscrolled (pure)", () => {
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("is false when there are no constraints", () => {
expect(
isViewportOverscrolled(
makeState({ scrollX: 9999, scrollConstraints: null }),
),
).toBe(false);
});
it("is false when the viewport is within the hard bounds", () => {
expect(
isViewportOverscrolled(
makeState({ scrollX: -100, scrollY: -100, scrollConstraints: box }),
),
).toBe(false);
});
it("is true when panned past an edge (rubberband overscroll)", () => {
// scrollX 30 is past the hard max of 0
expect(
isViewportOverscrolled(
makeState({ scrollX: 30, scrollConstraints: box }),
),
).toBe(true);
});
it("is true when zoomed out below the fit zoom", () => {
// fit zoom for this box is 0.2; 0.1 is below it
expect(
isViewportOverscrolled(
makeState({
zoom: { value: getNormalizedZoom(0.1) },
scrollConstraints: box,
}),
),
).toBe(true);
});
});
describe("animateToConstraints (rubberband snap-back)", () => {
afterEach(() => {
AnimationController.reset();
});
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
it("starts an animation toward the box when overscrolled", () => {
const onFrame = vi.fn();
// scrollX 200 is outside the hard range [-800, 0]
animateToConstraints(
makeState({ scrollX: 200, scrollConstraints: box }),
onFrame,
);
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
true,
);
expect(onFrame).toHaveBeenCalled();
});
it("is a no-op when already within the box", () => {
const onFrame = vi.fn();
animateToConstraints(
makeState({ scrollX: -100, scrollConstraints: box }),
onFrame,
);
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
expect(onFrame).not.toHaveBeenCalled();
});
});
describe("rubberband tolerance (integration)", () => {
beforeEach(() => {
mockBoundingClientRect();
});
afterEach(() => {
restoreOriginalGetBoundingClientRect();
AnimationController.reset();
});
it("lets the user pan past the box edge (bounded by tolerance)", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
await waitFor(() => expect(h.state.width).toBe(200));
React.act(() => {
h.app.scrollToContent([], {
scrollConstraints: {
x: 0,
y: 0,
width: 1000,
height: 1000,
tolerance: 25,
},
});
});
// snapped to the top edge (hard max scrollY = 0)
expect(h.state.scrollY).toBe(0);
// page-up pans up, pushing scrollY past the hard edge into the overscroll
Keyboard.keyPress(KEYS.PAGE_UP);
// overscrolled past 0, but bounded by tolerance (25 screen px / zoom 1)
expect(h.state.scrollY).toBeGreaterThan(0);
expect(h.state.scrollY).toBeLessThanOrEqual(
25 / h.state.zoom.value + 0.001,
);
});
});
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["**/*"],
+1 -37
View File
@@ -4,7 +4,6 @@ import type {
throttleRAF,
MIME_TYPES,
EditorInterface,
StrokeWidthKey,
} from "@excalidraw/common";
import type { LinearElementEditor } from "@excalidraw/element";
@@ -34,7 +33,6 @@ import type {
ExcalidrawNonSelectionElement,
BindMode,
ExcalidrawTextElement,
StrokeVariability,
} from "@excalidraw/element/types";
import type {
@@ -273,36 +271,6 @@ export type ObservedElementsAppState = {
export type BoxSelectionMode = "contain" | "overlap";
/** A box, in scene coordinates, that pan & zoom are constrained to. */
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
/**
* Pixel amount to allow to overscroll.
*/
tolerance?: number;
/**
* Lower zoom bound. When set, it takes precedence over the zoom the box would
* otherwise enforce (the fit zoom that keeps the box covering the viewport),
* letting the viewport zoom out past the box.
*/
minZoom?: number;
/**
* Upper zoom bound. When set, it takes precedence over the global `MAX_ZOOM`,
* capping how far the viewport can zoom in.
*/
maxZoom?: number;
/**
* Extra scrollable margin around the box, as `[top, right, bottom, left]`
* (cardinal order), letting the viewport scroll past each box edge to reveal
* that much empty space beyond it. Values are screen pixels, so the margin
* stays the same on-screen regardless of zoom. Defaults to `[0, 0, 0, 0]`.
*/
padding?: [top: number, right: number, bottom: number, left: number];
};
export interface AppState {
contextMenu: {
items: ContextMenuItems;
@@ -394,10 +362,9 @@ export interface AppState {
currentItemStrokeColor: string;
currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidthKey: StrokeWidthKey;
currentItemStrokeWidth: number;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number;
currentItemStrokeVariability: StrokeVariability;
currentItemOpacity: number;
currentItemFontFamily: FontFamilyValues;
currentItemFontSize: number;
@@ -410,9 +377,6 @@ export interface AppState {
viewBackgroundColor: string;
scrollX: number;
scrollY: number;
/** when set, pan & zoom are constrained so the viewport stays within this
* scene-coordinate box (see `scrollToContent`'s `scrollConstraints` option) */
scrollConstraints: ScrollConstraints | null;
cursorButton: "up" | "down";
scrolledOutside: boolean;
name: string | null;
+1 -1
View File
@@ -635,7 +635,7 @@ export const textWysiwyg = ({
event.preventDefault();
app.actionManager.executeAction(actionZoomOut);
updateWysiwygStyle();
} else if (!event.shiftKey && actionResetZoom.keyTest(event, app.state)) {
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionResetZoom);
updateWysiwygStyle();
+1 -2
View File
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 Excalidraw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-23
View File
@@ -1,23 +0,0 @@
# Laser Pointer
## Usage
import { LaserPointer } from '@excalidraw/laser-pointer'
const stroke = new LaserPointer(options)
stroke.addPoint([100, 200, 1])
stroke.close()
const outline = stroke.getStrokeOutline()
## Options
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `number` | `2` | Radius of the stroke. |
| `streamline` | `number` | `0.42` | Interpolate input points to reduce jitter. |
| `simplify` | `number` | `0.1` | Reduce stroke size by sacrificing precision. |
| `simplifyPhase` | `"input" \| "output" \| "tail" ` | `"output"` | Decides when the simplification algorithm should be applied. |
| `sizeMapping` | `(details: SizeMappingDetails) => number` | `() => 1` | Maps each point to a value between `0.0` and `1.0`. |
| `keepHead` | `boolean` | `false` | Whether size mapping should influence the head of the stroke. |
View File
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@excalidraw/laser-pointer",
"version": "1.3.1",
"description": "Generate outline for laser pointer tool",
"type": "module",
"types": "./dist/types/laser-pointer/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/laser-pointer/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
"dist/*"
],
"keywords": [
"excalidraw",
"laserpointer"
],
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./state";
export type { Point } from "./math";
-105
View File
@@ -1,105 +0,0 @@
export type Point = [x: number, y: number, r: number];
export function add([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax + bx, ay + by, ar + br];
}
export function sub([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax - bx, ay - by, ar - br];
}
export function smul([x, y, r]: Point, s: number): Point {
return [x * s, y * s, r * s];
}
export function norm([x, y, r]: Point): Point {
return [x / Math.sqrt(x ** 2 + y ** 2), y / Math.sqrt(x ** 2 + y ** 2), r];
}
export function rot([x, y, r]: Point, rad: number): Point {
return [
Math.cos(rad) * x - Math.sin(rad) * y,
Math.sin(rad) * x + Math.cos(rad) * y,
r,
];
}
export function plerp(a: Point, b: Point, t: number): Point {
return add(a, smul(sub(b, a), t));
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function angle(p: Point, p1: Point, p2: Point) {
return (
Math.atan2(p2[1] - p[1], p2[0] - p[0]) -
Math.atan2(p1[1] - p[1], p1[0] - p[0])
);
}
export function normAngle(a: number) {
return Math.atan2(Math.sin(a), Math.cos(a));
}
export function mag([x, y]: Point) {
return Math.sqrt(x ** 2 + y ** 2);
}
export function dist([ax, ay]: Point, [bx, by]: Point): number {
return Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
}
export function getCircleAndPerpendicularLineIntersectionsAtPoint(
point: Point,
direction: Point,
radius: number,
): [Point, Point] {
return [
add(point, smul(norm(rot(direction, Math.PI / 2)), radius)),
add(point, smul(norm(rot(direction, -Math.PI / 2)), radius)),
];
}
export function runLength(ps: Point[]): number {
if (ps.length < 2) {
return 0;
}
let len = 0;
for (let i = 1; i <= ps.length - 1; i++) {
len += dist(ps[i - 1], ps[i]);
}
len += dist(ps[ps.length - 2], ps[ps.length - 1]);
return len;
}
export const clamp = (v: number, min: number, max: number) =>
Math.max(min, Math.min(max, v));
export function distancePointToSegment(p3: Point, p1: Point, p2: Point) {
const sMag = dist(p1, p2);
if (sMag === 0) {
return dist(p3, p1);
}
const u = clamp(
((p3[0] - p1[0]) * (p2[0] - p1[0]) + (p3[1] - p1[1]) * (p2[1] - p1[1])) /
sMag ** 2,
0,
1,
);
const pi: Point = [
p1[0] + u * (p2[0] - p1[0]),
p1[1] + u * (p2[1] - p1[1]),
p3[2],
];
return dist(pi, p3);
}
-42
View File
@@ -1,42 +0,0 @@
import { type Point, distancePointToSegment } from "./math";
export function douglasPeucker(points: Point[], epsilon: number): Point[] {
if (epsilon === 0) {
return points;
}
if (points.length <= 2) {
return points;
}
const first = points[0];
const last = points[points.length - 1];
const [maxDistance, maxIndex] = points.reduce(
([maxDistance, maxIndex], point, index) => {
const distance = distancePointToSegment(point, first, last);
return distance > maxDistance
? [distance, index]
: [maxDistance, maxIndex];
},
[0, -1],
);
if (maxDistance >= epsilon) {
const maxIndexPoint = points[maxIndex];
return [
...douglasPeucker(
[first, ...points.slice(1, maxIndex), maxIndexPoint],
epsilon,
).slice(0, -1),
maxIndexPoint,
...douglasPeucker(
[maxIndexPoint, ...points.slice(maxIndex, -1), last],
epsilon,
).slice(1),
];
}
return [first, last];
}
-377
View File
@@ -1,377 +0,0 @@
import * as m from "./math";
import { douglasPeucker } from "./simplify";
import type { Point } from "./math";
export type SizeMappingDetails = {
pressure: number;
runningLength: number;
currentIndex: number;
totalLength: number;
};
export type LaserPointerOptions = {
size: number;
streamline: number;
simplify: number;
simplifyPhase: "tail" | "output" | "input";
keepHead: boolean;
sizeMapping: (details: SizeMappingDetails) => number;
};
export class LaserPointer {
static defaults: LaserPointerOptions = {
size: 2,
streamline: 0.45,
simplify: 0.1,
simplifyPhase: "output",
keepHead: false,
sizeMapping: () => 1,
};
static constants = {
cornerDetectionMaxAngle: 75,
cornerDetectionVariance: (s: number) => (s > 35 ? 0.5 : 1),
maxTailLength: 50,
};
options: LaserPointerOptions;
constructor(options: Partial<LaserPointerOptions>) {
this.options = Object.assign({}, LaserPointer.defaults, options);
}
originalPoints: Point[] = [];
private stablePoints: Point[] = [];
private tailPoints: Point[] = [];
private isFresh = true;
private get lastPoint(): Point {
return (
this.tailPoints[this.tailPoints.length - 1] ??
this.stablePoints[this.stablePoints.length - 1]
);
}
addPoint(point: Point) {
const lastPoint = this.originalPoints[this.originalPoints.length - 1];
if (lastPoint && lastPoint[0] === point[0] && lastPoint[1] === point[1]) {
return;
}
this.originalPoints.push(point);
if (this.isFresh) {
this.isFresh = false;
this.stablePoints.push(point);
return;
}
if (this.options.streamline > 0) {
point = m.plerp(this.lastPoint, point, 1 - this.options.streamline);
}
this.tailPoints.push(point);
if (m.runLength(this.tailPoints) > LaserPointer.constants.maxTailLength) {
this.stabilizeTail();
}
}
close() {
this.stabilizeTail();
}
stabilizeTail() {
if (this.options.simplify > 0 && this.options.simplifyPhase === "tail") {
throw new Error("Not implemented yet");
} else {
this.stablePoints.push(...this.tailPoints);
this.tailPoints = [];
}
}
private getSize(
sizeOverride: number | undefined,
pressure: number,
index: number,
totalLength: number,
runningLength: number,
) {
return (
(sizeOverride ?? this.options.size) *
this.options.sizeMapping({
pressure,
runningLength,
currentIndex: index,
totalLength,
})
);
}
getStrokeOutline(sizeOverride?: number | undefined): Point[] {
if (this.isFresh) {
return [];
}
let points = [...this.stablePoints, ...this.tailPoints];
if (this.options.simplify > 0 && this.options.simplifyPhase === "input") {
points = douglasPeucker(points, this.options.simplify);
}
const len = points.length;
if (len === 0) {
return [];
}
if (len === 1) {
const c = points[0];
const size = this.getSize(sizeOverride, c[2], 0, len, 0);
if (size < 0.5) {
return [];
}
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), size)));
}
ps.push(
m.add(
c,
m.smul(
[1, 0, 0] as Point,
this.getSize(sizeOverride, c[2], 0, len, 0),
),
),
);
return ps;
}
if (len === 2) {
const c = points[0];
const n = points[1];
const cSize = this.getSize(sizeOverride, c[2], 0, len, 0);
const nSize = this.getSize(sizeOverride, n[2], 0, len, 0);
if (cSize < 0.5 || nSize < 0.5) {
return [];
}
const ps: Point[] = [];
const pAngle = m.angle(c, [c[0], c[1] - 100, c[2]] as Point, n);
for (
let theta = pAngle;
theta <= Math.PI + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), cSize)));
}
for (
let theta = Math.PI + pAngle;
theta <= Math.PI * 2 + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(n, m.smul(m.rot([1, 0, 0] as Point, theta), nSize)));
}
ps.push(ps[0]);
return ps;
}
const forwardPoints: Point[] = [];
const backwardPoints: Point[] = [];
let speed = 0;
let prevSpeed = 0;
let visibleStartIndex = 0;
let runningLength = 0;
for (let i = 1; i < len - 1; i++) {
const p = points[i - 1];
const c = points[i];
const n = points[i + 1];
const pressure = c[2];
const d = m.dist(p, c);
runningLength += d;
speed = prevSpeed + (d - prevSpeed) * 0.2;
const cSize = this.getSize(sizeOverride, pressure, i, len, runningLength);
if (cSize === 0) {
visibleStartIndex = i + 1;
continue;
}
const dirPC = m.norm(m.sub(p, c));
const dirNC = m.norm(m.sub(n, c));
const p1dirPC = m.rot(dirPC, Math.PI / 2);
const p2dirPC = m.rot(dirPC, -Math.PI / 2);
const p1dirNC = m.rot(dirNC, Math.PI / 2);
const p2dirNC = m.rot(dirNC, -Math.PI / 2);
const p1PC = m.add(c, m.smul(p1dirPC, cSize));
const p2PC = m.add(c, m.smul(p2dirPC, cSize));
const p1NC = m.add(c, m.smul(p1dirNC, cSize));
const p2NC = m.add(c, m.smul(p2dirNC, cSize));
const ftdir = m.add(p1dirPC, p2dirNC);
const btdir = m.add(p2dirPC, p1dirNC);
const paPC = m.add(
c,
m.smul(m.mag(ftdir) === 0 ? dirPC : m.norm(ftdir), cSize),
);
const paNC = m.add(
c,
m.smul(m.mag(btdir) === 0 ? dirNC : m.norm(btdir), cSize),
);
const cAngle = m.normAngle(m.angle(c, p, n));
const D_ANGLE =
(LaserPointer.constants.cornerDetectionMaxAngle / 180) *
Math.PI *
LaserPointer.constants.cornerDetectionVariance(speed);
if (Math.abs(cAngle) < D_ANGLE) {
const tAngle = Math.abs(m.normAngle(Math.PI - cAngle)); // turn angle
if (tAngle === 0) {
continue;
}
if (cAngle < 0) {
backwardPoints.push(p2PC, paNC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
forwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
backwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
backwardPoints.push(paNC, p1NC);
} else {
forwardPoints.push(p1PC, paPC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
backwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
forwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
forwardPoints.push(paPC, p2NC);
}
} else {
forwardPoints.push(paPC);
backwardPoints.push(paNC);
}
prevSpeed = speed;
}
if (visibleStartIndex >= len - 2) {
if (this.options.keepHead) {
const c = points[len - 1];
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(
m.add(
c,
m.smul(m.rot([1, 0, 0] as Point, theta), this.options.size),
),
);
}
ps.push(m.add(c, m.smul([1, 0, 0] as Point, this.options.size)));
return ps;
}
return [];
}
const first = points[visibleStartIndex];
const second = points[visibleStartIndex + 1];
const penultimate = points[len - 2];
const ultimate = points[len - 1];
const dirFS = m.norm(m.sub(second, first));
const dirPU = m.norm(m.sub(penultimate, ultimate));
const ppdirFS = m.rot(dirFS, -Math.PI / 2);
const ppdirPU = m.rot(dirPU, Math.PI / 2);
const startCapSize = this.getSize(sizeOverride, first[2], 0, len, 0);
const startCap: Point[] = [];
const endCapSize = this.options.keepHead
? this.options.size
: this.getSize(sizeOverride, penultimate[2], len - 2, len, runningLength);
const endCap: Point[] = [];
// Lowered threshold to 0.1,
// ensuring virtually all strokes get proper rounded caps for visual consistency.
if (startCapSize > 0.1) {
for (let theta = 0; theta <= Math.PI; theta += Math.PI / 16) {
startCap.unshift(
m.add(first, m.rot(m.smul(ppdirFS, startCapSize), -theta)),
);
}
startCap.unshift(m.add(first, m.smul(ppdirFS, -startCapSize)));
} else {
startCap.push(first);
}
for (let theta = 0; theta <= Math.PI * 3; theta += Math.PI / 16) {
endCap.push(m.add(ultimate, m.rot(m.smul(ppdirPU, -endCapSize), -theta)));
}
const strokeOutline = [
...startCap,
...forwardPoints,
...endCap.reverse(),
...backwardPoints.reverse(),
];
if (startCap.length > 0) {
strokeOutline.push(startCap[0]);
}
if (this.options.simplify > 0 && this.options.simplifyPhase === "output") {
return douglasPeucker(strokeOutline, this.options.simplify);
}
return strokeOutline;
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+1 -3
View File
@@ -6,7 +6,7 @@
"declaration": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "Node",
"resolveJsonModule": true,
"jsx": "react-jsx",
"emitDeclarationOnly": true,
@@ -17,8 +17,6 @@
"@excalidraw/element/*": ["./element/src/*"],
"@excalidraw/excalidraw": ["./excalidraw/index.tsx"],
"@excalidraw/excalidraw/*": ["./excalidraw/*"],
"@excalidraw/laser-pointer": ["./laser-pointer/src/index.ts"],
"@excalidraw/laser-pointer/*": ["./laser-pointer/src/*"],
"@excalidraw/math": ["./math/src/index.ts"],
"@excalidraw/math/*": ["./math/src/*"],
"@excalidraw/utils": ["./utils/src/index.ts"],
@@ -30,8 +30,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -86,7 +85,6 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -23,7 +23,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidthKey": "medium",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+1 -3
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "bundler",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -27,8 +27,6 @@
"@excalidraw/element/*": ["./packages/element/src/*"],
"@excalidraw/fractional-indexing": ["./packages/fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./packages/fractional-indexing/src/*"],
"@excalidraw/laser-pointer": ["./packages/laser-pointer/src/index.ts"],
"@excalidraw/laser-pointer/*": ["./packages/laser-pointer/src/*"],
"@excalidraw/math": ["./packages/math/src/index.ts"],
"@excalidraw/math/*": ["./packages/math/src/*"],
"@excalidraw/utils": ["./packages/utils/src/index.ts"],
-11
View File
@@ -59,17 +59,6 @@ export default defineConfig({
"./packages/fractional-indexing/src/$1",
),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"./packages/laser-pointer/src/index.ts",
),
},
{
find: /^@excalidraw\/laser-pointer\/(.*?)/,
replacement: path.resolve(__dirname, "./packages/laser-pointer/src/$1"),
},
],
},
//@ts-ignore
+5
View File
@@ -1521,6 +1521,11 @@
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd"
integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ==
"@excalidraw/laser-pointer@1.3.1":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c"
integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==
"@excalidraw/markdown-to-text@0.1.2":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"