Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5974e463d8 | |||
| dba56d3edc | |||
| 2fb12fdd5f | |||
| d56794024f | |||
| a2303cb121 | |||
| 63fce9a40c | |||
| 6359862b2e | |||
| 17eac29a9c | |||
| c070c8ffa6 | |||
| 1b75a1dbe2 | |||
| e4c70cb6c6 | |||
| 20f694d110 | |||
| 3c235f9618 | |||
| 24efadfc65 | |||
| d9b4f1fc6a | |||
| 937be77c00 |
@@ -1014,7 +1014,10 @@ const ExcalidrawWrapper = () => {
|
|||||||
</OverwriteConfirmDialog.Action>
|
</OverwriteConfirmDialog.Action>
|
||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
<AppFooter
|
||||||
|
excalidrawAPI={excalidrawAPI}
|
||||||
|
onChange={() => excalidrawAPI?.refresh()}
|
||||||
|
/>
|
||||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
|
|||||||
@@ -1,13 +1,131 @@
|
|||||||
import { Footer } from "@excalidraw/excalidraw/index";
|
import { Footer } from "@excalidraw/excalidraw/index";
|
||||||
import React from "react";
|
import {
|
||||||
|
getCommonBounds,
|
||||||
|
getSelectedElements,
|
||||||
|
getVisibleSceneBounds,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
ScrollConstraints,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
|
|
||||||
|
const ScrollConstraintsDebugFooter = ({
|
||||||
|
excalidrawAPI,
|
||||||
|
}: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
||||||
|
}) => {
|
||||||
|
const [activeLock, setActiveLock] = useState<ScrollConstraints | null>(null);
|
||||||
|
const [tolerance, setTolerance] = useState(0);
|
||||||
|
|
||||||
|
const setLock = useCallback(
|
||||||
|
(nextLock: ScrollConstraints) => {
|
||||||
|
// pass an empty target so the constraints are applied without scrolling
|
||||||
|
excalidrawAPI?.scrollToContent([], { scrollConstraints: nextLock });
|
||||||
|
setActiveLock(nextLock);
|
||||||
|
},
|
||||||
|
[excalidrawAPI],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleScrollLock = useCallback(() => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeLock) {
|
||||||
|
excalidrawAPI.scrollToContent([], { scrollConstraints: null });
|
||||||
|
setActiveLock(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [x1, y1, x2, y2] = selectedElements.length
|
||||||
|
? getCommonBounds(
|
||||||
|
selectedElements,
|
||||||
|
excalidrawAPI.getSceneElementsMapIncludingDeleted(),
|
||||||
|
)
|
||||||
|
: getVisibleSceneBounds(excalidrawAPI.getAppState());
|
||||||
|
|
||||||
|
setLock({
|
||||||
|
x: x1,
|
||||||
|
y: y1,
|
||||||
|
width: x2 - x1,
|
||||||
|
height: y2 - y1,
|
||||||
|
tolerance,
|
||||||
|
});
|
||||||
|
}, [activeLock, excalidrawAPI, setLock, tolerance]);
|
||||||
|
|
||||||
|
const updateTolerance = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextTolerance = Number(event.target.value) || 0;
|
||||||
|
setTolerance(nextTolerance);
|
||||||
|
|
||||||
|
if (activeLock) {
|
||||||
|
setLock({ ...activeLock, tolerance: nextTolerance });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeLock, setLock],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: ".35rem",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 .35rem",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
type="button"
|
||||||
|
onClick={toggleScrollLock}
|
||||||
|
>
|
||||||
|
{activeLock ? "disable scroll lock" : "lock scroll"}
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: ".25rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
tolerance
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={tolerance}
|
||||||
|
onChange={updateTolerance}
|
||||||
|
style={{ width: 56 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(
|
||||||
({ onChange }: { onChange: () => void }) => {
|
({
|
||||||
|
excalidrawAPI,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
||||||
|
onChange: () => void;
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<div
|
<div
|
||||||
@@ -17,6 +135,7 @@ export const AppFooter = React.memo(
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -204,135 +204,6 @@ export const easeOut = (k: number) => {
|
|||||||
return 1 - Math.pow(1 - k, 4);
|
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
|
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||||
export const chunk = <T extends any>(
|
export const chunk = <T extends any>(
|
||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { t } from "../i18n";
|
|||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
|
import { constrainScrollState } from "../scroll";
|
||||||
import { getShortcutKey } from "../shortcut";
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
@@ -138,19 +139,20 @@ export const actionZoomIn = register({
|
|||||||
icon: ZoomInIcon,
|
icon: ZoomInIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
|
const nextState = {
|
||||||
|
...appState,
|
||||||
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
|
userToFollow: null,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: { ...nextState, ...constrainScrollState(nextState) },
|
||||||
...appState,
|
|
||||||
...getStateForZoom(
|
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
userToFollow: null,
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -179,19 +181,20 @@ export const actionZoomOut = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
|
const nextState = {
|
||||||
|
...appState,
|
||||||
|
...getStateForZoom(
|
||||||
|
{
|
||||||
|
viewportX: appState.width / 2 + appState.offsetLeft,
|
||||||
|
viewportY: appState.height / 2 + appState.offsetTop,
|
||||||
|
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
|
},
|
||||||
|
appState,
|
||||||
|
),
|
||||||
|
userToFollow: null,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: { ...nextState, ...constrainScrollState(nextState) },
|
||||||
...appState,
|
|
||||||
...getStateForZoom(
|
|
||||||
{
|
|
||||||
viewportX: appState.width / 2 + appState.offsetLeft,
|
|
||||||
viewportY: appState.height / 2 + appState.offsetTop,
|
|
||||||
nextZoom: getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
|
||||||
},
|
|
||||||
appState,
|
|
||||||
),
|
|
||||||
userToFollow: null,
|
|
||||||
},
|
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -219,6 +222,8 @@ export const actionResetZoom = register({
|
|||||||
icon: ZoomResetIcon,
|
icon: ZoomResetIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
// resetting to 100% may violate active scroll constraints
|
||||||
|
predicate: (elements, appState) => !appState.scrollConstraints,
|
||||||
perform: (_elements, appState, _, app) => {
|
perform: (_elements, appState, _, app) => {
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
@@ -243,6 +248,8 @@ export const actionResetZoom = register({
|
|||||||
className="reset-zoom-button zoom-button"
|
className="reset-zoom-button zoom-button"
|
||||||
title={t("buttons.resetZoom")}
|
title={t("buttons.resetZoom")}
|
||||||
aria-label={t("buttons.resetZoom")}
|
aria-label={t("buttons.resetZoom")}
|
||||||
|
// keep the zoom level visible, but non-resettable while constrained
|
||||||
|
disabled={!!appState.scrollConstraints}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateData(null);
|
updateData(null);
|
||||||
}}
|
}}
|
||||||
@@ -251,7 +258,8 @@ export const actionResetZoom = register({
|
|||||||
</ToolButton>
|
</ToolButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
),
|
),
|
||||||
keyTest: (event) =>
|
keyTest: (event, appState) =>
|
||||||
|
!appState.scrollConstraints &&
|
||||||
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
|
||||||
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
|
||||||
});
|
});
|
||||||
@@ -396,6 +404,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
label: "labels.zoomToFitViewport",
|
label: "labels.zoomToFitViewport",
|
||||||
icon: zoomAreaIcon,
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
predicate: (elements, appState) => !appState.scrollConstraints,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return zoomToFit({
|
return zoomToFit({
|
||||||
@@ -410,7 +419,8 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
},
|
},
|
||||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||||
// TBD on how proceed
|
// TBD on how proceed
|
||||||
keyTest: (event) =>
|
keyTest: (event, appState) =>
|
||||||
|
!appState.scrollConstraints &&
|
||||||
event.code === CODES.TWO &&
|
event.code === CODES.TWO &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
@@ -422,6 +432,7 @@ export const actionZoomToFitSelection = register({
|
|||||||
label: "helpDialog.zoomToSelection",
|
label: "helpDialog.zoomToSelection",
|
||||||
icon: zoomAreaIcon,
|
icon: zoomAreaIcon,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
predicate: (elements, appState) => !appState.scrollConstraints,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return zoomToFit({
|
return zoomToFit({
|
||||||
@@ -435,7 +446,8 @@ export const actionZoomToFitSelection = register({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// NOTE this action should use shift-2 per figma, alas
|
// NOTE this action should use shift-2 per figma, alas
|
||||||
keyTest: (event) =>
|
keyTest: (event, appState) =>
|
||||||
|
!appState.scrollConstraints &&
|
||||||
event.code === CODES.THREE &&
|
event.code === CODES.THREE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
@@ -448,6 +460,7 @@ export const actionZoomToFit = register({
|
|||||||
icon: zoomAreaIcon,
|
icon: zoomAreaIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
|
predicate: (elements, appState) => !appState.scrollConstraints,
|
||||||
perform: (elements, appState, _, app) =>
|
perform: (elements, appState, _, app) =>
|
||||||
zoomToFit({
|
zoomToFit({
|
||||||
targetElements: elements,
|
targetElements: elements,
|
||||||
@@ -458,7 +471,8 @@ export const actionZoomToFit = register({
|
|||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
canvasOffsets: app.getEditorUIOffsets(),
|
canvasOffsets: app.getEditorUIOffsets(),
|
||||||
}),
|
}),
|
||||||
keyTest: (event) =>
|
keyTest: (event, appState) =>
|
||||||
|
!appState.scrollConstraints &&
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
scrolledOutside: false,
|
scrolledOutside: false,
|
||||||
scrollX: 0,
|
scrollX: 0,
|
||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
|
scrollConstraints: null,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
hoveredElementIds: {},
|
hoveredElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
@@ -226,6 +227,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
scrolledOutside: { browser: true, export: false, server: false },
|
scrolledOutside: { browser: true, export: false, server: false },
|
||||||
scrollX: { browser: true, export: false, server: false },
|
scrollX: { browser: true, export: false, server: false },
|
||||||
scrollY: { browser: true, export: false, server: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
|
scrollConstraints: { browser: false, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false, server: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
hoveredElementIds: { browser: false, export: false, server: false },
|
hoveredElementIds: { browser: false, export: false, server: false },
|
||||||
selectedGroupIds: { browser: true, export: false, server: false },
|
selectedGroupIds: { browser: true, export: false, server: false },
|
||||||
|
|||||||
@@ -77,11 +77,9 @@ import {
|
|||||||
updateObject,
|
updateObject,
|
||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
easeToValuesRAF,
|
|
||||||
muteFSAbortError,
|
muteFSAbortError,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
easeOut,
|
|
||||||
updateStable,
|
updateStable,
|
||||||
addEventListener,
|
addEventListener,
|
||||||
normalizeEOL,
|
normalizeEOL,
|
||||||
@@ -203,7 +201,6 @@ import {
|
|||||||
cropElement,
|
cropElement,
|
||||||
wrapText,
|
wrapText,
|
||||||
isElementLink,
|
isElementLink,
|
||||||
parseElementLinkFromURL,
|
|
||||||
isMeasureTextSupported,
|
isMeasureTextSupported,
|
||||||
normalizeText,
|
normalizeText,
|
||||||
measureText,
|
measureText,
|
||||||
@@ -264,6 +261,7 @@ import {
|
|||||||
getActiveTextElement,
|
getActiveTextElement,
|
||||||
isEligibleFrameChildType,
|
isEligibleFrameChildType,
|
||||||
getBindingStrategyForDraggingBindingElementEndpoints,
|
getBindingStrategyForDraggingBindingElementEndpoints,
|
||||||
|
parseElementLinkFromURL,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -331,7 +329,7 @@ import {
|
|||||||
actionToggleCropEditor,
|
actionToggleCropEditor,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
|
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||||
import { actionPaste } from "../actions/actionClipboard";
|
import { actionPaste } from "../actions/actionClipboard";
|
||||||
import { actionCopyElementLink } from "../actions/actionElementLink";
|
import { actionCopyElementLink } from "../actions/actionElementLink";
|
||||||
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
import { actionUnlockAllElements } from "../actions/actionElementLock";
|
||||||
@@ -413,6 +411,14 @@ import {
|
|||||||
isGridModeEnabled,
|
isGridModeEnabled,
|
||||||
} from "../snapping";
|
} from "../snapping";
|
||||||
import { Renderer } from "../scene/Renderer";
|
import { Renderer } from "../scene/Renderer";
|
||||||
|
import {
|
||||||
|
type ScrollToContentOptions,
|
||||||
|
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
||||||
|
scrollToElements,
|
||||||
|
constrainScrollState,
|
||||||
|
animateToConstraints,
|
||||||
|
isViewportOverscrolled,
|
||||||
|
} from "../scroll";
|
||||||
import {
|
import {
|
||||||
setEraserCursor,
|
setEraserCursor,
|
||||||
setCursor,
|
setCursor,
|
||||||
@@ -425,16 +431,12 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
|
|||||||
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
|
||||||
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
import { textWysiwyg } from "../wysiwyg/textWysiwyg";
|
||||||
import { isOverScrollBars } from "../scene/scrollbars";
|
import { isOverScrollBars } from "../scene/scrollbars";
|
||||||
|
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
|
|
||||||
import { LassoTrail } from "../lasso";
|
import { LassoTrail } from "../lasso";
|
||||||
|
|
||||||
import { EraserTrail } from "../eraser";
|
import { EraserTrail } from "../eraser";
|
||||||
|
|
||||||
import { getShortcutKey } from "../shortcut";
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
import { tryParseSpreadsheet } from "../charts";
|
import { tryParseSpreadsheet } from "../charts";
|
||||||
|
import { AnimationController } from "../renderer/animation";
|
||||||
|
|
||||||
import ConvertElementTypePopup, {
|
import ConvertElementTypePopup, {
|
||||||
getConversionTypeFromElements,
|
getConversionTypeFromElements,
|
||||||
@@ -609,6 +611,10 @@ const YOUTUBE_VIDEO_STATES = new Map<
|
|||||||
|
|
||||||
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
|
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
|
||||||
|
|
||||||
|
/** how long after the last pan/zoom we animate the rubberband back into the
|
||||||
|
* scroll constraints box */
|
||||||
|
const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200;
|
||||||
|
|
||||||
let IS_PLAIN_PASTE = false;
|
let IS_PLAIN_PASTE = false;
|
||||||
let IS_PLAIN_PASTE_TIMER = 0;
|
let IS_PLAIN_PASTE_TIMER = 0;
|
||||||
let PLAIN_PASTE_TOAST_SHOWN = false;
|
let PLAIN_PASTE_TOAST_SHOWN = false;
|
||||||
@@ -4341,148 +4347,70 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private cancelInProgressAnimation: (() => void) | null = null;
|
|
||||||
|
|
||||||
scrollToContent = (
|
scrollToContent = (
|
||||||
/**
|
target?:
|
||||||
* target to scroll to
|
|
||||||
*
|
|
||||||
* - string - id of element or group, or url containing elementLink
|
|
||||||
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
|
|
||||||
*/
|
|
||||||
target:
|
|
||||||
| string
|
| string
|
||||||
| ExcalidrawElement
|
| ExcalidrawElement
|
||||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
| readonly NonDeletedExcalidrawElement[],
|
||||||
opts?: (
|
opts?: 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;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
|
let elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
if (typeof target === "string") {
|
if (typeof target === "string") {
|
||||||
let id: string | null;
|
const id = isElementLink(target)
|
||||||
if (isElementLink(target)) {
|
? parseElementLinkFromURL(target)
|
||||||
id = parseElementLinkFromURL(target);
|
: target;
|
||||||
} else {
|
elements = id ? this.scene.getElementsFromId(id) : [];
|
||||||
id = target;
|
} else if (Array.isArray(target)) {
|
||||||
}
|
elements = target;
|
||||||
if (id) {
|
} else if (target) {
|
||||||
const elements = this.scene.getElementsFromId(id);
|
elements = [target as NonDeleted<ExcalidrawElement>];
|
||||||
|
} else {
|
||||||
|
elements = this.scene.getNonDeletedElements();
|
||||||
|
}
|
||||||
|
|
||||||
if (elements?.length) {
|
const scrollConstraints = opts?.scrollConstraints ?? null;
|
||||||
this.scrollToContent(elements, {
|
if (scrollConstraints || this.state.scrollConstraints) {
|
||||||
fitToContent: opts?.fitToContent ?? true,
|
flushSync(() => {
|
||||||
animate: opts?.animate ?? true,
|
this.setState((prevState) => ({
|
||||||
});
|
scrollConstraints,
|
||||||
} else if (isElementLink(target)) {
|
...constrainScrollState({ ...prevState, scrollConstraints }),
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cancelInProgressAnimation?.();
|
// Navigating to an element by id or element-link defaults to zooming the
|
||||||
|
// element into view, animated — matching the historical element-link
|
||||||
// convert provided target into ExcalidrawElement[] if necessary
|
// behavior — unless the caller opts out.
|
||||||
const targetElements = Array.isArray(target) ? target : [target];
|
const resolvedOpts =
|
||||||
|
typeof target === "string"
|
||||||
let zoom = this.state.zoom;
|
? {
|
||||||
let scrollX = this.state.scrollX;
|
...opts,
|
||||||
let scrollY = this.state.scrollY;
|
fitToViewport: undefined,
|
||||||
|
fitToContent: opts?.fitToContent ?? true,
|
||||||
if (opts?.fitToContent || opts?.fitToViewport) {
|
animate: opts?.animate ?? true,
|
||||||
const { appState } = zoomToFit({
|
|
||||||
canvasOffsets: opts.canvasOffsets,
|
|
||||||
targetElements,
|
|
||||||
appState: this.state,
|
|
||||||
fitToViewport: !!opts?.fitToViewport,
|
|
||||||
viewportZoomFactor: opts?.viewportZoomFactor,
|
|
||||||
minZoom: opts?.minZoom,
|
|
||||||
maxZoom: opts?.maxZoom,
|
|
||||||
});
|
|
||||||
zoom = appState.zoom;
|
|
||||||
scrollX = appState.scrollX;
|
|
||||||
scrollY = appState.scrollY;
|
|
||||||
} else {
|
|
||||||
// compute only the viewport location, without any zoom adjustment
|
|
||||||
const scroll = calculateScrollCenter(targetElements, this.state);
|
|
||||||
scrollX = scroll.scrollX;
|
|
||||||
scrollY = scroll.scrollY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// when animating, we use RequestAnimationFrame to prevent the animation
|
|
||||||
// from slowing down other processes
|
|
||||||
if (opts?.animate) {
|
|
||||||
const origScrollX = this.state.scrollX;
|
|
||||||
const origScrollY = this.state.scrollY;
|
|
||||||
const origZoom = this.state.zoom.value;
|
|
||||||
|
|
||||||
const cancel = easeToValuesRAF({
|
|
||||||
fromValues: {
|
|
||||||
scrollX: origScrollX,
|
|
||||||
scrollY: origScrollY,
|
|
||||||
zoom: origZoom,
|
|
||||||
},
|
|
||||||
toValues: { scrollX, scrollY, zoom: zoom.value },
|
|
||||||
interpolateValue: (from, to, progress, key) => {
|
|
||||||
// for zoom, use different easing
|
|
||||||
if (key === "zoom") {
|
|
||||||
return from * Math.pow(to / from, easeOut(progress));
|
|
||||||
}
|
}
|
||||||
// handle using default
|
: opts;
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
onStep: ({ scrollX, scrollY, zoom }) => {
|
|
||||||
this.setState({
|
|
||||||
scrollX,
|
|
||||||
scrollY,
|
|
||||||
zoom: { value: zoom },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onStart: () => {
|
|
||||||
this.setState({ shouldCacheIgnoreZoom: true });
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
this.setState({ shouldCacheIgnoreZoom: false });
|
|
||||||
},
|
|
||||||
onCancel: () => {
|
|
||||||
this.setState({ shouldCacheIgnoreZoom: false });
|
|
||||||
},
|
|
||||||
duration: opts?.duration ?? 500,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.cancelInProgressAnimation = () => {
|
scrollToElements(
|
||||||
cancel();
|
this.state,
|
||||||
this.cancelInProgressAnimation = null;
|
elements,
|
||||||
};
|
this.setState.bind(this),
|
||||||
} else {
|
resolvedOpts,
|
||||||
this.setState({ scrollX, scrollY, zoom });
|
);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeUnfollowRemoteUser = () => {
|
private maybeUnfollowRemoteUser = () => {
|
||||||
@@ -4495,11 +4423,43 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||||
state,
|
state,
|
||||||
) => {
|
) => {
|
||||||
this.cancelInProgressAnimation?.();
|
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
this.setState(state);
|
this.setState(state);
|
||||||
|
|
||||||
|
// with rubberband tolerance, allow a soft overscroll while interacting and
|
||||||
|
// animate back to the box once the interaction settles; otherwise hard-clamp
|
||||||
|
const tolerance = this.state.scrollConstraints?.tolerance ?? 0;
|
||||||
|
this.constrainViewportToScrollConstraints(tolerance);
|
||||||
|
if (tolerance > 0) {
|
||||||
|
this.snapBackToScrollConstraintsDebounced();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
|
||||||
|
* unconstrained). Runs as a queued update, so it sees the preceding change.
|
||||||
|
* `tolerance` (screen px) relaxes the bounds for rubberbanding. */
|
||||||
|
private constrainViewportToScrollConstraints = (tolerance = 0) => {
|
||||||
|
this.setState((prevState) =>
|
||||||
|
prevState.scrollConstraints
|
||||||
|
? constrainScrollState(prevState, tolerance)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** animates an overscrolled viewport back inside the constraint box */
|
||||||
|
private snapBackToScrollConstraints = () => {
|
||||||
|
if (!this.unmounted) {
|
||||||
|
animateToConstraints(this.state, (viewport) => this.setState(viewport));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private snapBackToScrollConstraintsDebounced = debounce(
|
||||||
|
this.snapBackToScrollConstraints,
|
||||||
|
SCROLL_CONSTRAINTS_SNAP_BACK_DELAY,
|
||||||
|
);
|
||||||
|
|
||||||
setToast = (toast: AppState["toast"]) => {
|
setToast = (toast: AppState["toast"]) => {
|
||||||
this.setState({ toast });
|
this.setState({ toast });
|
||||||
};
|
};
|
||||||
@@ -5678,9 +5638,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// while rubberband-overscrolled past the scroll constraints, suppress
|
||||||
|
// zooming until the viewport has snapped back inside the box
|
||||||
|
if (isViewportOverscrolled(this.state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const initialScale = gesture.initialScale;
|
const initialScale = gesture.initialScale;
|
||||||
if (initialScale) {
|
if (initialScale) {
|
||||||
this.setState((state) => ({
|
this.translateCanvas((state) => ({
|
||||||
...getStateForZoom(
|
...getStateForZoom(
|
||||||
{
|
{
|
||||||
viewportX: this.lastViewportPosition.x,
|
viewportX: this.lastViewportPosition.x,
|
||||||
@@ -6928,7 +6894,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
? 1
|
? 1
|
||||||
: distance / gesture.initialDistance;
|
: distance / gesture.initialDistance;
|
||||||
|
|
||||||
const nextZoom = scaleFactor
|
// while rubberband-overscrolled past the scroll constraints, pin the zoom
|
||||||
|
// (still allowing the pan below) until the viewport has snapped back
|
||||||
|
const nextZoom = isViewportOverscrolled(this.state)
|
||||||
|
? this.state.zoom.value
|
||||||
|
: scaleFactor
|
||||||
? getNormalizedZoom(initialScale * scaleFactor)
|
? getNormalizedZoom(initialScale * scaleFactor)
|
||||||
: this.state.zoom.value;
|
: this.state.zoom.value;
|
||||||
|
|
||||||
@@ -12822,6 +12792,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const { deltaX, deltaY } = event;
|
const { deltaX, deltaY } = event;
|
||||||
// note that event.ctrlKey is necessary to handle pinch zooming
|
// note that event.ctrlKey is necessary to handle pinch zooming
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
|
// while rubberband-overscrolled past the scroll constraints, suppress
|
||||||
|
// zooming until the viewport has snapped back inside the box
|
||||||
|
if (isViewportOverscrolled(this.state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sign = Math.sign(deltaY);
|
const sign = Math.sign(deltaY);
|
||||||
const MAX_STEP = ZOOM_STEP * 100;
|
const MAX_STEP = ZOOM_STEP * 100;
|
||||||
const absDelta = Math.abs(deltaY);
|
const absDelta = Math.abs(deltaY);
|
||||||
@@ -12975,6 +12951,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
cb && cb();
|
cb && cb();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// a smaller viewport may push the min zoom up / shrink the pan range
|
||||||
|
this.constrainViewportToScrollConstraints();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,38 @@ type RestoredAppState = Omit<
|
|||||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
"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 = (
|
const restoreLinearElementPoints = (
|
||||||
points: unknown,
|
points: unknown,
|
||||||
@@ -560,7 +591,7 @@ export const restoreElement = (
|
|||||||
} as ExcalidrawLinearElement));
|
} as ExcalidrawLinearElement));
|
||||||
}
|
}
|
||||||
|
|
||||||
return restoreElementWithProperties(element, {
|
const restoredLine = restoreElementWithProperties(element, {
|
||||||
type: "line",
|
type: "line",
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
endBinding: null,
|
endBinding: null,
|
||||||
@@ -578,6 +609,8 @@ export const restoreElement = (
|
|||||||
: {}),
|
: {}),
|
||||||
...getSizeFromPoints(points),
|
...getSizeFromPoints(points),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return handleOversizedLinearElements(restoredLine);
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
const startArrowhead = normalizeArrowhead(element.startArrowhead);
|
||||||
const endArrowhead =
|
const endArrowhead =
|
||||||
@@ -644,37 +677,7 @@ export const restoreElement = (
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Last resort fix for extremely large arrows
|
return handleOversizedLinearElements(normalizedRestoredElement);
|
||||||
if (
|
|
||||||
normalizedRestoredElement.width > MAX_ARROW_PX ||
|
|
||||||
normalizedRestoredElement.height > MAX_ARROW_PX
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
`Removing extremely large arrow ${
|
|
||||||
normalizedRestoredElement.id
|
|
||||||
} (type: ${
|
|
||||||
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
|
|
||||||
}, width: ${normalizedRestoredElement.width}, height: ${
|
|
||||||
normalizedRestoredElement.height
|
|
||||||
}, x: ${normalizedRestoredElement.x}, y: ${
|
|
||||||
normalizedRestoredElement.y
|
|
||||||
})`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...normalizedRestoredElement,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
points: [
|
|
||||||
pointFrom<LocalPoint>(0, 0),
|
|
||||||
pointFrom<LocalPoint>(100, 100),
|
|
||||||
],
|
|
||||||
isDeleted: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedRestoredElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic elements
|
// generic elements
|
||||||
|
|||||||
@@ -123,4 +123,9 @@ export class AnimationController {
|
|||||||
AnimationController.animations.delete(key);
|
AnimationController.animations.delete(key);
|
||||||
AnimationController.cancelScheduledFrameIfIdle();
|
AnimationController.cancelScheduledFrameIfIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static reset() {
|
||||||
|
AnimationController.animations.clear();
|
||||||
|
AnimationController.cancelScheduledFrame();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { easeOut, MAX_ZOOM, MIN_ZOOM } from "@excalidraw/common";
|
||||||
|
import { clamp } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import { zoomToFit } from "./actions/actionCanvas";
|
||||||
|
import { AnimationController } from "./renderer/animation";
|
||||||
|
import { getNormalizedZoom } from "./scene";
|
||||||
|
import { calculateScrollCenter } from "./scene/scroll";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
NormalizedZoomValue,
|
||||||
|
Offsets,
|
||||||
|
ScrollConstraints,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const SCROLL_TO_CONTENT_ANIMATION_KEY = "animateScrollToContent";
|
||||||
|
|
||||||
|
/** default duration of the scroll/zoom animation, in milliseconds */
|
||||||
|
const DEFAULT_ANIMATION_DURATION = 500;
|
||||||
|
|
||||||
|
export type ScrollToContentOptions = (
|
||||||
|
| {
|
||||||
|
fitToContent?: boolean;
|
||||||
|
fitToViewport?: never;
|
||||||
|
viewportZoomFactor?: number;
|
||||||
|
animate?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
fitToContent?: never;
|
||||||
|
fitToViewport?: boolean;
|
||||||
|
/** when fitToViewport=true, how much screen should the content cover,
|
||||||
|
* between 0.1 (10%) and 1 (100%) */
|
||||||
|
viewportZoomFactor?: number;
|
||||||
|
animate?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
) & {
|
||||||
|
minZoom?: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
canvasOffsets?: Offsets;
|
||||||
|
scrollConstraints?: ScrollConstraints | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Viewport = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest zoom at which the constraint box still fully covers the viewport.
|
||||||
|
* Below this, the viewport would extend past the box on its binding dimension,
|
||||||
|
* so it becomes the enforced minimum zoom. When the box is smaller than the
|
||||||
|
* viewport this is > 1, forcing a zoom-in (best-effort fit).
|
||||||
|
*/
|
||||||
|
export const getMinZoomForConstraints = (
|
||||||
|
constraints: ScrollConstraints,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
): number =>
|
||||||
|
clamp(
|
||||||
|
Math.max(
|
||||||
|
viewport.width / constraints.width,
|
||||||
|
viewport.height / constraints.height,
|
||||||
|
),
|
||||||
|
MIN_ZOOM,
|
||||||
|
MAX_ZOOM,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps a single scroll axis so the visible scene span stays inside the box.
|
||||||
|
* The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within
|
||||||
|
* `[boxStart, boxStart + boxSize]`, expanded by `startExpand` at the low edge
|
||||||
|
* and `endExpand` at the high edge (rubberband overscroll plus any padding).
|
||||||
|
* When the box can't cover the viewport on this axis (only at the MAX_ZOOM cap
|
||||||
|
* for a tiny box) we center the box instead.
|
||||||
|
*/
|
||||||
|
const constrainScrollAxis = (
|
||||||
|
scroll: number,
|
||||||
|
boxStart: number,
|
||||||
|
boxSize: number,
|
||||||
|
visibleSize: number,
|
||||||
|
startExpand: number,
|
||||||
|
endExpand: number,
|
||||||
|
): number => {
|
||||||
|
const max = -boxStart + startExpand;
|
||||||
|
const min = visibleSize - (boxStart + boxSize) - endExpand;
|
||||||
|
return min > max ? (min + max) / 2 : clamp(scroll, min, max);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps a proposed scroll/zoom so that, when `scrollConstraints` is set, the
|
||||||
|
* viewport cannot pan or zoom out of the box. Returns the input scroll/zoom
|
||||||
|
* unchanged when there are no constraints. Because the whole viewport is kept
|
||||||
|
* inside the box, any zoom anchor (which lives within the viewport) is also
|
||||||
|
* guaranteed to stay inside the box.
|
||||||
|
*
|
||||||
|
* `tolerance` is the rubberband overscroll allowance, in screen pixels: the
|
||||||
|
* viewport may pan past the box edges (and zoom out below the fit zoom) until
|
||||||
|
* each edge has crossed the box by up to `tolerance` pixels on screen,
|
||||||
|
* independent of the current zoom. Pass `0` (default) for a hard clamp.
|
||||||
|
*
|
||||||
|
* An explicit `minZoom`/`maxZoom` on the constraints takes precedence over the
|
||||||
|
* zoom the box would otherwise enforce: `minZoom` replaces the fit zoom (so the
|
||||||
|
* viewport may zoom out past the box) and `maxZoom` replaces the global
|
||||||
|
* `MAX_ZOOM` cap.
|
||||||
|
*
|
||||||
|
* `padding` ([top, right, bottom, left], screen px) permanently extends the
|
||||||
|
* scrollable area past each box edge by that many on-screen pixels, so the
|
||||||
|
* viewport can reveal that much empty space beyond the box. Unlike `tolerance`
|
||||||
|
* it does not snap back.
|
||||||
|
*/
|
||||||
|
export const constrainScrollState = (
|
||||||
|
state: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
||||||
|
>,
|
||||||
|
tolerance = 0,
|
||||||
|
): Viewport => {
|
||||||
|
const { scrollConstraints, width, height } = state;
|
||||||
|
|
||||||
|
if (!scrollConstraints) {
|
||||||
|
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
tolerance = Math.max(tolerance, 0);
|
||||||
|
|
||||||
|
// zoom bounds: an explicit min/maxZoom on the constraints takes precedence
|
||||||
|
// over the box-enforced fit zoom (lower) and the global MAX_ZOOM (upper).
|
||||||
|
// Without a `minZoom`, relax the fit zoom by `tolerance` so the user can
|
||||||
|
// briefly zoom out past it (rubberbanding), letting each viewport edge cross
|
||||||
|
// the box by up to `tolerance` screen px.
|
||||||
|
const minZoom =
|
||||||
|
scrollConstraints.minZoom ??
|
||||||
|
getMinZoomForConstraints(scrollConstraints, {
|
||||||
|
width: width - 2 * tolerance,
|
||||||
|
height: height - 2 * tolerance,
|
||||||
|
});
|
||||||
|
const maxZoom = scrollConstraints.maxZoom ?? MAX_ZOOM;
|
||||||
|
const zoomValue = getNormalizedZoom(
|
||||||
|
clamp(state.zoom.value, minZoom, maxZoom),
|
||||||
|
);
|
||||||
|
|
||||||
|
// `tolerance`/`padding` are screen px; express them in scene coords at the
|
||||||
|
// current zoom so the on-screen amount stays the same regardless of zoom.
|
||||||
|
const overscroll = tolerance / zoomValue;
|
||||||
|
const [padTop, padRight, padBottom, padLeft] = (
|
||||||
|
scrollConstraints.padding ?? [0, 0, 0, 0]
|
||||||
|
).map((px) => px / zoomValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollX: constrainScrollAxis(
|
||||||
|
state.scrollX,
|
||||||
|
scrollConstraints.x,
|
||||||
|
scrollConstraints.width,
|
||||||
|
width / zoomValue,
|
||||||
|
overscroll + padLeft,
|
||||||
|
overscroll + padRight,
|
||||||
|
),
|
||||||
|
scrollY: constrainScrollAxis(
|
||||||
|
state.scrollY,
|
||||||
|
scrollConstraints.y,
|
||||||
|
scrollConstraints.height,
|
||||||
|
height / zoomValue,
|
||||||
|
overscroll + padTop,
|
||||||
|
overscroll + padBottom,
|
||||||
|
),
|
||||||
|
zoom: { value: zoomValue },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the viewport currently sits outside the hard constraint bounds, i.e.
|
||||||
|
* the user is rubberband-overscrolled (panned past an edge or zoomed out below
|
||||||
|
* the fit zoom). Always `false` when there are no constraints. Used to suppress
|
||||||
|
* zooming until the viewport has settled back inside the box.
|
||||||
|
*/
|
||||||
|
export const isViewportOverscrolled = (
|
||||||
|
state: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
||||||
|
>,
|
||||||
|
): boolean => {
|
||||||
|
if (!state.scrollConstraints) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = constrainScrollState(state); // hard clamp (tolerance 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
target.scrollX !== state.scrollX ||
|
||||||
|
target.scrollY !== state.scrollY ||
|
||||||
|
target.zoom.value !== state.zoom.value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rubberband snap-back: animates the viewport from its current (possibly
|
||||||
|
* overscrolled) position back inside the constraint box via the shared
|
||||||
|
* AnimationController. No-op when already within the hard bounds (or when there
|
||||||
|
* are no constraints).
|
||||||
|
*/
|
||||||
|
export const animateToConstraints = (
|
||||||
|
state: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
||||||
|
>,
|
||||||
|
onFrame: (
|
||||||
|
viewport: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
|
||||||
|
>,
|
||||||
|
) => void,
|
||||||
|
duration = DEFAULT_ANIMATION_DURATION,
|
||||||
|
) => {
|
||||||
|
const target = constrainScrollState(state); // hard clamp (tolerance 0)
|
||||||
|
|
||||||
|
if (
|
||||||
|
target.scrollX === state.scrollX &&
|
||||||
|
target.scrollY === state.scrollY &&
|
||||||
|
target.zoom.value === state.zoom.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animateToViewport(state, target, duration, onFrame);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls (and optionally zooms) the viewport so that the given target is in
|
||||||
|
* view, optionally animating the transition.
|
||||||
|
*/
|
||||||
|
export const scrollToElements = (
|
||||||
|
state: AppState,
|
||||||
|
target: readonly ExcalidrawElement[],
|
||||||
|
onFrame: (
|
||||||
|
state: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
|
||||||
|
>,
|
||||||
|
) => void,
|
||||||
|
opts?: ScrollToContentOptions,
|
||||||
|
) => {
|
||||||
|
AnimationController.cancel(SCROLL_TO_CONTENT_ANIMATION_KEY);
|
||||||
|
|
||||||
|
const viewport = getTargetViewport(state, target, opts);
|
||||||
|
|
||||||
|
if (opts?.animate) {
|
||||||
|
animateToViewport(
|
||||||
|
state,
|
||||||
|
viewport,
|
||||||
|
opts.duration ?? DEFAULT_ANIMATION_DURATION,
|
||||||
|
onFrame,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// no animation: jump straight to the target. Re-enable zoom caching in
|
||||||
|
// case we just cancelled an in-flight animation that had suppressed it.
|
||||||
|
onFrame({ ...viewport, shouldCacheIgnoreZoom: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Computes the viewport (scroll + zoom) that brings the target elements into
|
||||||
|
* view, based on the requested fit behavior. */
|
||||||
|
const getTargetViewport = (
|
||||||
|
state: AppState,
|
||||||
|
targetElements: readonly ExcalidrawElement[],
|
||||||
|
opts?: ScrollToContentOptions,
|
||||||
|
): Viewport => {
|
||||||
|
let viewport: Viewport;
|
||||||
|
|
||||||
|
if (opts?.fitToContent || opts?.fitToViewport) {
|
||||||
|
const { appState } = zoomToFit({
|
||||||
|
canvasOffsets: opts.canvasOffsets,
|
||||||
|
targetElements,
|
||||||
|
appState: state,
|
||||||
|
fitToViewport: !!opts.fitToViewport,
|
||||||
|
viewportZoomFactor: opts.viewportZoomFactor,
|
||||||
|
minZoom: opts.minZoom,
|
||||||
|
maxZoom: opts.maxZoom,
|
||||||
|
});
|
||||||
|
|
||||||
|
viewport = {
|
||||||
|
scrollX: appState.scrollX,
|
||||||
|
scrollY: appState.scrollY,
|
||||||
|
zoom: appState.zoom,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// keep the current zoom, only recenter the viewport on the target
|
||||||
|
const { scrollX, scrollY } = calculateScrollCenter(targetElements, state);
|
||||||
|
viewport = { scrollX, scrollY, zoom: state.zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep the target inside any active scroll constraints (no-op otherwise)
|
||||||
|
return constrainScrollState({ ...state, ...viewport });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolates the viewport from `from` to `target` at the (already-eased)
|
||||||
|
* blend amount `factor` (0 = `from`, 1 = `target`).
|
||||||
|
*
|
||||||
|
* Zoom is interpolated geometrically (so it feels uniform), and rather than
|
||||||
|
* tweening scrollX/scrollY directly we tween the *focal point* — the scene
|
||||||
|
* point under the viewport center — and derive scroll from it. Mixing a linear
|
||||||
|
* scroll with a geometric zoom makes the focal point swoop sideways
|
||||||
|
* mid-animation (most visible when zooming out); gliding the focal point keeps
|
||||||
|
* it steady. `width/2/zoom - scroll` is the inverse of `centerScrollOn` without
|
||||||
|
* offsets, so factor 0/1 land exactly on `from`/`target`.
|
||||||
|
*/
|
||||||
|
export const interpolateViewport = ({
|
||||||
|
from,
|
||||||
|
target,
|
||||||
|
factor,
|
||||||
|
}: {
|
||||||
|
from: Pick<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">;
|
||||||
|
target: Viewport;
|
||||||
|
factor: number;
|
||||||
|
}): Viewport => {
|
||||||
|
const zoom = (from.zoom.value *
|
||||||
|
Math.pow(
|
||||||
|
target.zoom.value / from.zoom.value,
|
||||||
|
factor,
|
||||||
|
)) as NormalizedZoomValue;
|
||||||
|
|
||||||
|
const fromCenterX = from.width / 2 / from.zoom.value - from.scrollX;
|
||||||
|
const fromCenterY = from.height / 2 / from.zoom.value - from.scrollY;
|
||||||
|
const toCenterX = from.width / 2 / target.zoom.value - target.scrollX;
|
||||||
|
const toCenterY = from.height / 2 / target.zoom.value - target.scrollY;
|
||||||
|
|
||||||
|
const centerX = fromCenterX + (toCenterX - fromCenterX) * factor;
|
||||||
|
const centerY = fromCenterY + (toCenterY - fromCenterY) * factor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollX: from.width / 2 / zoom - centerX,
|
||||||
|
scrollY: from.height / 2 / zoom - centerY,
|
||||||
|
zoom: { value: zoom },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eases the viewport from its current position to `target` over `duration`,
|
||||||
|
* driving the transition through the shared AnimationController so it doesn't
|
||||||
|
* slow down other processes. */
|
||||||
|
const animateToViewport = (
|
||||||
|
from: Pick<AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height">,
|
||||||
|
target: Viewport,
|
||||||
|
duration: number,
|
||||||
|
onFrame: (
|
||||||
|
state: Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom"
|
||||||
|
>,
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
|
AnimationController.start<{ elapsed: number }>(
|
||||||
|
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
||||||
|
({ deltaTime, state }) => {
|
||||||
|
const elapsed = (state?.elapsed ?? 0) + deltaTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const factor = easeOut(clamp(progress, 0, 1));
|
||||||
|
|
||||||
|
onFrame({
|
||||||
|
...interpolateViewport({ from, target, factor }),
|
||||||
|
shouldCacheIgnoreZoom: progress < 1, // ignore zoom caching while animating
|
||||||
|
});
|
||||||
|
|
||||||
|
return progress < 1 ? { elapsed } : null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -962,6 +962,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -1159,6 +1160,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": true,
|
"scrolledOutside": true,
|
||||||
@@ -1374,6 +1376,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -1706,6 +1709,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2038,6 +2042,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": true,
|
"scrolledOutside": true,
|
||||||
@@ -2253,6 +2258,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2495,6 +2501,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2796,6 +2803,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3167,6 +3175,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3688,6 +3697,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -4012,6 +4022,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -4338,6 +4349,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -5624,6 +5636,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -6842,6 +6855,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -7801,6 +7815,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8799,6 +8814,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": true,
|
"scrolledOutside": true,
|
||||||
@@ -9794,6 +9810,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"id4": true,
|
"id4": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -721,6 +722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"id4": true,
|
"id4": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -1285,6 +1287,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -1646,6 +1649,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -2009,6 +2013,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -2270,6 +2275,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -2761,6 +2767,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -3065,6 +3072,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -3385,6 +3393,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -3680,6 +3689,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -3967,6 +3977,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -4203,6 +4214,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -4461,6 +4473,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -4733,6 +4746,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -4963,6 +4977,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -5193,6 +5208,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -5441,6 +5457,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -5698,6 +5715,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -5956,6 +5974,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -6286,6 +6305,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"id8": true,
|
"id8": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -6714,6 +6734,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -7090,6 +7111,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -7400,6 +7422,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -7694,6 +7717,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -7925,6 +7949,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -8278,6 +8303,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -8634,6 +8660,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -9038,6 +9065,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -9326,6 +9354,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -9591,6 +9620,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -9857,6 +9887,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -10093,6 +10124,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -10388,6 +10420,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -10707,6 +10740,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -10947,6 +10981,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -11870,6 +11905,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -12131,6 +12167,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -12367,6 +12404,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -12605,6 +12643,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -13012,6 +13051,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -13220,6 +13260,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -13431,6 +13472,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -13730,6 +13772,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -14032,6 +14075,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": -50,
|
"scrollX": -50,
|
||||||
"scrollY": -50,
|
"scrollY": -50,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -14275,6 +14319,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -14513,6 +14558,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -14753,6 +14799,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -14999,6 +15046,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -15334,6 +15382,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -15502,6 +15551,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -15790,6 +15840,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -16054,6 +16105,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -16207,6 +16259,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -16491,6 +16544,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -16653,6 +16707,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -17403,6 +17458,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -18051,6 +18107,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -18697,6 +18754,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -19450,6 +19508,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -20220,6 +20279,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -20701,6 +20761,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||||||
"id1": true,
|
"id1": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -21213,6 +21274,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
@@ -21673,6 +21735,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"searchMatches": null,
|
"searchMatches": null,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -516,6 +517,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -929,6 +931,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -1496,6 +1499,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -1706,6 +1710,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2091,6 +2096,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2335,6 +2341,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2518,6 +2525,7 @@ exports[`regression tests > can drag element that covers another element, while
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -2842,6 +2850,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3100,6 +3109,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3342,6 +3352,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3579,6 +3590,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -3838,6 +3850,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -4151,6 +4164,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -4591,6 +4605,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -4875,6 +4890,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -5151,6 +5167,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -5360,6 +5377,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -5559,6 +5577,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -5956,6 +5975,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -6251,6 +6271,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -7048,6 +7069,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -7382,6 +7404,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -7662,6 +7685,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -7898,6 +7922,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8137,6 +8162,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8318,6 +8344,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8499,6 +8526,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8680,6 +8708,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -8914,6 +8943,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -9146,6 +9176,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -9343,6 +9374,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -9577,6 +9609,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -9758,6 +9791,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -9990,6 +10024,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -10171,6 +10206,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -10368,6 +10404,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -10553,6 +10590,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -11083,6 +11121,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -11362,6 +11401,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": "-6.25000",
|
"scrollX": "-6.25000",
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -11488,6 +11528,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -11690,6 +11731,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -12011,6 +12053,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -12442,6 +12485,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -13081,6 +13125,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 60,
|
"scrollX": 60,
|
||||||
"scrollY": 60,
|
"scrollY": 60,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -13207,6 +13252,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
|||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -13840,6 +13886,7 @@ exports[`regression tests > switches from group of selected elements to another
|
|||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -14179,6 +14226,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
|||||||
"id3": true,
|
"id3": true,
|
||||||
},
|
},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -14442,6 +14490,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 20,
|
"scrollX": 20,
|
||||||
"scrollY": "-18.53553",
|
"scrollY": "-18.53553",
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -14566,6 +14615,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -14932,6 +14982,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
@@ -15059,6 +15110,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
|
|||||||
@@ -526,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", () => {
|
it("when the number of points of a line is greater or equal 2", () => {
|
||||||
const lineElement_0 = API.createElement({
|
const lineElement_0 = API.createElement({
|
||||||
type: "line",
|
type: "line",
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
|
import { AnimationController } from "../renderer/animation";
|
||||||
|
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../scroll";
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { act, render } from "./test-utils";
|
import { act, render } from "./test-utils";
|
||||||
|
|
||||||
const { h } = window;
|
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(
|
return act(
|
||||||
() =>
|
() =>
|
||||||
new Promise((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
requestAnimationFrame(() => {
|
let remaining = frames;
|
||||||
requestAnimationFrame(resolve);
|
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);
|
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 () => {
|
it("should scroll the viewport to the selected element", async () => {
|
||||||
await render(<Excalidraw />);
|
await render(<Excalidraw />);
|
||||||
|
|
||||||
@@ -109,11 +179,16 @@ describe("fitToContent", () => {
|
|||||||
|
|
||||||
describe("fitToContent animated", () => {
|
describe("fitToContent animated", () => {
|
||||||
beforeEach(() => {
|
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(() => {
|
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 () => {
|
it("should ease scroll the viewport to the selected element", async () => {
|
||||||
@@ -130,17 +205,18 @@ describe("fitToContent animated", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.app.scrollToContent(rectElement, { animate: true });
|
h.app.scrollToContent(rectElement, {
|
||||||
|
animate: true,
|
||||||
|
duration: LONG_ANIMATION_DURATION,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(window.requestAnimationFrame).toHaveBeenCalled();
|
// the animation hasn't progressed yet, so we're still at the origin
|
||||||
|
|
||||||
// Since this is an animation, we expect values to change through time.
|
|
||||||
// We'll verify that the scroll values change at 50ms and 100ms
|
|
||||||
expect(h.state.scrollX).toBe(0);
|
expect(h.state.scrollX).toBe(0);
|
||||||
expect(h.state.scrollY).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 prevScrollX = h.state.scrollX;
|
||||||
const prevScrollY = h.state.scrollY;
|
const prevScrollY = h.state.scrollY;
|
||||||
@@ -148,7 +224,7 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollX).not.toBe(0);
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
expect(h.state.scrollY).not.toBe(0);
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
await waitForAnimationProgress();
|
||||||
|
|
||||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
expect(h.state.scrollY).not.toBe(prevScrollY);
|
||||||
@@ -171,12 +247,14 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollY).toBe(0);
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
act(() => {
|
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 waitForAnimationProgress();
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
|
||||||
|
|
||||||
const prevScrollX = h.state.scrollX;
|
const prevScrollX = h.state.scrollX;
|
||||||
const prevScrollY = h.state.scrollY;
|
const prevScrollY = h.state.scrollY;
|
||||||
@@ -184,9 +262,48 @@ describe("fitToContent animated", () => {
|
|||||||
expect(h.state.scrollX).not.toBe(0);
|
expect(h.state.scrollX).not.toBe(0);
|
||||||
expect(h.state.scrollY).not.toBe(0);
|
expect(h.state.scrollY).not.toBe(0);
|
||||||
|
|
||||||
await waitForNextAnimationFrame();
|
await waitForAnimationProgress();
|
||||||
|
|
||||||
expect(h.state.scrollX).not.toBe(prevScrollX);
|
expect(h.state.scrollX).not.toBe(prevScrollX);
|
||||||
expect(h.state.scrollY).not.toBe(prevScrollY);
|
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,528 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
import {
|
||||||
|
actionResetZoom,
|
||||||
|
actionZoomToFit,
|
||||||
|
actionZoomToFitSelection,
|
||||||
|
} from "../actions/actionCanvas";
|
||||||
|
import { getNormalizedZoom } from "../scene";
|
||||||
|
import {
|
||||||
|
animateToConstraints,
|
||||||
|
constrainScrollState,
|
||||||
|
getMinZoomForConstraints,
|
||||||
|
isViewportOverscrolled,
|
||||||
|
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
||||||
|
} from "../scroll";
|
||||||
|
import { AnimationController } from "../renderer/animation";
|
||||||
|
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { Keyboard } from "./helpers/ui";
|
||||||
|
import {
|
||||||
|
mockBoundingClientRect,
|
||||||
|
render,
|
||||||
|
restoreOriginalGetBoundingClientRect,
|
||||||
|
waitFor,
|
||||||
|
} from "./test-utils";
|
||||||
|
|
||||||
|
import type { AppState, ScrollConstraints } from "../types";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
// `mockBoundingClientRect()` makes the viewport 200 x 100
|
||||||
|
const VIEWPORT = { width: 200, height: 100 };
|
||||||
|
|
||||||
|
const makeState = (
|
||||||
|
overrides: Partial<AppState> & {
|
||||||
|
scrollConstraints: ScrollConstraints | null;
|
||||||
|
},
|
||||||
|
): Pick<
|
||||||
|
AppState,
|
||||||
|
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
||||||
|
> => ({
|
||||||
|
scrollX: 0,
|
||||||
|
scrollY: 0,
|
||||||
|
zoom: { value: getNormalizedZoom(1) },
|
||||||
|
width: VIEWPORT.width,
|
||||||
|
height: VIEWPORT.height,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("constrainScrollState (pure)", () => {
|
||||||
|
it("returns scroll/zoom unchanged when there are no constraints", () => {
|
||||||
|
const state = makeState({
|
||||||
|
scrollX: 123,
|
||||||
|
scrollY: -45,
|
||||||
|
zoom: { value: getNormalizedZoom(0.5) },
|
||||||
|
scrollConstraints: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(constrainScrollState(state)).toEqual({
|
||||||
|
scrollX: 123,
|
||||||
|
scrollY: -45,
|
||||||
|
zoom: { value: getNormalizedZoom(0.5) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("box larger than the viewport", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("clamps scroll so the viewport can't leave the box", () => {
|
||||||
|
// pan past the box's top-left corner → clamp to the corner (scroll 0)
|
||||||
|
const corner = constrainScrollState(
|
||||||
|
makeState({ scrollX: 100, scrollY: 100, scrollConstraints: box }),
|
||||||
|
);
|
||||||
|
expect(corner.scrollX).toBeCloseTo(0);
|
||||||
|
expect(corner.scrollY).toBeCloseTo(0);
|
||||||
|
|
||||||
|
// pan past the box's far edges → clamp to width/height - boxSize
|
||||||
|
const farEdge = constrainScrollState(
|
||||||
|
makeState({ scrollX: -5000, scrollY: -5000, scrollConstraints: box }),
|
||||||
|
);
|
||||||
|
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width); // -800
|
||||||
|
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height); // -900
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces a minimum zoom so the box keeps covering the viewport", () => {
|
||||||
|
const minZoom = getMinZoomForConstraints(box, VIEWPORT);
|
||||||
|
expect(minZoom).toBeCloseTo(0.2); // max(200/1000, 100/1000)
|
||||||
|
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: box,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(minZoom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an in-bounds viewport untouched", () => {
|
||||||
|
const inBounds = makeState({
|
||||||
|
scrollX: -100,
|
||||||
|
scrollY: -100,
|
||||||
|
zoom: { value: getNormalizedZoom(1) },
|
||||||
|
scrollConstraints: box,
|
||||||
|
});
|
||||||
|
expect(constrainScrollState(inBounds)).toMatchObject({
|
||||||
|
scrollX: -100,
|
||||||
|
scrollY: -100,
|
||||||
|
zoom: { value: getNormalizedZoom(1) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("box smaller than the viewport (best-effort fit)", () => {
|
||||||
|
it("forces a zoom-in and removes pan room on the binding axis", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 50, height: 50 };
|
||||||
|
// minZoom = max(200/50, 100/50) = 4
|
||||||
|
expect(getMinZoomForConstraints(box, VIEWPORT)).toBeCloseTo(4);
|
||||||
|
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollX: 999,
|
||||||
|
scrollY: 999,
|
||||||
|
zoom: { value: getNormalizedZoom(1) },
|
||||||
|
scrollConstraints: box,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.zoom.value).toBeCloseTo(4);
|
||||||
|
// width binds: visible width (200/4=50) == box width → no x pan room
|
||||||
|
expect(result.scrollX).toBeCloseTo(0);
|
||||||
|
// height has slack (100/4=25 < 50) → clamped to the top edge here
|
||||||
|
expect(result.scrollY).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("centers the box when it can't cover the viewport even at MAX_ZOOM", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1, height: 1 };
|
||||||
|
expect(getMinZoomForConstraints(box, VIEWPORT)).toBe(MAX_ZOOM);
|
||||||
|
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({ scrollX: 1000, scrollY: 1000, scrollConstraints: box }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.zoom.value).toBe(MAX_ZOOM);
|
||||||
|
// centered: (min + max) / 2, with min = w/zoom - (x+w), max = -x
|
||||||
|
const centeredX = (VIEWPORT.width / MAX_ZOOM - 1 + 0) / 2;
|
||||||
|
expect(result.scrollX).toBeCloseTo(centeredX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scrollToContent scrollConstraints (integration)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps the current viewport inside the box when constraints are set", async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
await waitFor(() => expect(h.state.width).toBe(200));
|
||||||
|
|
||||||
|
// start well outside the future box
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent([], {
|
||||||
|
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// viewport must now be within [-800, 0] x [-900, 0]
|
||||||
|
expect(h.state.scrollX).toBeLessThanOrEqual(0);
|
||||||
|
expect(h.state.scrollX).toBeGreaterThanOrEqual(-800);
|
||||||
|
expect(h.state.scrollY).toBeLessThanOrEqual(0);
|
||||||
|
expect(h.state.scrollY).toBeGreaterThanOrEqual(-900);
|
||||||
|
expect(h.state.zoom.value).toBeGreaterThanOrEqual(0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents keyboard panning out of the box and restores freedom on clear", async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
await waitFor(() => expect(h.state.width).toBe(200));
|
||||||
|
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent([], {
|
||||||
|
scrollConstraints: { x: 0, y: 0, width: 300, height: 200 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// hammer page-down (pans down) far past the box; scroll must stay clamped
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||||
|
}
|
||||||
|
expect(h.state.scrollY).toBeGreaterThanOrEqual(h.state.height - 200);
|
||||||
|
|
||||||
|
// clearing constraints lets it scroll freely again
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent([], { scrollConstraints: null });
|
||||||
|
});
|
||||||
|
const before = h.state.scrollY;
|
||||||
|
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||||
|
expect(h.state.scrollY).toBeLessThan(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the scroll target into the box when scrolling to content", async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
await waitFor(() => expect(h.state.width).toBe(200));
|
||||||
|
|
||||||
|
// an element well outside the future constraint box
|
||||||
|
const rect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 2000,
|
||||||
|
y: 2000,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
API.setElements([rect]);
|
||||||
|
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent(rect, {
|
||||||
|
scrollConstraints: { x: 0, y: 0, width: 1000, height: 1000 },
|
||||||
|
animate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// recentering on the element would scroll way past the box; it must be
|
||||||
|
// clamped to the far edges instead (scrollX -800, scrollY -900 at zoom 1)
|
||||||
|
expect(h.state.scrollX).toBeCloseTo(-800);
|
||||||
|
expect(h.state.scrollY).toBeCloseTo(-900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables reset-zoom and zoom-to-fit actions while constrained", async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
await waitFor(() => expect(h.state.width).toBe(200));
|
||||||
|
|
||||||
|
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(true);
|
||||||
|
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(true);
|
||||||
|
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent([], {
|
||||||
|
scrollConstraints: { x: 0, y: 0, width: 500, height: 500 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.app.actionManager.isActionEnabled(actionResetZoom)).toBe(false);
|
||||||
|
expect(h.app.actionManager.isActionEnabled(actionZoomToFit)).toBe(false);
|
||||||
|
expect(h.app.actionManager.isActionEnabled(actionZoomToFitSelection)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("explicit minZoom / maxZoom (pure)", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("lets minZoom override the box-enforced fit zoom (zoom out past the box)", () => {
|
||||||
|
// without minZoom the box forces a fit zoom of 0.2; minZoom 0.1 takes over
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still clamps zoom out at minZoom", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.5 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets maxZoom cap zoom-in below the global MAX_ZOOM", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(MAX_ZOOM) },
|
||||||
|
scrollConstraints: { ...box, maxZoom: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an in-range zoom untouched", () => {
|
||||||
|
const { zoom } = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(1.5) },
|
||||||
|
scrollConstraints: { ...box, minZoom: 0.5, maxZoom: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(zoom.value).toBeCloseTo(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("padding (pure)", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("extends the scrollable area past each edge by `[top, right, bottom, left]`", () => {
|
||||||
|
const padding: [number, number, number, number] = [10, 20, 30, 40];
|
||||||
|
|
||||||
|
// pan past the top-left corner → clamp to the padded corner
|
||||||
|
const topLeft = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollX: 999,
|
||||||
|
scrollY: 999,
|
||||||
|
scrollConstraints: { ...box, padding },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(topLeft.scrollX).toBeCloseTo(40); // left padding
|
||||||
|
expect(topLeft.scrollY).toBeCloseTo(10); // top padding
|
||||||
|
|
||||||
|
// pan past the far edges → clamp to width/height - boxSize - far padding
|
||||||
|
const farEdge = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollX: -5000,
|
||||||
|
scrollY: -5000,
|
||||||
|
scrollConstraints: { ...box, padding },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(farEdge.scrollX).toBeCloseTo(VIEWPORT.width - box.width - 20); // -820 (right)
|
||||||
|
expect(farEdge.scrollY).toBeCloseTo(VIEWPORT.height - box.height - 30); // -930 (bottom)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the padding a fixed screen distance regardless of zoom", () => {
|
||||||
|
// 40 screen px of top padding at zoom 2 → 20 scene px
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollY: 999,
|
||||||
|
zoom: { value: getNormalizedZoom(2) },
|
||||||
|
scrollConstraints: { ...box, padding: [40, 0, 0, 0] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.scrollY).toBeCloseTo(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to no extra room", () => {
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({ scrollX: 999, scrollConstraints: box }),
|
||||||
|
);
|
||||||
|
expect(result.scrollX).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stacks with the rubberband tolerance", () => {
|
||||||
|
const tolerance = 30; // screen px
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollX: 999,
|
||||||
|
scrollConstraints: { ...box, tolerance, padding: [0, 0, 0, 40] },
|
||||||
|
}),
|
||||||
|
tolerance,
|
||||||
|
);
|
||||||
|
// left padding (40) + tolerance overscroll (30) at zoom 1
|
||||||
|
expect(result.scrollX).toBeCloseTo(70);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rubberband tolerance (pure)", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("allows scroll overscroll up to `tolerance` screen pixels", () => {
|
||||||
|
const tolerance = 30; // screen px
|
||||||
|
// hard max for scrollX is -box.x = 0; soft adds tolerance / zoom scene px
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }),
|
||||||
|
tolerance,
|
||||||
|
);
|
||||||
|
expect(result.scrollX).toBeCloseTo(tolerance / 1); // 30 (zoom 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the overscroll a fixed screen distance regardless of zoom", () => {
|
||||||
|
const tolerance = 30; // screen px
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
scrollX: 999,
|
||||||
|
zoom: { value: getNormalizedZoom(2) },
|
||||||
|
scrollConstraints: { ...box, tolerance },
|
||||||
|
}),
|
||||||
|
tolerance,
|
||||||
|
);
|
||||||
|
// 30 screen px at zoom 2 -> 15 scene px of overscroll
|
||||||
|
expect(result.scrollX).toBeCloseTo(tolerance / 2); // 15
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relaxes the minimum zoom by the `tolerance` screen pixels", () => {
|
||||||
|
const tolerance = 25; // screen px
|
||||||
|
// relaxed min zoom lets the box shrink within the viewport by `tolerance`
|
||||||
|
// px on each side: max((200-50)/1000, (100-50)/1000) = 0.15
|
||||||
|
const expected = getMinZoomForConstraints(box, {
|
||||||
|
width: VIEWPORT.width - 2 * tolerance,
|
||||||
|
height: VIEWPORT.height - 2 * tolerance,
|
||||||
|
});
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.01) },
|
||||||
|
scrollConstraints: { ...box, tolerance },
|
||||||
|
}),
|
||||||
|
tolerance,
|
||||||
|
);
|
||||||
|
expect(result.zoom.value).toBeCloseTo(expected); // 0.15
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hard-clamps when tolerance is 0 (default)", () => {
|
||||||
|
const result = constrainScrollState(
|
||||||
|
makeState({ scrollX: 999, scrollConstraints: box }),
|
||||||
|
);
|
||||||
|
expect(result.scrollX).toBeCloseTo(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isViewportOverscrolled (pure)", () => {
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("is false when there are no constraints", () => {
|
||||||
|
expect(
|
||||||
|
isViewportOverscrolled(
|
||||||
|
makeState({ scrollX: 9999, scrollConstraints: null }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when the viewport is within the hard bounds", () => {
|
||||||
|
expect(
|
||||||
|
isViewportOverscrolled(
|
||||||
|
makeState({ scrollX: -100, scrollY: -100, scrollConstraints: box }),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when panned past an edge (rubberband overscroll)", () => {
|
||||||
|
// scrollX 30 is past the hard max of 0
|
||||||
|
expect(
|
||||||
|
isViewportOverscrolled(
|
||||||
|
makeState({ scrollX: 30, scrollConstraints: box }),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when zoomed out below the fit zoom", () => {
|
||||||
|
// fit zoom for this box is 0.2; 0.1 is below it
|
||||||
|
expect(
|
||||||
|
isViewportOverscrolled(
|
||||||
|
makeState({
|
||||||
|
zoom: { value: getNormalizedZoom(0.1) },
|
||||||
|
scrollConstraints: box,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("animateToConstraints (rubberband snap-back)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
AnimationController.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 };
|
||||||
|
|
||||||
|
it("starts an animation toward the box when overscrolled", () => {
|
||||||
|
const onFrame = vi.fn();
|
||||||
|
// scrollX 200 is outside the hard range [-800, 0]
|
||||||
|
animateToConstraints(
|
||||||
|
makeState({ scrollX: 200, scrollConstraints: box }),
|
||||||
|
onFrame,
|
||||||
|
);
|
||||||
|
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(onFrame).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when already within the box", () => {
|
||||||
|
const onFrame = vi.fn();
|
||||||
|
animateToConstraints(
|
||||||
|
makeState({ scrollX: -100, scrollConstraints: box }),
|
||||||
|
onFrame,
|
||||||
|
);
|
||||||
|
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(onFrame).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rubberband tolerance (integration)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
AnimationController.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the user pan past the box edge (bounded by tolerance)", async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
await waitFor(() => expect(h.state.width).toBe(200));
|
||||||
|
|
||||||
|
React.act(() => {
|
||||||
|
h.app.scrollToContent([], {
|
||||||
|
scrollConstraints: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1000,
|
||||||
|
height: 1000,
|
||||||
|
tolerance: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// snapped to the top edge (hard max scrollY = 0)
|
||||||
|
expect(h.state.scrollY).toBe(0);
|
||||||
|
|
||||||
|
// page-up pans up, pushing scrollY past the hard edge into the overscroll
|
||||||
|
Keyboard.keyPress(KEYS.PAGE_UP);
|
||||||
|
|
||||||
|
// overscrolled past 0, but bounded by tolerance (25 screen px / zoom 1)
|
||||||
|
expect(h.state.scrollY).toBeGreaterThan(0);
|
||||||
|
expect(h.state.scrollY).toBeLessThanOrEqual(
|
||||||
|
25 / h.state.zoom.value + 0.001,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -273,6 +273,36 @@ export type ObservedElementsAppState = {
|
|||||||
|
|
||||||
export type BoxSelectionMode = "contain" | "overlap";
|
export type BoxSelectionMode = "contain" | "overlap";
|
||||||
|
|
||||||
|
/** A box, in scene coordinates, that pan & zoom are constrained to. */
|
||||||
|
export type ScrollConstraints = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/**
|
||||||
|
* Pixel amount to allow to overscroll.
|
||||||
|
*/
|
||||||
|
tolerance?: number;
|
||||||
|
/**
|
||||||
|
* Lower zoom bound. When set, it takes precedence over the zoom the box would
|
||||||
|
* otherwise enforce (the fit zoom that keeps the box covering the viewport),
|
||||||
|
* letting the viewport zoom out past the box.
|
||||||
|
*/
|
||||||
|
minZoom?: number;
|
||||||
|
/**
|
||||||
|
* Upper zoom bound. When set, it takes precedence over the global `MAX_ZOOM`,
|
||||||
|
* capping how far the viewport can zoom in.
|
||||||
|
*/
|
||||||
|
maxZoom?: number;
|
||||||
|
/**
|
||||||
|
* Extra scrollable margin around the box, as `[top, right, bottom, left]`
|
||||||
|
* (cardinal order), letting the viewport scroll past each box edge to reveal
|
||||||
|
* that much empty space beyond it. Values are screen pixels, so the margin
|
||||||
|
* stays the same on-screen regardless of zoom. Defaults to `[0, 0, 0, 0]`.
|
||||||
|
*/
|
||||||
|
padding?: [top: number, right: number, bottom: number, left: number];
|
||||||
|
};
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
items: ContextMenuItems;
|
items: ContextMenuItems;
|
||||||
@@ -380,6 +410,9 @@ export interface AppState {
|
|||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
scrollX: number;
|
scrollX: number;
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
|
/** when set, pan & zoom are constrained so the viewport stays within this
|
||||||
|
* scene-coordinate box (see `scrollToContent`'s `scrollConstraints` option) */
|
||||||
|
scrollConstraints: ScrollConstraints | null;
|
||||||
cursorButton: "up" | "down";
|
cursorButton: "up" | "down";
|
||||||
scrolledOutside: boolean;
|
scrolledOutside: boolean;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ export const textWysiwyg = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app.actionManager.executeAction(actionZoomOut);
|
app.actionManager.executeAction(actionZoomOut);
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
|
} else if (!event.shiftKey && actionResetZoom.keyTest(event, app.state)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app.actionManager.executeAction(actionResetZoom);
|
app.actionManager.executeAction(actionResetZoom);
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||||||
},
|
},
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
|
"scrollConstraints": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user