Compare commits

...

8 Commits

Author SHA1 Message Date
Dany Valverde Caldas 4ce70b815e fix(editor): ensure canvas is cleared when background color is invalid to prevent ghosting (#11458)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 22:59:17 +02:00
David Luzar c070c8ffa6 fix(editor): improve scroll animation interpolation (#11562) 2026-06-26 09:56:12 +02:00
Márk Tolmács e4c70cb6c6 feat(editor): AnimationController for scrollToContent (#11553)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-26 09:35:32 +02:00
minato32 20f694d110 fix(editor): prevent freeze from extremely large line elements (#11556)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-25 20:06:02 +02:00
David Luzar 2a82821ec5 fix(editor): tweak freedraw settings and tablet UI/UX (#11551)
* fix(editor): align constant freedraw stroke width more with generic shapes

* reduce streamline for non-mouse

* do not change `currentItemStrokeVariability` unintentionally

* render penMode button under compact styles panel on tablets

* show stroke variability as standalone button in compact actions menu

* improve toolbar clicking UX with pen

* change streamline defaults

* change to `variable` stroke if toggling penMode for the first time
2026-06-24 16:59:10 +02:00
Márk Tolmács 070df27e4d fix(editor): Modern TS require imports from rootDir (#11552)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 10:52:56 +00:00
Márk Tolmács cd514d72d6 feat(editor): LaserPointer based freedraw (#11507)
Introduces constant width freedraw mode, keeping the original variable mode as default.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 09:16:22 +02:00
zsviczian 0642e72cfa fix(editor): Arrows with text are rendered blurry in PNG export with larger scale (#11492) 2026-06-22 18:22:26 +02:00
63 changed files with 2361 additions and 635 deletions
+7
View File
@@ -82,6 +82,13 @@ 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: {
+2 -1
View File
@@ -57,7 +57,8 @@
"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:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"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:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
+43 -4
View File
@@ -404,11 +404,47 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
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"]>
> = {
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,
} as const;
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";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
@@ -423,7 +459,7 @@ export const DEFAULT_ELEMENT_PROPS: {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: 2,
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
@@ -514,3 +550,6 @@ 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;
-129
View File
@@ -204,135 +204,6 @@ 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[],
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+2
View File
@@ -38,6 +38,8 @@ 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,6 +4,7 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
DEFAULT_STROKE_STREAMLINE,
VERTICAL_ALIGN,
randomInteger,
randomId,
@@ -444,6 +445,7 @@ export const newFreeDrawElement = (
type: "freedraw";
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
strokeOptions?: ExcalidrawFreeDrawElement["strokeOptions"];
pressures?: ExcalidrawFreeDrawElement["pressures"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
@@ -452,6 +454,10 @@ export const newFreeDrawElement = (
points: opts.points || [],
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
strokeOptions: opts.strokeOptions ?? {
variability: "variable",
streamline: DEFAULT_STROKE_STREAMLINE,
},
};
};
+38 -51
View File
@@ -889,8 +889,10 @@ export const renderElement = (
case "embeddable": {
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2 + appState.scrollX;
const cy = (y1 + y2) / 2 + appState.scrollY;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
const cx = centerX + appState.scrollX;
const cy = centerY + appState.scrollY;
let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
@@ -912,64 +914,49 @@ export const renderElement = (
const boundTextElement = getBoundTextElement(element, elementsMap);
if (isArrowElement(element) && boundTextElement) {
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
// Draw arrow directly as vector and clear label hole separately.
// This avoids temp-canvas bitmap blit which introduces resampling blur.
shiftX = element.width / 2 - (element.x - x1);
shiftY = element.height / 2 - (element.y - y1);
tempCanvasContext.rotate(element.angle);
const tempRc = rough.canvas(tempCanvas);
context.save();
context.rotate(element.angle);
context.translate(-shiftX, -shiftY);
drawElementOnCanvas(element, rc, context, renderConfig);
context.restore();
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 boundTextShiftX = (x1 + x2) / 2 - boundTextCx;
const boundTextShiftY = (y1 + y2) / 2 - boundTextCy;
tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY);
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;
// 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,
);
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);
}
} else {
context.rotate(element.angle);
+69 -7
View File
@@ -1,5 +1,6 @@
import { simplify } from "points-on-curve";
import { getStroke } from "perfect-freehand";
import { LaserPointer } from "@excalidraw/laser-pointer";
import {
type GeometricShape,
@@ -24,6 +25,7 @@ import {
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -1171,27 +1173,87 @@ const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
) as SVGPathString;
};
export const getFreedrawOutlinePoints = (
/**
* 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 = (
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]])
? element.points.map(
([x, y], i) => [x, y, element.pressures[i]] as [number, number, number],
)
: [[0, 0, 0.5]];
return getStroke(inputPoints as number[][], {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
size: element.strokeWidth * VARIABLE_WIDTH_FREEDRAW.SIZE_FACTOR,
thinning: VARIABLE_WIDTH_FREEDRAW.THINNING,
smoothing: VARIABLE_WIDTH_FREEDRAW.SMOOTHING,
streamline: getFreedrawStreamline(element),
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,12 +384,20 @@ export type ExcalidrawElbowArrowElement = Merge<
}
>;
export type StrokeVariability = "variable" | "constant";
export type StrokeOptions = Readonly<{
variability: StrokeVariability;
streamline: number;
}>;
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
Readonly<{
type: "freedraw";
points: readonly LocalPoint[];
pressures: readonly number[];
simulatePressure: boolean;
strokeOptions: StrokeOptions;
}>;
export type FileId = string & { _brand: "FileId" };
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
@@ -5,6 +5,7 @@ import {
VERTICAL_ALIGN,
arrayToMap,
getFontString,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
getOriginalContainerHeightFromCache,
@@ -249,7 +250,10 @@ export const actionWrapTextInContainer = register({
fillStyle: appState.currentItemFillStyle,
strokeColor: appState.currentItemStrokeColor,
roughness: appState.currentItemRoughness,
strokeWidth: appState.currentItemStrokeWidth,
strokeWidth: getStrokeWidthByKey(
"rectangle",
appState.currentItemStrokeWidthKey,
),
strokeStyle: appState.currentItemStrokeStyle,
roundness:
appState.currentItemRoundness === "round"
@@ -1,8 +1,9 @@
import { queryByTestId } from "@testing-library/react";
import { fireEvent, queryByTestId } from "@testing-library/react";
import {
COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
FREEDRAW_STROKE_WIDTH,
FONT_FAMILY,
STROKE_WIDTH,
} from "@excalidraw/common";
@@ -128,6 +129,62 @@ 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",
@@ -135,7 +192,7 @@ describe("element locking", () => {
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
strokeWidth: STROKE_WIDTH.medium,
});
API.setElements([rect1, rect2]);
API.setSelectedElements([rect1, rect2]);
@@ -145,17 +202,17 @@ describe("element locking", () => {
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
queryByTestId(document.body, `strokeWidth-medium`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-extraBold`),
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
strokeWidth: STROKE_WIDTH.medium,
});
const text = API.createElement({
type: "text",
@@ -164,7 +221,7 @@ describe("element locking", () => {
API.setElements([rect, text]);
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `strokeWidth-medium`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
+128 -19
View File
@@ -12,7 +12,7 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH,
STROKE_WIDTH_KEYS,
VERTICAL_ALIGN,
KEYS,
randomInteger,
@@ -20,9 +20,11 @@ import {
getFontFamilyString,
getLineHeight,
isTransparent,
getStrokeWidthByKey,
reduceToCommonValue,
invariant,
FONT_SIZES,
type StrokeWidthKey,
} from "@excalidraw/common";
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
@@ -70,9 +72,11 @@ import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement,
FontFamilyValues,
StrokeVariability,
TextAlign,
VerticalAlign,
} from "@excalidraw/element/types";
@@ -83,6 +87,7 @@ 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";
@@ -131,6 +136,8 @@ import {
ArrowheadCardinalityOneOrManyIcon,
ArrowheadCardinalityZeroOrManyIcon,
ArrowheadCardinalityZeroOrOneIcon,
strokeVariabilityConstantIcon,
strokeVariabilityVariableIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -190,7 +197,11 @@ export const changeProperty = (
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T,
/**
* input value (usually the element attribute value,
* but depends on what the action's PanelComponent input expects)
*/
getValue: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
@@ -200,7 +211,7 @@ export const getFormValue = function <T extends Primitive>(
let ret: T | null = null;
if (editingTextElement) {
ret = getAttribute(editingTextElement);
ret = getValue(editingTextElement);
}
if (!ret) {
@@ -214,7 +225,7 @@ export const getFormValue = function <T extends Primitive>(
: selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getAttribute) ??
reduceToCommonValue(targetElements, getValue) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
@@ -544,20 +555,37 @@ export const actionChangeFillStyle = register<ExcalidrawElement["fillStyle"]>({
},
});
export const actionChangeStrokeWidth = register<
ExcalidrawElement["strokeWidth"]
>({
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>({
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: value,
strokeWidth: getStrokeWidthForElement(el, value),
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
appState: { ...appState, currentItemStrokeWidthKey: value },
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
@@ -565,35 +593,35 @@ export const actionChangeStrokeWidth = register<
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList">
<RadioSelection
<RadioSelection<StrokeWidthKey>
group="stroke-width"
options={[
{
value: STROKE_WIDTH.thin,
value: "thin",
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
value: "medium",
text: t("labels.medium"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
testId: "strokeWidth-medium",
},
{
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
value: "bold",
text: t("labels.bold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
testId: "strokeWidth-bold",
},
]}
value={getFormValue(
elements,
app,
(element) => element.strokeWidth,
getStrokeWidthKeyForElement,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
hasSelection ? null : appState.currentItemStrokeWidthKey,
)}
onChange={(value) => updateData(value)}
/>
@@ -656,6 +684,87 @@ 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,6 +13,7 @@ export {
actionChangeStrokeWidth,
actionChangeFillStyle,
actionChangeSloppiness,
actionChangeFreedrawMode,
actionChangeOpacity,
actionChangeFontSize,
actionChangeFontFamily,
+1
View File
@@ -68,6 +68,7 @@ export type ActionName =
| "changeStrokeWidth"
| "changeStrokeShape"
| "changeSloppiness"
| "changeFreedrawMode"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
+9 -2
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
DEFAULT_TEXT_ALIGN,
DEFAULT_GRID_SIZE,
EXPORT_SCALES,
@@ -34,12 +35,13 @@ 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,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemStrokeWidthKey: DEFAULT_ELEMENT_STROKE_WIDTH_KEY,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
@@ -167,10 +169,15 @@ 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 },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemStrokeWidthKey: { 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 },
+46 -11
View File
@@ -41,6 +41,7 @@ import {
canHaveArrowheads,
getTargetElements,
hasBackground,
hasFreedrawMode,
hasStrokeStyle,
hasStrokeWidth,
} from "../scene";
@@ -201,9 +202,9 @@ export const SelectedShapeActions = ({
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(appState.activeTool.type === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) &&
renderAction("changeFreedrawMode")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
@@ -394,6 +395,17 @@ 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),
@@ -826,6 +838,14 @@ 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}
@@ -1054,6 +1074,11 @@ 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",
@@ -1152,8 +1177,13 @@ 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") {
app.togglePenMode(true);
pendingPenDetectionRef.current = true;
}
if (value === "selection") {
@@ -1164,16 +1194,21 @@ export const ShapesSwitcher = ({
}
}
}}
onChange={({ pointerType }) => {
onChange={() => {
if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
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));
}
}}
/>
+86 -149
View File
@@ -27,6 +27,8 @@ import {
KEYS,
APP_NAME,
CURSOR_TYPE,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
DEFAULT_TRANSFORM_HANDLE_SPACING,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -75,11 +77,9 @@ import {
updateObject,
updateActiveTool,
isTransparent,
easeToValuesRAF,
muteFSAbortError,
isTestEnv,
isDevEnv,
easeOut,
updateStable,
addEventListener,
normalizeEOL,
@@ -109,6 +109,7 @@ import {
setDesktopUIMode,
isSelectionLikeTool,
oneOf,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
@@ -200,7 +201,6 @@ import {
cropElement,
wrapText,
isElementLink,
parseElementLinkFromURL,
isMeasureTextSupported,
normalizeText,
measureText,
@@ -261,6 +261,7 @@ import {
getActiveTextElement,
isEligibleFrameChildType,
getBindingStrategyForDraggingBindingElementEndpoints,
parseElementLinkFromURL,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -328,7 +329,7 @@ import {
actionToggleCropEditor,
} from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { actionToggleHandTool } from "../actions/actionCanvas";
import { actionPaste } from "../actions/actionClipboard";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { actionUnlockAllElements } from "../actions/actionElementLock";
@@ -410,6 +411,11 @@ import {
isGridModeEnabled,
} from "../snapping";
import { Renderer } from "../scene/Renderer";
import {
type ScrollToContentOptions,
SCROLL_TO_CONTENT_ANIMATION_KEY,
scrollToElements,
} from "../scroll";
import {
setEraserCursor,
setCursor,
@@ -422,16 +428,12 @@ 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,
@@ -4134,7 +4136,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeStyle: this.state.currentItemStrokeStyle,
roundness: null,
roughness: this.state.currentItemRoughness,
@@ -4305,6 +4307,9 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: force ?? !prevState.penMode,
penDetected: true,
currentItemStrokeVariability: !prevState.penDetected
? "variable"
: prevState.currentItemStrokeVariability,
};
});
};
@@ -4335,148 +4340,60 @@ class App extends React.Component<AppProps, AppState> {
});
};
private cancelInProgressAnimation: (() => void) | null = null;
scrollToContent = (
/**
* target to scroll to
*
* - string - id of element or group, or url containing elementLink
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
*/
target:
target?:
| string
| ExcalidrawElement
| 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;
},
| readonly NonDeletedExcalidrawElement[],
opts?: ScrollToContentOptions,
) => {
let elements: readonly NonDeleted<ExcalidrawElement>[];
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
id = parseElementLinkFromURL(target);
} else {
id = target;
}
if (id) {
const elements = this.scene.getElementsFromId(id);
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();
}
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,
},
});
}
if (!elements.length) {
if (typeof target === "string" && isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
return;
}
this.cancelInProgressAnimation?.();
// 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));
// 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,
}
// 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,
});
: opts;
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
scrollToElements(
this.state,
elements,
this.setState.bind(this),
resolvedOpts,
);
};
private maybeUnfollowRemoteUser = () => {
@@ -4489,7 +4406,8 @@ class App extends React.Component<AppProps, AppState> {
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgressAnimation?.();
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
this.setState({ shouldCacheIgnoreZoom: false });
this.maybeUnfollowRemoteUser();
this.setState(state);
};
@@ -6304,7 +6222,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("text"),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -7774,6 +7692,7 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: true,
penDetected: true,
currentItemStrokeVariability: "variable",
};
});
}
@@ -8992,6 +8911,8 @@ 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,
@@ -8999,15 +8920,24 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("freedraw"),
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],
});
@@ -9058,7 +8988,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("iframe"),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("iframe"),
@@ -9111,7 +9041,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: "transparent",
backgroundColor: "transparent",
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("embeddable"),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: this.getCurrentItemRoundness("embeddable"),
@@ -9158,7 +9088,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth("image"),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
roundness: null,
@@ -9336,7 +9266,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9363,7 +9293,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -9500,6 +9430,13 @@ 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,
@@ -9523,7 +9460,7 @@ class App extends React.Component<AppProps, AppState> {
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeWidth: this.getCurrentItemStrokeWidth(elementType),
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
@@ -120,6 +120,24 @@
}
}
// 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;
+30 -10
View File
@@ -235,8 +235,6 @@ const LayerUI = ({
);
const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel;
return (
<Section
heading="selectedShapeActions"
@@ -244,7 +242,7 @@ const LayerUI = ({
"transition-left": appState.zenModeEnabled,
})}
>
{isCompactMode ? (
{isCompactStylesPanel ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
@@ -312,6 +310,23 @@ 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" && (
@@ -343,13 +358,18 @@ const LayerUI = ({
/>
{heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
{/* 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}
/>
)}
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
+68
View File
@@ -1249,6 +1249,74 @@ 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" />
+86 -33
View File
@@ -3,6 +3,7 @@ import { isFiniteNumber, isValidPoint, pointFrom } from "@excalidraw/math";
import {
type CombineBrandsIfNeeded,
DEFAULT_FONT_FAMILY,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
FONT_FAMILY,
@@ -18,6 +19,9 @@ import {
getSizeFromPoints,
normalizeLink,
getLineHeight,
STROKE_WIDTH,
STROKE_WIDTH_KEYS,
type StrokeWidthKey,
} from "@excalidraw/common";
import {
calculateFixedPointForNonElbowArrowBinding,
@@ -70,6 +74,7 @@ import type {
FontFamilyValues,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
StrokeVariability,
StrokeRoundness,
} from "@excalidraw/element/types";
@@ -96,7 +101,38 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_ARROW_PX = 75_000;
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 restoreLinearElementPoints = (
points: unknown,
@@ -188,6 +224,43 @@ 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[
@@ -483,6 +556,7 @@ export const restoreElement = (
return restoreElementWithProperties(element, {
points,
simulatePressure: element.simulatePressure,
strokeOptions: restoreFreedrawStrokeOptions(element.strokeOptions),
pressures,
});
}
@@ -517,7 +591,7 @@ export const restoreElement = (
} as ExcalidrawLinearElement));
}
return restoreElementWithProperties(element, {
const restoredLine = restoreElementWithProperties(element, {
type: "line",
startBinding: null,
endBinding: null,
@@ -535,6 +609,8 @@ export const restoreElement = (
: {}),
...getSizeFromPoints(points),
});
return handleOversizedLinearElements(restoredLine);
case "arrow": {
const startArrowhead = normalizeArrowhead(element.startArrowhead);
const endArrowhead =
@@ -601,37 +677,7 @@ export const restoreElement = (
),
};
// 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;
return handleOversizedLinearElements(normalizedRestoredElement);
}
// generic elements
@@ -1056,6 +1102,13 @@ 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",
+2 -1
View File
@@ -10,6 +10,7 @@ import {
applyDarkModeFilter,
DEFAULT_IMAGE_OPTIONS,
DEFAULT_UI_OPTIONS,
getStrokeWidthByKey,
isShallowEqual,
} from "@excalidraw/common";
@@ -450,4 +451,4 @@ export function useExcalidrawStateValue(
export { _useOnAppStateChange as useOnExcalidrawStateChange };
export { applyDarkModeFilter };
export { applyDarkModeFilter, getStrokeWidthByKey };
+3
View File
@@ -35,6 +35,9 @@
"strokeStyle_dashed": "Dashed",
"strokeStyle_dotted": "Dotted",
"sloppiness": "Sloppiness",
"pressure": "Pressure",
"pressure_constant": "Constant",
"pressure_variable": "Variable",
"opacity": "Opacity",
"textAlign": "Text align",
"edges": "Edges",
@@ -123,4 +123,9 @@ export class AnimationController {
AnimationController.animations.delete(key);
AnimationController.cancelScheduledFrameIfIdle();
}
static reset() {
AnimationController.animations.clear();
AnimationController.cancelScheduledFrame();
}
}
@@ -0,0 +1,90 @@
import { COLOR_WHITE } from "@excalidraw/common";
import { bootstrapCanvas } from "./helpers";
const setup = () => {
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 100;
const context = canvas.getContext("2d")!;
const clearRect = vi.spyOn(context, "clearRect");
const fillRect = vi.spyOn(context, "fillRect");
return { canvas, context, clearRect, fillRect };
};
const run = (viewBackgroundColor: unknown) => {
const { canvas, context, clearRect, fillRect } = setup();
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: viewBackgroundColor as string,
});
return { context, clearRect, fillRect };
};
describe("bootstrapCanvas background painting", () => {
it("skips clearRect for an opaque hex color (fill fully repaints)", () => {
const { clearRect, fillRect } = run("#ffffff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("skips clearRect for a 3-digit opaque hex color", () => {
const { clearRect, fillRect } = run("#fff");
expect(clearRect).not.toHaveBeenCalled();
expect(fillRect).toHaveBeenCalledTimes(1);
});
it("clears for a hex color with alpha (#RGBA / #RRGGBBAA)", () => {
expect(run("#ffff").clearRect).toHaveBeenCalledTimes(1);
expect(run("#ffffff80").clearRect).toHaveBeenCalledTimes(1);
});
it("clears and skips fill for the transparent keyword", () => {
const { clearRect, fillRect } = run("transparent");
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
it("clears for rgba()/hsla() colors and still fills", () => {
const rgba = run("rgba(255, 0, 0, 0.5)");
expect(rgba.clearRect).toHaveBeenCalledTimes(1);
expect(rgba.fillRect).toHaveBeenCalledTimes(1);
});
// the ghosting bug (#10931): a corrupted value must never leave the prior
// frame on screen — we always clear when we can't prove the color is opaque
it("clears for a corrupted color value to prevent ghosting", () => {
expect(run("0000").clearRect).toHaveBeenCalledTimes(1);
expect(run("asdfgh").clearRect).toHaveBeenCalledTimes(1);
});
it("falls back to white when the color is rejected by the canvas", () => {
const { canvas, context } = setup();
// simulate a stale fillStyle left over from a previous frame's drawing
context.fillStyle = "#ff0000";
let fillStyleAtFillTime = "";
vi.spyOn(context, "fillRect").mockImplementation(() => {
fillStyleAtFillTime = context.fillStyle as string;
});
bootstrapCanvas({
canvas,
scale: 1,
normalizedWidth: 200,
normalizedHeight: 100,
viewBackgroundColor: "not-a-color",
});
// not the stale red — the seeded default
expect(fillStyleAtFillTime).toBe(COLOR_WHITE);
});
it("clears for a non-string background", () => {
const { clearRect, fillRect } = run(undefined);
expect(clearRect).toHaveBeenCalledTimes(1);
expect(fillRect).not.toHaveBeenCalled();
});
});
+24 -14
View File
@@ -1,4 +1,4 @@
import { THEME, applyDarkModeFilter } from "@excalidraw/common";
import { COLOR_WHITE, THEME, applyDarkModeFilter } from "@excalidraw/common";
import type { StaticCanvasRenderConfig } from "../scene/types";
import type { AppState, StaticCanvasAppState } from "../types";
@@ -53,21 +53,31 @@ export const bootstrapCanvas = ({
// Paint background
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
// An opaque fill repaints every pixel, so clearRect would be redundant.
// For anything else — transparency, or a value we can't be certain about
// (e.g. corrupted persisted state like "0000") — clear first so the
// previous frame can't bleed through.
//
// We skip opaque #RRGGBB and #RGB hex colors as a quick optimization.
const isOpaque = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(viewBackgroundColor);
if (!isOpaque) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
if (viewBackgroundColor !== "transparent") {
context.save();
// The canvas silently ignores an invalid fillStyle, which would leave a
// stale color from a previous draw. Seed a sane default so corrupted
// values fall back to white instead of painting garbage.
context.fillStyle = COLOR_WHITE;
context.fillStyle = applyDarkModeFilter(
viewBackgroundColor,
theme === THEME.DARK,
);
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
}
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
+8 -1
View File
@@ -1,5 +1,6 @@
import {
applyDarkModeFilter,
COLOR_WHITE,
FRAME_STYLE,
THEME,
throttleRAF,
@@ -204,7 +205,13 @@ const renderLinkIcon = (
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = appState.viewBackgroundColor || "#fff";
// Seed a sane default so a corrupted color (silently rejected by the
// canvas) falls back to white instead of a stale fillStyle.
linkCanvasCacheContext.fillStyle = COLOR_WHITE;
linkCanvasCacheContext.fillStyle =
appState.viewBackgroundColor || COLOR_WHITE;
linkCanvasCacheContext.fillRect(0, 0, width, height);
if (canvasKey === "elementLink") {
+1
View File
@@ -9,6 +9,7 @@ export {
hasBackground,
hasStrokeWidth,
hasStrokeStyle,
hasFreedrawMode,
canHaveArrowheads,
canChangeRoundness,
} from "@excalidraw/element";
+180
View File
@@ -0,0 +1,180 @@
import { easeOut } 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 { calculateScrollCenter } from "./scene/scroll";
import type { AppState, NormalizedZoomValue, Offsets } 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;
};
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
/**
* 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 => {
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,
});
return {
scrollX: appState.scrollX,
scrollY: appState.scrollY,
zoom: appState.zoom,
};
}
// keep the current zoom, only recenter the viewport on the target
const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
return { scrollX, scrollY, zoom: state.zoom };
};
/**
* 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
});
// returning a falsy value signals the AnimationController to remove the
// animation; otherwise it would keep ticking (and calling onFrame) every
// frame forever after reaching the target
return progress < 1 ? { elapsed } : null;
},
);
};
@@ -904,7 +904,8 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1103,7 +1104,8 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1317,7 +1319,8 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1648,7 +1651,8 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1979,7 +1983,8 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2193,7 +2198,8 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2434,7 +2440,8 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2732,7 +2739,8 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3104,7 +3112,8 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "dotted",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "bold",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3214,11 +3223,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"seed": 1278240551,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"strokeWidth": 4,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 1402203177,
"versionNonce": 1349943049,
"width": 20,
"x": -10,
"y": 0,
@@ -3243,14 +3252,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"opacity": 60,
"roughness": 2,
"roundness": null,
"seed": 1898319239,
"seed": 406373543,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 2,
"strokeWidth": 4,
"type": "rectangle",
"updated": 1,
"version": 9,
"versionNonce": 941653321,
"version": 10,
"versionNonce": 1402203177,
"width": 20,
"x": 20,
"y": 30,
@@ -3259,7 +3268,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`] = `16`;
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] redo stack 1`] = `[]`;
@@ -3459,11 +3468,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"strokeStyle": "dotted",
"strokeWidth": 4,
"version": 7,
},
"inserted": {
"strokeStyle": "solid",
"strokeWidth": 2,
"version": 6,
},
},
@@ -3484,11 +3493,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"roughness": 2,
"strokeStyle": "dotted",
"version": 8,
},
"inserted": {
"roughness": 1,
"strokeStyle": "solid",
"version": 7,
},
},
@@ -3509,11 +3518,11 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"updated": {
"id3": {
"deleted": {
"opacity": 60,
"roughness": 2,
"version": 9,
},
"inserted": {
"opacity": 100,
"roughness": 1,
"version": 8,
},
},
@@ -3521,6 +3530,31 @@ 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 {
@@ -3548,6 +3582,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 2,
"strokeColor": "#e03131",
"strokeStyle": "dotted",
"strokeWidth": 4,
"version": 4,
},
"inserted": {
@@ -3557,12 +3592,13 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"roughness": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"version": 3,
},
},
},
},
"id": "id19",
"id": "id21",
},
]
`;
@@ -3597,7 +3633,8 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3920,7 +3957,8 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4243,7 +4281,8 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5528,7 +5567,8 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6745,7 +6785,8 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7702,7 +7743,8 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8702,7 +8744,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9693,7 +9736,8 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -30,7 +30,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -664,7 +665,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1226,7 +1228,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1586,7 +1589,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1948,7 +1952,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2211,7 +2216,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2698,7 +2704,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3001,7 +3008,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3320,7 +3328,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3614,7 +3623,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3900,7 +3910,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4135,7 +4146,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4392,7 +4404,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4663,7 +4676,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4892,7 +4906,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5121,7 +5136,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5368,7 +5384,8 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5624,7 +5641,8 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5882,7 +5900,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6211,7 +6230,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6638,7 +6658,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7012,7 +7033,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7324,7 +7346,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7617,7 +7640,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7847,7 +7871,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8199,7 +8224,8 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8551,7 +8577,8 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8957,7 +8984,8 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9083,8 +9111,12 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"updated": 1,
"version": 7,
@@ -9185,8 +9217,12 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"version": 7,
"width": 50,
@@ -9236,7 +9272,8 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9500,7 +9537,8 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9765,7 +9803,8 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9997,7 +10036,8 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10294,7 +10334,8 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10612,7 +10653,8 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10848,7 +10890,8 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11289,7 +11332,7 @@ exports[`history > multiplayer undo/redo > should support undo and redo when esc
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11773,7 +11816,8 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12033,7 +12077,8 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12268,7 +12313,8 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12505,7 +12551,8 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#e03131",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12656,8 +12703,12 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"updated": 1,
"version": 5,
@@ -12706,8 +12757,12 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#e03131",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"updated": 1,
"version": 4,
@@ -12845,8 +12900,12 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"roundness": null,
"simulatePressure": false,
"strokeColor": "#e03131",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"version": 4,
"width": 50,
@@ -12896,7 +12955,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13106,7 +13166,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13313,7 +13374,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13614,7 +13676,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13912,7 +13975,8 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14157,7 +14221,8 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14394,7 +14459,8 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14631,7 +14697,8 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14878,7 +14945,8 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15209,7 +15277,8 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15379,7 +15448,8 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15663,7 +15733,8 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -15926,7 +15997,8 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16079,7 +16151,8 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16361,7 +16434,8 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -16523,7 +16597,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -17272,7 +17347,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -17919,7 +17995,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -18566,7 +18643,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -19316,7 +19394,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -20085,7 +20164,8 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -20565,7 +20645,8 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -21076,7 +21157,8 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -21535,7 +21617,8 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -30,7 +30,8 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -456,7 +457,8 @@ exports[`given element A and group of elements B and given both are selected whe
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -872,7 +874,8 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1438,7 +1441,8 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1645,7 +1649,8 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2029,7 +2034,8 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2274,7 +2280,8 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2454,7 +2461,8 @@ exports[`regression tests > can drag element that covers another element, while
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -2779,7 +2787,8 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1971c2",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3034,7 +3043,8 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3275,7 +3285,8 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3511,7 +3522,8 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -3769,7 +3781,8 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4083,7 +4096,8 @@ exports[`regression tests > deleting last but one element in editing group shoul
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -4519,7 +4533,8 @@ exports[`regression tests > deselects group of selected elements on pointer down
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -4802,7 +4817,8 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5078,7 +5094,8 @@ exports[`regression tests > deselects selected element on pointer down when poin
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -5286,7 +5303,8 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5486,7 +5504,8 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -5879,7 +5898,8 @@ exports[`regression tests > drags selected elements from point inside common bou
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6176,7 +6196,8 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -6914,8 +6935,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"version": 4,
"width": 50,
@@ -6965,7 +6990,8 @@ exports[`regression tests > given a group of selected elements with an element t
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7299,7 +7325,8 @@ exports[`regression tests > given a selected element A and a not selected elemen
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7578,7 +7605,8 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -7813,7 +7841,8 @@ exports[`regression tests > given selected element A with lower z-index than uns
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8053,7 +8082,8 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8233,7 +8263,8 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8413,7 +8444,8 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8593,7 +8625,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -8826,7 +8859,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9057,7 +9091,8 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9198,8 +9233,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"version": 4,
"width": 30,
@@ -9249,7 +9288,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9482,7 +9522,8 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9662,7 +9703,8 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -9893,7 +9935,8 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10073,7 +10116,8 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10214,8 +10258,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
"roundness": null,
"simulatePressure": false,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "constant",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"version": 4,
"width": 30,
@@ -10265,7 +10313,8 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10445,7 +10494,8 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -10976,7 +11026,8 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11256,7 +11307,8 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -11379,7 +11431,8 @@ exports[`regression tests > shift click on selected element should deselect it o
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11579,7 +11632,8 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -11898,7 +11952,8 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12327,7 +12382,8 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -12967,7 +13023,8 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13093,7 +13150,8 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -13724,7 +13782,8 @@ exports[`regression tests > switches from group of selected elements to another
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14063,7 +14122,8 @@ exports[`regression tests > switches selected element on pointer down > [end of
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14327,7 +14387,8 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "down",
"defaultSidebarDockedPreference": false,
@@ -14450,7 +14511,8 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14815,7 +14877,8 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -14938,7 +15001,8 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -1,6 +1,6 @@
import React from "react";
import { CODES } from "@excalidraw/common";
import { CODES, STROKE_WIDTH } 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(2); // Bold: 2
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
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, reseed } from "@excalidraw/common";
import { KEYS, STROKE_WIDTH, 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(2); // Bold: 2
expect(firstRect.strokeWidth).toBe(STROKE_WIDTH.bold);
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
@@ -240,8 +240,12 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
"seed": Any<Number>,
"simulatePressure": true,
"strokeColor": "#1e1e1e",
"strokeOptions": {
"streamline": "0.50000",
"variability": "variable",
},
"strokeStyle": "solid",
"strokeWidth": 2,
"strokeWidth": 1,
"type": "freedraw",
"updated": 1,
"version": 2,
@@ -193,6 +193,53 @@ 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" });
@@ -479,6 +526,53 @@ 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",
@@ -640,6 +734,21 @@ 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";
@@ -688,6 +797,21 @@ 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(),
+137 -20
View File
@@ -1,20 +1,62 @@
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;
const waitForNextAnimationFrame = () => {
/**
* 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) => {
return act(
() =>
new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
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);
}),
);
};
@@ -77,6 +119,34 @@ 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 />);
@@ -109,11 +179,16 @@ describe("fitToContent", () => {
describe("fitToContent animated", () => {
beforeEach(() => {
vi.spyOn(window, "requestAnimationFrame");
// 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;
});
afterEach(() => {
vi.restoreAllMocks();
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();
});
it("should ease scroll the viewport to the selected element", async () => {
@@ -130,17 +205,18 @@ describe("fitToContent animated", () => {
});
act(() => {
h.app.scrollToContent(rectElement, { animate: true });
h.app.scrollToContent(rectElement, {
animate: true,
duration: LONG_ANIMATION_DURATION,
});
});
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
// the animation hasn't progressed yet, so we're still at the origin
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
await waitForNextAnimationFrame();
// Since this is an animation, we expect values to change through time.
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -148,7 +224,7 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
await waitForAnimationProgress();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
@@ -171,12 +247,14 @@ describe("fitToContent animated", () => {
expect(h.state.scrollY).toBe(0);
act(() => {
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
h.app.scrollToContent(rectElement, {
animate: true,
fitToContent: true,
duration: LONG_ANIMATION_DURATION,
});
});
expect(window.requestAnimationFrame).toHaveBeenCalled();
await waitForNextAnimationFrame();
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
@@ -184,9 +262,48 @@ describe("fitToContent animated", () => {
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForNextAnimationFrame();
await waitForAnimationProgress();
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);
});
});
@@ -0,0 +1,57 @@
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");
});
});
+13 -2
View File
@@ -4,7 +4,12 @@ import util from "util";
import { pointFrom, type LocalPoint, type Radians } from "@excalidraw/math";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS, assertNever } from "@excalidraw/common";
import {
DEFAULT_VERTICAL_ALIGN,
ROUNDNESS,
assertNever,
getStrokeWidthByKey,
} from "@excalidraw/common";
import {
newArrowElement,
@@ -200,6 +205,9 @@ 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;
@@ -258,7 +266,9 @@ export class API {
backgroundColor:
rest.backgroundColor ?? appState.currentItemBackgroundColor,
fillStyle: rest.fillStyle ?? appState.currentItemFillStyle,
strokeWidth: rest.strokeWidth ?? appState.currentItemStrokeWidth,
strokeWidth:
rest.strokeWidth ??
getStrokeWidthByKey(type, appState.currentItemStrokeWidthKey),
strokeStyle: rest.strokeStyle ?? appState.currentItemStrokeStyle,
roundness: (
rest.roundness === undefined
@@ -317,6 +327,7 @@ export class API {
type: type as "freedraw",
simulatePressure: true,
points: rest.points,
strokeOptions: rest.strokeOptions,
...base,
});
break;
@@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["**/*"],
+4 -1
View File
@@ -4,6 +4,7 @@ import type {
throttleRAF,
MIME_TYPES,
EditorInterface,
StrokeWidthKey,
} from "@excalidraw/common";
import type { LinearElementEditor } from "@excalidraw/element";
@@ -33,6 +34,7 @@ import type {
ExcalidrawNonSelectionElement,
BindMode,
ExcalidrawTextElement,
StrokeVariability,
} from "@excalidraw/element/types";
import type {
@@ -362,9 +364,10 @@ export interface AppState {
currentItemStrokeColor: string;
currentItemBackgroundColor: string;
currentItemFillStyle: ExcalidrawElement["fillStyle"];
currentItemStrokeWidth: number;
currentItemStrokeWidthKey: StrokeWidthKey;
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
currentItemRoughness: number;
currentItemStrokeVariability: StrokeVariability;
currentItemOpacity: number;
currentItemFontFamily: FontFamilyValues;
currentItemFontSize: number;
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+21
View File
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,23 @@
# 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
@@ -0,0 +1,34 @@
{
"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
@@ -0,0 +1,2 @@
export * from "./state";
export type { Point } from "./math";
+105
View File
@@ -0,0 +1,105 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,377 @@
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
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+3 -1
View File
@@ -6,7 +6,7 @@
"declaration": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"emitDeclarationOnly": true,
@@ -17,6 +17,8 @@
"@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,7 +30,8 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeVariability": "constant",
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
@@ -23,7 +23,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemStrokeWidthKey": "medium",
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+3 -1
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
@@ -27,6 +27,8 @@
"@excalidraw/element/*": ["./packages/element/src/*"],
"@excalidraw/fractional-indexing": ["./packages/fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./packages/fractional-indexing/src/*"],
"@excalidraw/laser-pointer": ["./packages/laser-pointer/src/index.ts"],
"@excalidraw/laser-pointer/*": ["./packages/laser-pointer/src/*"],
"@excalidraw/math": ["./packages/math/src/index.ts"],
"@excalidraw/math/*": ["./packages/math/src/*"],
"@excalidraw/utils": ["./packages/utils/src/index.ts"],
-1
View File
@@ -1,5 +1,4 @@
{
"public": true,
"headers": [
{
"source": "/(.*)",
+11
View File
@@ -59,6 +59,17 @@ 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,11 +1521,6 @@
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"