Compare commits

..

8 Commits

Author SHA1 Message Date
dwelle b9b0b4c0d4 debug 2026-06-26 11:21:11 +02:00
dwelle 08827b18f8 wip 2026-06-26 10:22:23 +02:00
dwelle 470d28bedd remove sus predicate 2026-04-10 13:36:36 +02:00
dwelle 61bb357f8a fix group action regression 2026-04-10 13:36:15 +02:00
barnabasmolnar 4ade2172ee lint again, i guess... 2026-04-08 00:11:26 +02:00
barnabasmolnar 6021adab73 forward scrollConstraints prop from Excalidraw to App + fixed example app 2026-04-08 00:04:20 +02:00
barnabasmolnar fe3ba884f6 lint 2026-04-07 23:44:27 +02:00
barnabasmolnar 7dbd62a30c implement snap-free scroll constraints 2026-04-07 19:19:43 +02:00
42 changed files with 2347 additions and 1830 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye
FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
@@ -0,0 +1,124 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [active] [ref=e5]:
- generic:
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection — V or 1" [ref=e38] [cursor=pointer]:
- radio "Selection" [checked]
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: "1"
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e52]: "2"
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e59]: "3"
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- generic [ref=e61]:
- img [ref=e62]
- generic [ref=e66]: "4"
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- generic [ref=e68]:
- img [ref=e69]
- generic [ref=e74]: "5"
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- generic [ref=e76]:
- img [ref=e77]
- generic [ref=e78]: "6"
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e85]: "7"
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- generic [ref=e87]:
- img [ref=e88]
- generic [ref=e93]: "8"
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- generic [ref=e95]:
- img [ref=e96]
- generic [ref=e101]: "9"
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- generic [ref=e103]:
- img [ref=e104]
- generic [ref=e109]: "0"
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- generic [ref=e119]:
- link "Excalidraw+" [ref=e120] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [ref=e140] [cursor=pointer]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "lock scroll" [ref=e160] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
@@ -0,0 +1,126 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [ref=e5]:
- generic:
- generic:
- generic:
- generic:
- img
- img
- generic:
- text: Your drawings are saved in your browser's storage.
- text: Browser storage can be cleared unexpectedly.
- text: Save your work to a file regularly to avoid losing it.
- generic:
- button "Open Ctrl+O" [ref=e181] [cursor=pointer]:
- img [ref=e183]
- generic [ref=e185]: Open
- generic [ref=e186]: Ctrl+O
- button "Help ?" [ref=e187] [cursor=pointer]:
- img [ref=e189]
- generic [ref=e194]: Help
- generic [ref=e195]: "?"
- button "Live collaboration..." [ref=e196] [cursor=pointer]:
- img [ref=e198]
- generic [ref=e205]: Live collaboration...
- link "Sign up" [ref=e206] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest
- img [ref=e208]
- generic [ref=e214]: Sign up
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection" [ref=e215] [cursor=pointer]:
- radio "Selection" [checked]
- img [ref=e217]
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- img [ref=e48]
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- img [ref=e55]
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- img [ref=e62]
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- img [ref=e69]
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- img [ref=e77]
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- img [ref=e81]
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- img [ref=e88]
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- img [ref=e96]
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- img [ref=e104]
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [disabled] [ref=e140]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "disable scroll lock" [active] [ref=e222] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
+2 -2
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24 AS build
FROM --platform=${BUILDPLATFORM} node:18 AS build
WORKDIR /opt/node_app
@@ -13,7 +13,7 @@ ARG NODE_ENV=production
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
FROM nginx:1.27-alpine
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+2 -2
View File
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
label: "𝕏",
href: "https://x.com/excalidraw",
label: "Twitter",
href: "https://twitter.com/excalidraw",
},
{
label: "Linkedin",
+13 -1
View File
@@ -4,8 +4,20 @@ import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
import type { ScrollConstraints } from "@excalidraw/excalidraw/types";
import App from "../../with-script-in-browser/components/ExampleApp";
const scrollConstraints: ScrollConstraints = {
x: 0,
y: 0,
width: 850,
height: 400,
lockZoom: true,
overscrollAllowance: 0,
viewportZoomFactor: 1,
};
const ExcalidrawWrapper: React.FC = () => {
return (
<>
@@ -14,7 +26,7 @@ const ExcalidrawWrapper: React.FC = () => {
useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib}
>
<Excalidraw />
<Excalidraw scrollConstraints={scrollConstraints} />
</App>
</>
);
@@ -178,7 +178,8 @@ export default function ExampleApp({
const newElement = cloneElement(
Excalidraw,
{
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
onExcalidrawAPI: (api: ExcalidrawImperativeAPI | null) =>
setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise,
onChange: (
elements: NonDeletedExcalidrawElement[],
@@ -820,6 +821,48 @@ export default function ExampleApp({
/>
Export with embed scene
</label>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[0], {
animate: true,
fitToViewport: true,
viewportZoomFactor: 0.9,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
}}
>
Fit and lock first element
</button>
<button
type="button"
onClick={() => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
excalidrawAPI.scrollToContent(elements[1], {
animate: true,
fitToViewport: true,
viewportZoomFactor: 0.9,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
}}
>
Fit and lock second element
</button>
<button
onClick={async () => {
if (!excalidrawAPI) {
+4 -1
View File
@@ -1015,7 +1015,10 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action>
)}
</OverwriteConfirmDialog>
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
<AppFooter
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger />
+168 -2
View File
@@ -1,13 +1,178 @@
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 { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon";
type ScrollConstraintOptions = Pick<
ScrollConstraints,
"lockZoom" | "overscrollAllowance" | "viewportZoomFactor"
>;
const ScrollConstraintsDebugFooter = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
}) => {
const [activeLock, setActiveLock] = useState<ScrollConstraints | null>(null);
const [options, setOptions] = useState<ScrollConstraintOptions>({
lockZoom: false,
overscrollAllowance: 0.2,
viewportZoomFactor: 0.2,
});
const setLock = useCallback(
(nextLock: ScrollConstraints) => {
excalidrawAPI?.setScrollConstraints(nextLock);
setActiveLock(nextLock);
},
[excalidrawAPI],
);
const toggleScrollLock = useCallback(() => {
if (!excalidrawAPI) {
return;
}
if (activeLock) {
excalidrawAPI.setScrollConstraints(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,
...options,
});
}, [activeLock, excalidrawAPI, options, setLock]);
const updateOptions = useCallback(
(nextOptions: ScrollConstraintOptions) => {
setOptions(nextOptions);
if (activeLock) {
setLock({ ...activeLock, ...nextOptions });
}
},
[activeLock, setLock],
);
const updateNumberOption = useCallback(
(option: "overscrollAllowance" | "viewportZoomFactor", value: string) => {
updateOptions({
...options,
[option]: value === "" ? undefined : Number(value),
});
},
[options, updateOptions],
);
const updateBooleanOption = useCallback(
(option: "lockZoom", value: boolean) => {
updateOptions({
...options,
[option]: value,
});
},
[options, updateOptions],
);
return (
<div
style={{
display: "flex",
gap: ".45rem",
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" }}>
overscroll
<input
type="number"
min={0}
max={1}
step={0.05}
value={options.overscrollAllowance ?? ""}
onChange={(event) =>
updateNumberOption("overscrollAllowance", event.target.value)
}
style={{ width: 56 }}
/>
</label>
<label style={{ display: "flex", gap: ".25rem", alignItems: "center" }}>
zoom factor
<input
type="number"
min={0.1}
step={0.1}
value={options.viewportZoomFactor ?? ""}
onChange={(event) =>
updateNumberOption("viewportZoomFactor", event.target.value)
}
style={{ width: 56 }}
/>
</label>
<label style={{ display: "flex", gap: ".2rem", alignItems: "center" }}>
<input
type="checkbox"
checked={!!options.lockZoom}
onChange={(event) =>
updateBooleanOption("lockZoom", event.target.checked)
}
/>
lock zoom
</label>
</div>
);
};
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
({
excalidrawAPI,
onChange,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
onChange: () => void;
}) => {
return (
<Footer>
<div
@@ -17,6 +182,7 @@ export const AppFooter = React.memo(
alignItems: "center",
}}
>
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div>
+4 -6
View File
@@ -1943,9 +1943,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION),
hoveredElement.width,
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION),
hoveredElement.height,
]),
};
};
@@ -1976,11 +1976,9 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
(nonRotatedPoint[0] - hoveredElement.x) /
Math.max(hoveredElement.width, PRECISION);
(nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
const fixedPointY =
(nonRotatedPoint[1] - hoveredElement.y) /
Math.max(hoveredElement.height, PRECISION);
(nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
-3
View File
@@ -250,9 +250,6 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
// main
// ---------------------------------------------------------------------------
const frameIdsToDuplicate = new Set(
elements
.filter(
+2 -2
View File
@@ -2124,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
offsetX + points[points.length - 1][0] > MAX_POS ||
offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][0] > MAX_POS ||
offsetX + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
+2 -4
View File
@@ -19,7 +19,6 @@ import {
getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -102,9 +101,8 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
return boundsContainBounds(
getElementBounds(element, elementsMap),
getElementBounds(frame, elementsMap),
return getElementsWithinSelection([frame], element, elementsMap).some(
(e) => e.id === frame.id,
);
};
+5 -3
View File
@@ -34,6 +34,7 @@ import {
elementOverlapsWithFrame,
getContainingFrame,
getFrameChildren,
isElementIntersectingFrame,
} from "./frame";
import { LinearElementEditor } from "./linearElementEditor";
@@ -169,7 +170,7 @@ export const getElementsWithinSelection = (
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
elementAABB = [
@@ -208,9 +209,10 @@ export const getElementsWithinSelection = (
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
}
elementsInSelection.push(element);
continue;
}
// 2. Handle the case where the label is overlapped by the selection box
+64 -62
View File
@@ -1,56 +1,59 @@
import { arrayToMap } from "@excalidraw/common";
import { arrayToMapWithIndex } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
return element.groupIds[element.groupIds.length - level - 1];
};
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const origElements: ExcalidrawElement[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
const orderLevel = (
levelElements: readonly ExcalidrawElement[],
level: number,
const orderInnerGroups = (
elements: readonly ExcalidrawElement[],
): ExcalidrawElement[] => {
const buckets = new Map<string, ExcalidrawElement[]>();
// Slots preserve first-occurrence order: a groupId reserves its slot
// the first time one of its members is seen; loose elements occupy
// their own slot. Groups are then expanded (and recursed into) in place.
const slots: (ExcalidrawElement | string)[] = [];
for (const element of levelElements) {
const groupId = groupIdAtLevel(element, level);
if (groupId === undefined) {
slots.push(element);
continue;
const firstGroupSig = elements[0]?.groupIds?.join("");
const aGroup: ExcalidrawElement[] = [elements[0]];
const bGroup: ExcalidrawElement[] = [];
for (const element of elements.slice(1)) {
if (element.groupIds?.join("") === firstGroupSig) {
aGroup.push(element);
} else {
bGroup.push(element);
}
let bucket = buckets.get(groupId);
if (!bucket) {
bucket = [];
buckets.set(groupId, bucket);
slots.push(groupId);
}
bucket.push(element);
}
return slots.flatMap((slot) =>
typeof slot === "string"
? orderLevel(buckets.get(slot)!, level + 1)
: [slot],
);
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
};
// `groupIds` is stored innermost-first, so the outermost group is the
// last entry. We recurse from level 0 (outermost) inward.
const sortedElements = orderLevel(elements, 0);
const groupHandledElements = new Map<string, true>();
origElements.forEach((element, idx) => {
if (groupHandledElements.has(element.id)) {
return;
}
if (element.groupIds?.length) {
const topGroup = element.groupIds[element.groupIds.length - 1];
const groupElements = origElements.slice(idx).filter((element) => {
const ret = element?.groupIds?.some((id) => id === topGroup);
if (ret) {
groupHandledElements.set(element!.id, true);
}
return ret;
});
for (const elem of orderInnerGroups(groupElements)) {
sortedElements.add(elem);
}
} else {
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
if (sortedElements.length !== elements.length) {
console.error("defragmentGroups: lost some elements... bailing!");
if (sortedElements.size !== elements.length) {
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
return elements;
}
return sortedElements;
return [...sortedElements];
};
/**
@@ -65,40 +68,39 @@ const defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
const elementsMap = arrayToMap(elements);
const elementsMap = arrayToMapWithIndex(elements);
const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set<ExcalidrawElement>();
for (const element of elements) {
if (sortedElements.has(element)) {
continue;
origElements.forEach((element, idx) => {
if (!element) {
return;
}
if (element.boundElements?.length) {
sortedElements.add(element);
for (const boundElement of element.boundElements) {
origElements[idx] = null;
element.boundElements.forEach((boundElement) => {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
sortedElements.add(child);
sortedElements.add(child[0]);
origElements[child[1]] = null;
}
});
} else if (element.type === "text" && element.containerId) {
const parent = elementsMap.get(element.containerId);
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
sortedElements.add(element);
origElements[idx] = null;
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
}
continue;
} else {
sortedElements.add(element);
origElements[idx] = null;
}
// if element has a container and container lists it, skip this element
// as it'll be taken care of by the container
if (
element.type === "text" &&
element.containerId &&
elementsMap
.get(element.containerId)
?.boundElements?.some((el) => el.id === element.id)
) {
continue;
}
sortedElements.add(element);
}
});
// if there's a bug which resulted in losing some of the elements, return
// original instead as that's better than losing data
@@ -115,5 +117,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
return normalizeBoundElementsOrder(defragmentGroups(elements));
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
};
+1 -43
View File
@@ -2,7 +2,6 @@ import {
convertToExcalidrawElements,
Excalidraw,
} from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -11,12 +10,7 @@ import {
render,
} from "@excalidraw/excalidraw/tests/test-utils";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
} from "../src/types";
import type { ExcalidrawElement } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -131,26 +125,6 @@ describe("adding elements to frames", () => {
});
});
it("should treat an element fully containing a frame as overlapping the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: -50,
y: -50,
width: 250,
height: 250,
});
API.setElements([containingRect, frame]);
expect(
elementOverlapsWithFrame(
containingRect,
frame as ExcalidrawFrameLikeElement,
arrayToMap(h.elements),
),
).toBe(true);
});
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
@@ -441,22 +415,6 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should add a dragged element fully containing the frame", () => {
const containingRect = API.createElement({
type: "rectangle",
x: 220,
y: 20,
width: 300,
height: 300,
});
API.setElements([frame, containingRect]);
dragElementIntoFrame(frame, containingRect);
expect(API.getElement(containingRect).frameId).toBe(frame.id);
});
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
+3 -43
View File
@@ -326,59 +326,19 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
"CBA_rect3",
"CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
"CBA_rect3",
"CBA_rect7",
"rect4",
"X_rect8",
"YX_rect10",
"X_rect11",
"YX_rect10",
"rect9",
],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "A_rect1",
type: "rectangle",
groupIds: ["A"],
}),
API.createElement({
id: "CBA_rect2",
type: "rectangle",
groupIds: ["C", "B", "A"],
}),
API.createElement({
id: "A_rect3",
type: "rectangle",
groupIds: ["A"],
}),
]),
["A_rect1", "CBA_rect2", "A_rect3"],
);
assertOrder(
normalizeElementOrder([
API.createElement({
id: "abcT_rect1",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
API.createElement({
id: "abcT_rect2",
type: "rectangle",
groupIds: ["a", "bc", "T"],
}),
API.createElement({
id: "abcT_rect3",
type: "rectangle",
groupIds: ["ab", "c", "T"],
}),
]),
["abcT_rect1", "abcT_rect3", "abcT_rect2"],
);
});
// TODO
+63 -9
View File
@@ -40,6 +40,7 @@ import {
ZoomResetIcon,
} from "../components/icons";
import { setCursor } from "../cursor";
import { constrainScrollState } from "../scene/scrollConstraints";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
@@ -51,6 +52,8 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types";
const canUseZoomActions = (appState: AppState) => !appState.scrollConstraints;
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
@@ -139,7 +142,7 @@ export const actionZoomIn = register({
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
appState: constrainScrollState({
...appState,
...getStateForZoom(
{
@@ -150,7 +153,7 @@ export const actionZoomIn = register({
appState,
),
userToFollow: null,
},
}),
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -180,7 +183,7 @@ export const actionZoomOut = register({
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
appState: constrainScrollState({
...appState,
...getStateForZoom(
{
@@ -191,7 +194,7 @@ export const actionZoomOut = register({
appState,
),
userToFollow: null,
},
}),
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
},
@@ -243,6 +246,7 @@ export const actionResetZoom = register({
className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
disabled={!canUseZoomActions(appState)}
onClick={() => {
updateData(null);
}}
@@ -251,6 +255,7 @@ export const actionResetZoom = register({
</ToolButton>
</Tooltip>
),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
@@ -274,7 +279,7 @@ const zoomValueToFitBoundsOnViewport = (
return Math.min(adjustedZoomValue, 1);
};
export const zoomToFitBounds = ({
export function resolveViewportForBounds({
bounds,
appState,
canvasOffsets,
@@ -292,7 +297,7 @@ export const zoomToFitBounds = ({
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
}) {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds;
@@ -345,12 +350,58 @@ export const zoomToFitBounds = ({
zoom: { value: newZoomValue },
});
return {
bounds,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
viewportZoomFactor,
viewportDimensions: {
width: appState.width,
height: appState.height,
},
effectiveViewportDimensions: {
width: effectiveCanvasWidth,
height: effectiveCanvasHeight,
},
};
}
export const zoomToFitBounds = ({
bounds,
appState,
canvasOffsets,
fitToViewport = false,
viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: {
bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => {
const resolvedViewport = resolveViewportForBounds({
bounds,
appState,
canvasOffsets,
fitToViewport,
viewportZoomFactor,
minZoom,
maxZoom,
});
return {
appState: {
...appState,
scrollX: centerScroll.scrollX,
scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue },
scrollX: resolvedViewport.scrollX,
scrollY: resolvedViewport.scrollY,
zoom: resolvedViewport.zoom,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
@@ -408,6 +459,7 @@ export const actionZoomToFitSelectionInViewport = register({
canvasOffsets: app.getEditorUIOffsets(),
});
},
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed
keyTest: (event) =>
@@ -434,6 +486,7 @@ export const actionZoomToFitSelection = register({
canvasOffsets: app.getEditorUIOffsets(),
});
},
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE this action should use shift-2 per figma, alas
keyTest: (event) =>
event.code === CODES.THREE &&
@@ -458,6 +511,7 @@ export const actionZoomToFit = register({
fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&
@@ -329,6 +329,7 @@ export const actionFinalize = register<FormData>({
selectionElement: null,
multiElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
selectedElementIds:
element &&
+12 -3
View File
@@ -49,9 +49,18 @@ import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
const allElementsInSameGroup = (
elements: readonly ExcalidrawElement[],
editingGroupId: AppState["editingGroupId"],
) => {
if (elements.length >= 2) {
const groupIds = elements[0].groupIds;
const editingGroupIndex = editingGroupId
? elements[0].groupIds.indexOf(editingGroupId)
: -1;
const groupIds =
editingGroupIndex > -1
? elements[0].groupIds.slice(0, editingGroupIndex)
: elements[0].groupIds;
for (const groupId of groupIds) {
if (
elements.reduce(
@@ -78,7 +87,7 @@ const enableActionGroup = (
return (
selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) &&
!allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
!frameAndChildrenSelectedTogether(selectedElements)
);
};
@@ -53,8 +53,5 @@ export const actionToggleSearchMenu = register({
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});
+7
View File
@@ -95,6 +95,13 @@ export class ActionManager {
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
(!action.predicate ||
action.predicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
this.app.props,
this.app,
)) &&
action.keyTest &&
action.keyTest(
event,
+4
View File
@@ -99,6 +99,7 @@ export const getDefaultAppState = (): Omit<
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
startBoundElement: null,
suggestedBinding: null,
frameRendering: { enabled: true, clip: true, name: true, outline: true },
frameToHighlight: null,
@@ -121,6 +122,7 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false,
userToFollow: null,
followedBy: new Set(),
scrollConstraints: null,
isCropping: false,
croppingElementId: null,
searchMatches: null,
@@ -230,6 +232,7 @@ const APP_STATE_STORAGE_CONF = (<
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBinding: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },
frameToHighlight: { browser: false, export: false, server: false },
@@ -248,6 +251,7 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
followedBy: { browser: false, export: false, server: false },
scrollConstraints: { browser: true, export: false, server: false },
isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false },
+483 -140
View File
@@ -326,7 +326,10 @@ import {
actionToggleCropEditor,
} from "../actions";
import { actionWrapTextInContainer } from "../actions/actionBoundText";
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import {
actionToggleHandTool,
resolveViewportForBounds,
} from "../actions/actionCanvas";
import { actionPaste } from "../actions/actionClipboard";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { actionUnlockAllElements } from "../actions/actionElementLock";
@@ -370,7 +373,7 @@ import {
hasBackground,
isSomeElementSelected,
} from "../scene";
import { getStateForZoom } from "../scene/zoom";
import { getConstrainedZoomAnchor, getStateForZoom } from "../scene/zoom";
import {
dataURLToString,
generateIdFromFile,
@@ -426,6 +429,12 @@ import { isMaybeMermaidDefinition } from "../mermaid";
import { LassoTrail } from "../lasso";
import {
constrainScrollState,
calculateConstrainedScrollCenter,
areCanvasTranslatesClose,
getScrollConstraintsForBounds,
} from "../scene/scrollConstraints";
import { EraserTrail } from "../eraser";
import { getShortcutKey } from "../shortcut";
@@ -488,6 +497,9 @@ import type {
CollaboratorPointer,
ToolType,
OnUserFollowedPayload,
ScrollConstraints,
AnimateTranslateCanvasValues,
ScrollToContentOptions,
UnsubscribeCallback,
EmbedsValidationStatus,
ElementsPendingErasure,
@@ -540,6 +552,7 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
height: 0,
offsetLeft: 0,
offsetTop: 0,
scrollConstraints: null,
});
ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
@@ -594,6 +607,20 @@ let isDraggingScrollBar: boolean = false;
let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
let touchTimeout = 0;
let invalidateContextMenu = false;
let scrollConstraintsAnimationTimeout: ReturnType<typeof setTimeout> | null =
null;
const areScrollConstraintsEqual = (
left: ScrollConstraints | null | undefined,
right: ScrollConstraints | null | undefined,
) => {
return left === right || (!!left && !!right && isShallowEqual(left, right));
};
const SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING =
"Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled.";
const SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING =
"Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled.";
/**
* Map of youtube embed video states
@@ -603,8 +630,6 @@ const YOUTUBE_VIDEO_STATES = new Map<
ValueOf<typeof YOUTUBE_STATES>
>();
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
let IS_PLAIN_PASTE = false;
let IS_PLAIN_PASTE_TIMER = 0;
let PLAIN_PASTE_TOAST_SHOWN = false;
@@ -783,6 +808,8 @@ class App extends React.Component<AppProps, AppState> {
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
app: this,
setScrollConstraints: this.setScrollConstraints,
};
return api;
}
@@ -797,6 +824,7 @@ class App extends React.Component<AppProps, AppState> {
objectsSnapModeEnabled = false,
theme = defaultAppState.theme,
name = `${t("labels.untitled")}-${getDateTime()}`,
scrollConstraints,
} = props;
this.state = {
@@ -812,6 +840,7 @@ class App extends React.Component<AppProps, AppState> {
name,
width: window.innerWidth,
height: window.innerHeight,
scrollConstraints: scrollConstraints ?? null,
};
this.refreshEditorInterface();
@@ -1737,18 +1766,6 @@ class App extends React.Component<AppProps, AppState> {
this.state.activeEmbeddable?.element === el &&
this.state.activeEmbeddable?.state === "hover";
// scale video embeds based on zoom (capped) so that smaller embeds
// on canvas when zoomed are still of legible quality
// (note: for some embed types like gdrive, the quality is poor when
// scaling mid playback and works only when you initially start the
// playback at the higher zoom level)
const shouldScaleEmbeddableViewport = src?.type === "video";
const embeddableViewportScale = clamp(
shouldScaleEmbeddableViewport ? scale : 1,
0.75,
MAX_EMBEDDABLE_VIEWPORT_SCALE,
);
return (
<div
key={el.id}
@@ -1815,42 +1832,31 @@ class App extends React.Component<AppProps, AppState> {
padding: `${el.strokeWidth}px`,
}}
>
<div
className="excalidraw__embeddable__content"
style={{
width: `${embeddableViewportScale * 100}%`,
height: `${embeddableViewportScale * 100}%`,
transform: `scale(${1 / embeddableViewportScale})`,
}}
>
{(isEmbeddableElement(el)
? this.props.renderEmbeddable?.(el, this.state)
: null) ?? (
<iframe
ref={(ref) => this.cacheEmbeddableRef(el, ref)}
className="excalidraw__embeddable"
srcDoc={
src?.type === "document"
? src.srcdoc(this.state.theme)
: undefined
}
src={
src?.type !== "document" ? src?.link ?? "" : undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox={`${
src?.sandbox?.allowSameOrigin
? "allow-same-origin"
: ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>
{(isEmbeddableElement(el)
? this.props.renderEmbeddable?.(el, this.state)
: null) ?? (
<iframe
ref={(ref) => this.cacheEmbeddableRef(el, ref)}
className="excalidraw__embeddable"
srcDoc={
src?.type === "document"
? src.srcdoc(this.state.theme)
: undefined
}
src={
src?.type !== "document" ? src?.link ?? "" : undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox={`${
src?.sandbox?.allowSameOrigin ? "allow-same-origin" : ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>
</div>
</div>
@@ -2970,7 +2976,13 @@ class App extends React.Component<AppProps, AppState> {
toast: this.state.toast,
};
if (initialData?.scrollToContent) {
if (this.props.scrollConstraints) {
restoredAppState = {
...restoredAppState,
scrollConstraints: this.props.scrollConstraints,
...calculateConstrainedScrollCenter(this.state, restoredAppState),
};
} else if (initialData?.scrollToContent) {
restoredAppState = {
...restoredAppState,
...calculateScrollCenter(restoredElements, {
@@ -2979,10 +2991,18 @@ class App extends React.Component<AppProps, AppState> {
height: this.state.height,
offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft,
scrollConstraints: this.state.scrollConstraints,
}),
};
}
if (initialData?.scrollX != null) {
restoredAppState.scrollX = initialData.scrollX;
}
if (initialData?.scrollY != null) {
restoredAppState.scrollY = initialData.scrollY;
}
this.resetStore();
this.resetHistory();
this.syncActionResult({
@@ -3233,7 +3253,11 @@ class App extends React.Component<AppProps, AppState> {
.forEach((element) => ShapeCache.delete(element));
this.refreshEditorInterface();
this.updateDOMRect();
this.setState({});
if (this.state.scrollConstraints) {
this.setState((state) => constrainScrollState(state));
} else {
this.setState({});
}
});
/** generally invoked only if fullscreen was invoked programmatically */
@@ -3483,6 +3507,15 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
}
if (
!areScrollConstraintsEqual(
prevProps.scrollConstraints,
this.props.scrollConstraints,
)
) {
this.syncScrollConstraints(this.props.scrollConstraints ?? null);
}
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
this.addEventListeners();
this.deselectElements();
@@ -3543,6 +3576,33 @@ class App extends React.Component<AppProps, AppState> {
this.props.onChange?.(elements, this.state, this.files);
this.onChangeEmitter.trigger(elements, this.state, this.files);
}
if (this.state.scrollConstraints?.animateOnNextUpdate) {
const newState = constrainScrollState(this.state, "rigid");
const fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const toValues = {
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
};
if (areCanvasTranslatesClose(fromValues, toValues)) {
return;
}
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
}
scrollConstraintsAnimationTimeout = setTimeout(() => {
this.cancelInProgressAnimation?.();
this.animateToConstrainedArea(fromValues, toValues);
}, 200);
}
}
private renderInteractiveSceneCallback = ({
@@ -4310,8 +4370,8 @@ class App extends React.Component<AppProps, AppState> {
*/
value: number,
) => {
this.setState({
...getStateForZoom(
this.setState(
getStateForZoom(
{
viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop,
@@ -4319,11 +4379,13 @@ class App extends React.Component<AppProps, AppState> {
},
this.state,
),
});
);
};
private cancelInProgressAnimation: (() => void) | null = null;
private scrollToContentRequestId = 0;
scrollToContent = (
/**
* target to scroll to
@@ -4335,30 +4397,13 @@ class App extends React.Component<AppProps, AppState> {
| string
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: (
| {
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
| {
fitToContent?: never;
fitToViewport?: boolean;
/** when fitToViewport=true, how much screen should the content cover,
* between 0.1 (10%) and 1 (100%)
*/
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
}
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
opts?: ScrollToContentOptions,
) => {
if (opts?.scrollLock && this.props.scrollConstraints !== undefined) {
console.warn(SCROLL_TO_CONTENT_SCROLL_LOCK_CONTROLLED_WARNING);
opts = { ...opts, scrollLock: undefined };
}
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
@@ -4370,10 +4415,29 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getElementsFromId(id);
if (elements?.length) {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
if (opts?.fitToViewport) {
this.scrollToContent(elements, {
fitToViewport: true,
viewportZoomFactor: opts.viewportZoomFactor,
animate: opts.animate ?? true,
duration: opts.duration,
scrollLock: opts.scrollLock,
minZoom: opts.minZoom,
maxZoom: opts.maxZoom,
canvasOffsets: opts.canvasOffsets,
});
} else {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
viewportZoomFactor: opts?.viewportZoomFactor,
animate: opts?.animate ?? true,
duration: opts?.duration,
scrollLock: opts?.scrollLock,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
canvasOffsets: opts?.canvasOffsets,
});
}
} else if (isElementLink(target)) {
this.setState({
toast: {
@@ -4388,27 +4452,45 @@ class App extends React.Component<AppProps, AppState> {
}
this.cancelInProgressAnimation?.();
this.scrollToContentRequestId += 1;
this.debounceConstrainScrollState.cancel();
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
scrollConstraintsAnimationTimeout = null;
}
if (opts?.scrollLock != null && this.state.scrollConstraints) {
// Clear the previous lock before starting the next locked transition so
// stale constraint enforcement cannot snap us back mid-flight.
this.setState({ scrollConstraints: null });
}
// convert provided target into ExcalidrawElement[] if necessary
const targetElements = Array.isArray(target) ? target : [target];
if (!targetElements.length) {
return;
}
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
const targetBounds = getCommonBounds(targetElements);
if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({
const resolvedViewport = resolveViewportForBounds({
bounds: targetBounds,
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;
zoom = resolvedViewport.zoom;
scrollX = resolvedViewport.scrollX;
scrollY = resolvedViewport.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targetElements, this.state);
@@ -4416,51 +4498,72 @@ class App extends React.Component<AppProps, AppState> {
scrollY = scroll.scrollY;
}
const scrollConstraints = opts?.scrollLock
? getScrollConstraintsForBounds({
bounds: targetBounds,
zoom,
viewportDimensions: {
width: this.state.width,
height: this.state.height,
},
scrollLock: opts.scrollLock,
})
: null;
const requestId = this.scrollToContentRequestId;
const installScrollConstraints = () => {
if (!scrollConstraints || requestId !== this.scrollToContentRequestId) {
return;
}
this.setState({
scrollConstraints,
});
};
// 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 fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const cancel = easeToValuesRAF({
fromValues: {
scrollX: origScrollX,
scrollY: origScrollY,
zoom: origZoom,
},
toValues: { scrollX, scrollY, zoom: zoom.value },
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
const toValues = { scrollX, scrollY, zoom: zoom.value };
this.animateTranslateCanvas({
fromValues,
toValues,
duration: opts?.duration ?? 500,
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
if (requestId !== this.scrollToContentRequestId) {
return;
}
this.setState(
{ shouldCacheIgnoreZoom: false },
installScrollConstraints,
);
},
onCancel: () => {
if (requestId !== this.scrollToContentRequestId) {
return;
}
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: opts?.duration ?? 500,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
} else if (scrollConstraints) {
this.setState({
scrollX,
scrollY,
zoom,
scrollConstraints,
});
} else {
this.setState({ scrollX, scrollY, zoom });
}
@@ -4474,11 +4577,165 @@ class App extends React.Component<AppProps, AppState> {
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
stateUpdate,
) => {
this.cancelInProgressAnimation?.();
this.maybeUnfollowRemoteUser();
this.setState(state);
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
}
const partialNewState =
typeof stateUpdate === "function"
? (
stateUpdate as (
prevState: Readonly<AppState>,
props: Readonly<AppProps>,
) => AppState
)(this.state, this.props)
: stateUpdate;
const newState: AppState = {
...this.state,
...partialNewState,
...(this.state.scrollConstraints && {
// manually reset if setState in onCancel wasn't committed yet
shouldCacheIgnoreZoom: false,
}),
};
// RULE: cannot go below the minimum zoom level if zoom lock is enabled
const constrainedState =
newState.scrollConstraints && newState.scrollConstraints.lockZoom
? constrainScrollState(newState, "elastic")
: newState;
if (constrainedState.zoom.value > newState.zoom.value) {
newState.zoom = constrainedState.zoom;
newState.scrollX = constrainedState.scrollX;
newState.scrollY = constrainedState.scrollY;
this.debounceConstrainScrollState(newState);
return;
}
this.setState(newState);
if (this.state.scrollConstraints) {
if (newState.zoom.value < this.state.zoom.value) {
// zoom-out: debounce to allow centering on user's cursor position before constraining
this.debounceConstrainScrollState(newState);
} else {
// zoom-in or pan: valid range only expands on zoom-in, constrain immediately
this.setState(constrainScrollState(newState));
}
}
};
private debounceConstrainScrollState = debounce((state: AppState) => {
const newState = constrainScrollState(state, "rigid");
const fromValues = {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
};
const toValues = {
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
};
if (areCanvasTranslatesClose(fromValues, toValues)) {
return;
}
this.cancelInProgressAnimation?.();
this.animateToConstrainedArea(fromValues, toValues);
}, 200);
private animateToConstrainedArea = (
fromValues: AnimateTranslateCanvasValues,
toValues: AnimateTranslateCanvasValues,
) => {
const cleanUp = () => {
this.setState((state) => ({
shouldCacheIgnoreZoom: false,
scrollConstraints: {
...state.scrollConstraints!,
animateOnNextUpdate: false,
},
}));
};
this.animateTranslateCanvas({
fromValues,
toValues,
duration: 200,
onStart: () => {
this.setState((state) => {
return {
shouldCacheIgnoreZoom: true,
scrollConstraints: {
...state.scrollConstraints!,
animateOnNextUpdate: false,
},
};
});
},
onEnd: cleanUp,
onCancel: cleanUp,
});
};
private animateTranslateCanvas = ({
fromValues,
toValues,
duration,
onStart,
onEnd,
onCancel,
}: {
fromValues: AnimateTranslateCanvasValues;
toValues: AnimateTranslateCanvasValues;
duration: number;
onStart: () => void;
onEnd: () => void;
onCancel: () => void;
}) => {
const cancel = easeToValuesRAF({
fromValues,
toValues,
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: zoom },
});
},
onStart,
onEnd: () => {
this.cancelInProgressAnimation = null;
onEnd();
},
onCancel: () => {
this.cancelInProgressAnimation = null;
onCancel();
},
duration,
});
this.cancelInProgressAnimation = () => {
cancel();
this.cancelInProgressAnimation = null;
};
};
setToast = (toast: AppState["toast"]) => {
@@ -5659,16 +5916,22 @@ class App extends React.Component<AppProps, AppState> {
const initialScale = gesture.initialScale;
if (initialScale) {
this.setState((state) => ({
...getStateForZoom(
this.setState((state) =>
constrainScrollState(
{
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(initialScale * event.scale),
...state,
...getStateForZoom(
{
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(initialScale * event.scale),
},
state,
),
},
state,
"loose",
),
}));
);
}
});
@@ -6947,7 +7210,7 @@ class App extends React.Component<AppProps, AppState> {
y: scenePointerY,
},
});
this.setState({ suggestedBinding: null });
this.setState({ suggestedBinding: null, startBoundElement: null });
if (!this.state.activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
@@ -7591,6 +7854,7 @@ class App extends React.Component<AppProps, AppState> {
appState: {
newElement: null,
editingTextElement: null,
startBoundElement: null,
suggestedBinding: null,
selectedElementIds: makeNextSelectedElementIds(
Object.keys(this.state.selectedElementIds)
@@ -8878,8 +9142,18 @@ class App extends React.Component<AppProps, AppState> {
};
});
const boundElement = getHoveredElementForBinding(
pointFrom<GlobalPoint>(
pointerDownState.origin.x,
pointerDownState.origin.y,
),
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
);
this.setState({
newElement: element,
startBoundElement: boundElement,
suggestedBinding: null,
});
};
@@ -10732,7 +11006,7 @@ class App extends React.Component<AppProps, AppState> {
sceneCoords,
});
}
this.setState({ suggestedBinding: null });
this.setState({ suggestedBinding: null, startBoundElement: null });
if (!activeTool.locked) {
resetCursor(this.interactiveCanvas);
this.setState((prevState) => ({
@@ -10753,9 +11027,9 @@ class App extends React.Component<AppProps, AppState> {
),
}));
} else {
this.setState({
this.setState((prevState) => ({
newElement: null,
});
}));
}
// so that the scene gets rendered again to display the newly drawn linear as well
this.scene.triggerUpdate();
@@ -12634,17 +12908,21 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
this.translateCanvas((state) => ({
...getStateForZoom(
this.translateCanvas((state) => {
const nextZoom = getNormalizedZoom(newZoom);
const anchor = getConstrainedZoomAnchor(
{
viewportX: this.lastViewportPosition.x,
viewportY: this.lastViewportPosition.y,
nextZoom: getNormalizedZoom(newZoom),
nextZoom,
},
state,
),
shouldCacheIgnoreZoom: true,
}));
);
return {
...getStateForZoom({ ...anchor, nextZoom }, state),
shouldCacheIgnoreZoom: true,
};
});
this.resetShouldCacheIgnoreZoomDebounced();
return;
}
@@ -12802,6 +13080,71 @@ class App extends React.Component<AppProps, AppState> {
await setLanguage(currentLang);
this.setAppState({});
}
/**
* Sets the scroll constraints of the application state.
*
* @param scrollConstraints - The new scroll constraints.
*/
public setScrollConstraints = (
scrollConstraints: ScrollConstraints | null,
) => {
if (this.props.scrollConstraints !== undefined) {
console.warn(SET_SCROLL_CONSTRAINTS_CONTROLLED_WARNING);
return;
}
this.syncScrollConstraints(scrollConstraints);
};
private syncScrollConstraints = (
scrollConstraints: ScrollConstraints | null,
) => {
this.debounceConstrainScrollState.cancel();
if (scrollConstraintsAnimationTimeout) {
clearTimeout(scrollConstraintsAnimationTimeout);
scrollConstraintsAnimationTimeout = null;
}
this.cancelInProgressAnimation?.();
if (scrollConstraints) {
this.setState(
{
scrollConstraints,
shouldCacheIgnoreZoom: false,
},
() => {
const newState = constrainScrollState(
{
...this.state,
scrollConstraints,
},
"rigid",
);
this.animateToConstrainedArea(
{
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
},
{
scrollX: newState.scrollX,
scrollY: newState.scrollY,
zoom: newState.zoom.value,
},
);
},
);
} else {
this.setState({
scrollConstraints: null,
shouldCacheIgnoreZoom: false,
});
}
};
}
// -----------------------------------------------------------------------------
+2 -1
View File
@@ -650,7 +650,8 @@ const LayerUI = ({
};
const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => {
const { cursorButton, scrollX, scrollY, ...ret } = appState;
const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } =
appState;
return ret;
};
-8
View File
@@ -814,14 +814,6 @@ body.excalidraw-cursor-resize * {
.excalidraw__embeddable__outer {
width: 100%;
height: 100%;
}
.excalidraw__embeddable__content {
width: 100%;
height: 100%;
transform-origin: top left;
&,
& > * {
border-radius: var(--embeddable-radius);
}
+6 -41
View File
@@ -96,8 +96,6 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
const MAX_ARROW_PX = 75_000;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
@@ -469,8 +467,8 @@ export const restoreElement = (
element.endArrowhead === undefined
? "arrow"
: normalizeArrowhead(element.endArrowhead);
const x = element.x as number | undefined;
const y = element.y as number | undefined;
const x: number | undefined = element.x;
const y: number | undefined = element.y;
const points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
!Array.isArray(element.points) || element.points.length < 2
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
@@ -495,8 +493,8 @@ export const restoreElement = (
startArrowhead,
endArrowhead,
points,
x: x ?? 0,
y: y ?? 0,
x,
y,
elbowed: (element as ExcalidrawArrowElement).elbowed,
...getSizeFromPoints(points),
};
@@ -515,44 +513,12 @@ export const restoreElement = (
})
: restoreElementWithProperties(element as ExcalidrawArrowElement, base);
const normalizedRestoredElement = {
return {
...restoredElement,
...LinearElementEditor.getNormalizeElementPointsAndCoords(
restoredElement,
),
};
// Last resort fix for extremely large arrows
if (
normalizedRestoredElement.width > MAX_ARROW_PX ||
normalizedRestoredElement.height > MAX_ARROW_PX
) {
console.error(
`Removing extremely large arrow ${
normalizedRestoredElement.id
} (type: ${
isElbowArrow(normalizedRestoredElement) ? "elbow" : "simple"
}, width: ${normalizedRestoredElement.width}, height: ${
normalizedRestoredElement.height
}, x: ${normalizedRestoredElement.x}, y: ${
normalizedRestoredElement.y
})`,
);
return {
...normalizedRestoredElement,
x: 0,
y: 0,
width: 100,
height: 100,
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 100),
],
isDeleted: true,
};
}
return normalizedRestoredElement;
}
// generic elements
@@ -700,7 +666,6 @@ export const restoreElements = <T extends ExcalidrawElement>(
const existingElementsMap = existingElements
? arrayToMap(existingElements)
: null;
const restoredElements = syncInvalidIndices(
(targetElements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
@@ -797,7 +762,7 @@ export const restoreElements = <T extends ExcalidrawElement>(
}
}
// NOTE (mtolmacs): Temporary fix for invalid/self-bound elbow arrows
// NOTE (mtolmacs): Temporary fix for extremely large arrows
// Need to iterate again so we have attached text nodes in elementsMap
return restoredElements.map((element) => {
if (
+2
View File
@@ -98,6 +98,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled,
showDeprecatedFonts,
renderScrollbars,
scrollConstraints,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@@ -208,6 +209,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars}
scrollConstraints={scrollConstraints}
>
{children}
</App>
@@ -19,6 +19,8 @@ export const renderSnaps = (
}
// in dark mode, we need to adjust the color to account for color inversion.
// Don't change if zen mode, because we draw only crosses, we want the
// colors to be more visible
const snapColor =
appState.theme === THEME.LIGHT || appState.zenModeEnabled
? SNAP_COLOR_LIGHT
@@ -0,0 +1,590 @@
import { MAX_ZOOM } from "@excalidraw/common";
import { isShallowEqual } from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import { getNormalizedZoom } from "./normalize";
import type {
AnimateTranslateCanvasValues,
AppState,
ScrollConstraints,
ScrollToContentLockOptions,
} from "../types";
// Constants for viewport zoom factor and overscroll allowance
const MIN_VIEWPORT_ZOOM_FACTOR = 0.1;
const MAX_VIEWPORT_ZOOM_FACTOR = MAX_ZOOM;
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.2;
const DEFAULT_OVERSCROLL_ALLOWANCE = 0.2;
// Memoization variable to cache constraints for performance optimization
let memoizedValues: {
previousState: Pick<
AppState,
"zoom" | "width" | "height" | "scrollConstraints"
>;
constraints: ReturnType<typeof calculateConstraints>;
allowOverscroll: boolean;
} | null = null;
type CanvasTranslate = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
export const getScrollConstraintsForBounds = ({
bounds,
zoom,
viewportDimensions,
scrollLock,
}: {
bounds: readonly [number, number, number, number];
zoom: AppState["zoom"];
viewportDimensions: Pick<AppState, "width" | "height">;
scrollLock?: boolean | ScrollToContentLockOptions;
}): ScrollConstraints => {
const [x1, y1, x2, y2] = bounds;
const width = x2 - x1;
const height = y2 - y1;
const baseZoom = Math.min(
viewportDimensions.width / width,
viewportDimensions.height / height,
);
const lockOptions =
scrollLock && typeof scrollLock === "object" ? scrollLock : undefined;
const viewportZoomFactor =
lockOptions?.viewportZoomFactor ?? zoom.value / baseZoom;
return {
x: x1,
y: y1,
width,
height,
animateOnNextUpdate: false,
lockZoom: !!lockOptions?.lockZoom,
overscrollAllowance: lockOptions?.overscrollAllowance,
viewportZoomFactor,
};
};
/**
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
*
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param width - The width of the viewport.
* @param height - The height of the viewport.
* @returns An object containing the calculated zoom levels for the X and Y axes, and the initial zoom level.
*/
const calculateZoomLevel = (
scrollConstraints: ScrollConstraints,
width: AppState["width"],
height: AppState["height"],
) => {
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? clamp(
scrollConstraints.viewportZoomFactor,
MIN_VIEWPORT_ZOOM_FACTOR,
MAX_VIEWPORT_ZOOM_FACTOR,
)
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
const scrollableWidth = scrollConstraints.width;
const scrollableHeight = scrollConstraints.height;
const zoomLevelX = width / scrollableWidth;
const zoomLevelY = height / scrollableHeight;
const initialZoomLevel = getNormalizedZoom(
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
);
return { zoomLevelX, zoomLevelY, initialZoomLevel };
};
/**
* Calculates the effective zoom level based on the scroll constraints and current zoom.
*
* @param params - Object containing scrollConstraints, width, height, and zoom.
* @returns An object with the effective zoom level, initial zoom level, and zoom levels for X and Y axes.
*/
const calculateZoom = ({
scrollConstraints,
width,
height,
zoom,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
}) => {
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
scrollConstraints,
width,
height,
);
const effectiveZoom = scrollConstraints.lockZoom
? Math.max(initialZoomLevel, zoom.value)
: zoom.value;
return {
effectiveZoom: getNormalizedZoom(effectiveZoom),
initialZoomLevel,
zoomLevelX,
zoomLevelY,
};
};
/**
* Calculates the scroll bounds (min and max scroll values) based on the scroll constraints and zoom level.
*
* @param params - Object containing scrollConstraints, width, height, effectiveZoom, zoomLevelX, zoomLevelY, and allowOverscroll.
* @returns An object with min and max scroll values for X and Y axes.
*/
const calculateScrollBounds = ({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
effectiveZoom: number;
zoomLevelX: number;
zoomLevelY: number;
allowOverscroll: boolean;
}) => {
const overscrollAllowance =
scrollConstraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE;
const validatedOverscroll = clamp(overscrollAllowance, 0, 1);
const calculateCenter = (zoom: number) => {
const centerX =
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
const centerY =
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
return { centerX, centerY };
};
const { centerX, centerY } = calculateCenter(effectiveZoom);
const overscrollValue = Math.min(
validatedOverscroll * scrollConstraints.width,
validatedOverscroll * scrollConstraints.height,
);
const fitsX = effectiveZoom <= zoomLevelX;
const fitsY = effectiveZoom <= zoomLevelY;
const getScrollRange = (
axis: "x" | "y",
fits: boolean,
constraint: ScrollConstraints,
viewportSize: number,
zoom: number,
overscroll: number,
) => {
const { pos, size } =
axis === "x"
? { pos: constraint.x, size: constraint.width }
: { pos: constraint.y, size: constraint.height };
const center = axis === "x" ? centerX : centerY;
if (allowOverscroll) {
return fits
? { min: center - overscroll, max: center + overscroll }
: {
min: pos - size + viewportSize / zoom - overscroll,
max: pos + overscroll,
};
}
return fits
? { min: center, max: center }
: { min: pos - size + viewportSize / zoom, max: pos };
};
const xRange = getScrollRange(
"x",
fitsX,
scrollConstraints,
width,
effectiveZoom,
overscrollValue,
);
const yRange = getScrollRange(
"y",
fitsY,
scrollConstraints,
height,
effectiveZoom,
overscrollValue,
);
return {
minScrollX: xRange.min,
maxScrollX: xRange.max,
minScrollY: yRange.min,
maxScrollY: yRange.max,
};
};
/**
* Calculates the scroll constraints including min and max scroll values and the effective zoom level.
*
* @param params - Object containing scrollConstraints, width, height, zoom, and allowOverscroll.
* @returns An object with min and max scroll values, effective zoom, and initial zoom level.
*/
const calculateConstraints = ({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
allowOverscroll: boolean;
}) => {
const { effectiveZoom, initialZoomLevel, zoomLevelX, zoomLevelY } =
calculateZoom({ scrollConstraints, width, height, zoom });
const scrollBounds = calculateScrollBounds({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
});
return {
...scrollBounds,
effectiveZoom: { value: effectiveZoom },
initialZoomLevel,
};
};
/**
* Constrains the scroll values within the provided min and max bounds.
*
* @param params - Object containing scrollX, scrollY, minScrollX, maxScrollX, minScrollY, maxScrollY, and constrainedZoom.
* @returns An object with constrained scrollX, scrollY, and zoom.
*/
const constrainScrollValues = ({
scrollX,
scrollY,
minScrollX,
maxScrollX,
minScrollY,
maxScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
minScrollX: number;
maxScrollX: number;
minScrollY: number;
maxScrollY: number;
constrainedZoom: AppState["zoom"];
}): CanvasTranslate => {
const constrainedScrollX = clamp(scrollX, minScrollX, maxScrollX);
const constrainedScrollY = clamp(scrollY, minScrollY, maxScrollY);
return {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
};
/**
* Inverts the scroll constraints to align with the state scrollX and scrollY values, which are inverted.
* This is a temporary fix and should be removed once issue #5965 is resolved.
*
* @param originalScrollConstraints - The original scroll constraints.
* @returns The aligned scroll constraints with inverted x and y coordinates.
*/
const alignScrollConstraints = (
originalScrollConstraints: ScrollConstraints,
): ScrollConstraints => {
return {
...originalScrollConstraints,
x: originalScrollConstraints.x * -1,
y: originalScrollConstraints.y * -1,
};
};
/**
* Determines whether the current viewport is outside the constrained area.
*
* @param state - The application state.
* @returns True if the viewport is outside the constrained area, false otherwise.
*/
const isViewportOutsideOfConstrainedArea = (state: AppState): boolean => {
if (!state.scrollConstraints) {
return false;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const adjustedWidth = width / zoom.value;
const adjustedHeight = height / zoom.value;
return (
scrollX > scrollConstraints.x ||
scrollX - adjustedWidth < scrollConstraints.x - scrollConstraints.width ||
scrollY > scrollConstraints.y ||
scrollY - adjustedHeight < scrollConstraints.y - scrollConstraints.height
);
};
/**
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
*
* @param state - The application state.
* @param scroll - Object containing current scrollX and scrollY.
* @returns An object with the calculated scrollX, scrollY, and zoom.
*/
export const calculateConstrainedScrollCenter = (
state: AppState,
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
): CanvasTranslate => {
const { width, height, scrollConstraints } = state;
if (!scrollConstraints) {
return { scrollX, scrollY, zoom: state.zoom };
}
const adjustedConstraints = alignScrollConstraints(scrollConstraints);
const zoomLevels = calculateZoomLevel(adjustedConstraints, width, height);
const initialZoom = { value: zoomLevels.initialZoomLevel };
const constraints = calculateConstraints({
scrollConstraints: adjustedConstraints,
width,
height,
zoom: initialZoom,
allowOverscroll: false,
});
return {
scrollX: constraints.minScrollX,
scrollY: constraints.minScrollY,
zoom: constraints.effectiveZoom,
};
};
/**
* Encodes scroll constraints into a compact string.
*
* @param constraints - The scroll constraints to encode.
* @returns A compact encoded string representing the scroll constraints.
*/
export const encodeConstraints = (constraints: ScrollConstraints): string => {
const payload = {
x: constraints.x,
y: constraints.y,
w: constraints.width,
h: constraints.height,
a: !!constraints.animateOnNextUpdate,
l: !!constraints.lockZoom,
v: constraints.viewportZoomFactor ?? 1,
oa: constraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE,
};
const serialized = JSON.stringify(payload);
return encodeURIComponent(window.btoa(serialized).replace(/=+/, ""));
};
/**
* Decodes a compact string back into scroll constraints.
*
* @param encoded - The encoded string representing the scroll constraints.
* @returns The decoded scroll constraints object.
*/
export const decodeConstraints = (encoded: string): ScrollConstraints => {
try {
const decodedStr = window.atob(decodeURIComponent(encoded));
const parsed = JSON.parse(decodedStr) as {
x: number;
y: number;
w: number;
h: number;
a: boolean;
l: boolean;
v: number;
oa: number;
};
return {
x: parsed.x || 0,
y: parsed.y || 0,
width: parsed.w || 0,
height: parsed.h || 0,
lockZoom: parsed.l || false,
viewportZoomFactor: parsed.v || 1,
animateOnNextUpdate: parsed.a || false,
overscrollAllowance: parsed.oa || DEFAULT_OVERSCROLL_ALLOWANCE,
};
} catch (error) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
animateOnNextUpdate: false,
lockZoom: false,
viewportZoomFactor: 1,
overscrollAllowance: DEFAULT_OVERSCROLL_ALLOWANCE,
};
}
};
type Options = { allowOverscroll: boolean; disableAnimation: boolean };
const DEFAULT_OPTION: Options = {
allowOverscroll: true,
disableAnimation: false,
};
/**
* Constrains the AppState scroll values within the defined scroll constraints.
*
* constraintMode can be "elastic", "rigid", or "loose":
* - "elastic": snaps to constraints but allows overscroll
* - "rigid": snaps to constraints without overscroll
* - "loose": allows overscroll and disables animation/snapping to constraints
*
* @param state - The original AppState.
* @param options - Options for allowing overscroll and disabling animation.
* @returns A new AppState object with constrained scroll values.
*/
export const constrainScrollState = (
state: AppState,
constraintMode: "elastic" | "rigid" | "loose" = "elastic",
): AppState => {
if (!state.scrollConstraints) {
return state;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
let allowOverscroll: boolean;
let disableAnimation: boolean;
switch (constraintMode) {
case "elastic":
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
case "rigid":
allowOverscroll = false;
disableAnimation = false;
break;
case "loose":
allowOverscroll = true;
disableAnimation = true;
break;
default:
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
}
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const canUseMemoizedValues =
memoizedValues &&
memoizedValues.previousState.scrollConstraints &&
memoizedValues.allowOverscroll === allowOverscroll &&
isShallowEqual(
state.scrollConstraints,
memoizedValues.previousState.scrollConstraints,
) &&
isShallowEqual(
{ zoom: zoom.value, width, height },
{
zoom: memoizedValues.previousState.zoom.value,
width: memoizedValues.previousState.width,
height: memoizedValues.previousState.height,
},
);
const constraints = canUseMemoizedValues
? memoizedValues!.constraints
: calculateConstraints({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
});
if (!canUseMemoizedValues) {
memoizedValues = {
previousState: {
zoom: state.zoom,
width: state.width,
height: state.height,
scrollConstraints: state.scrollConstraints,
},
constraints,
allowOverscroll,
};
}
const constrainedValues =
zoom.value >= constraints.effectiveZoom.value
? constrainScrollValues({
scrollX,
scrollY,
minScrollX: constraints.minScrollX,
maxScrollX: constraints.maxScrollX,
minScrollY: constraints.minScrollY,
maxScrollY: constraints.maxScrollY,
constrainedZoom: constraints.effectiveZoom,
})
: calculateConstrainedScrollCenter(state, { scrollX, scrollY });
return {
...state,
scrollConstraints: {
...state.scrollConstraints,
animateOnNextUpdate: disableAnimation
? false
: isViewportOutsideOfConstrainedArea(state),
},
...constrainedValues,
};
};
/**
* Checks if two canvas translate values are close within a threshold.
*
* @param from - First set of canvas translate values.
* @param to - Second set of canvas translate values.
* @returns True if the values are close, false otherwise.
*/
export const areCanvasTranslatesClose = (
from: AnimateTranslateCanvasValues,
to: AnimateTranslateCanvasValues,
): boolean => {
const threshold = 0.1;
return (
Math.abs(from.scrollX - to.scrollX) < threshold &&
Math.abs(from.scrollY - to.scrollY) < threshold &&
Math.abs(from.zoom - to.zoom) < threshold
);
};
+62
View File
@@ -1,5 +1,67 @@
import { constrainScrollState } from "./scrollConstraints";
import type { AppState, NormalizedZoomValue } from "../types";
/**
* When zooming out with scroll constraints active, the cursor-anchored zoom may
* produce a scroll position outside the valid bounds, causing a snap-back.
*
* This function adjusts the effective zoom anchor point so the resulting scroll
* stays within bounds silently, without any snap. When no adjustment is needed
* (zoom-in, no constraints, or already within bounds) it returns the original
* viewport position unchanged.
*/
export const getConstrainedZoomAnchor = (
{
viewportX,
viewportY,
nextZoom,
}: { viewportX: number; viewportY: number; nextZoom: NormalizedZoomValue },
state: AppState,
): { viewportX: number; viewportY: number } => {
if (!state.scrollConstraints || nextZoom >= state.zoom.value) {
return { viewportX, viewportY };
}
const appLayerX = viewportX - state.offsetLeft;
const appLayerY = viewportY - state.offsetTop;
const factor = 1 / nextZoom - 1 / state.zoom.value;
const newScrollX = state.scrollX + appLayerX * factor;
const newScrollY = state.scrollY + appLayerY * factor;
const constrained = constrainScrollState(
{
...state,
scrollX: newScrollX,
scrollY: newScrollY,
zoom: { value: nextZoom },
},
"rigid",
);
if (
constrained.scrollX === newScrollX &&
constrained.scrollY === newScrollY
) {
return { viewportX, viewportY };
}
const adjustedAppLayerX =
constrained.scrollX !== newScrollX
? (constrained.scrollX - state.scrollX) / factor
: appLayerX;
const adjustedAppLayerY =
constrained.scrollY !== newScrollY
? (constrained.scrollY - state.scrollY) / factor
: appLayerY;
return {
viewportX: adjustedAppLayerX + state.offsetLeft,
viewportY: adjustedAppLayerY + state.offsetTop,
};
};
export const getStateForZoom = (
{
viewportX,
+84 -538
View File
@@ -39,14 +39,6 @@ import type {
} from "./types";
const SNAP_DISTANCE = 8;
// Keep snap candidates with effectively identical offsets together. This needs
// to be wider than the 6-decimal rounding quantum to absorb JS floating-point
// noise around values like 4 and 4.000001.
const SNAP_OFFSET_TOLERANCE = 0.00001;
// Point snaps can come from both nearby and distant references within snap
// distance. If their visual distances have a large break, prefer the nearest
// cluster before comparing offsets.
const SNAP_REFERENCE_CLUSTER_BREAK_DISTANCE = 200;
// do not comput more gaps per axis than this limit
// TODO increase or remove once we optimize
@@ -57,51 +49,16 @@ export const getSnapDistance = (zoomValue: number) => {
return SNAP_DISTANCE / zoomValue;
};
/**
* Keeps the cluster threshold visually stable across zoom levels.
*/
const getSnapReferenceClusterBreakDistance = (zoomValue: number) => {
return SNAP_REFERENCE_CLUSTER_BREAK_DISTANCE / zoomValue;
};
type Vector2D = {
x: number;
y: number;
};
type PointPair = [GlobalPoint, GlobalPoint];
type SnapPointType = "outer" | "center";
type SnapSourceId = string;
const SELECTION_SNAP_SOURCE_ID = "selection";
// Snap source ids let us filter redundant snaplines only within one
// selection-to-reference relationship, without mixing separate reference groups.
// For example, "selection->rectA" may collapse to its outermost snaplines,
// while "selection->rectB" still keeps its own independent snaplines.
type SnapPoint = {
point: GlobalPoint;
type: SnapPointType;
snapSourceId: SnapSourceId;
};
const outerSnapPoint = (
point: GlobalPoint,
snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint => ({
point,
type: "outer",
snapSourceId,
});
export type PointSnap = {
type: "point";
points: PointPair;
pointTypes: [SnapPointType, SnapPointType];
snapSourceIds: [SnapSourceId, SnapSourceId];
// Distance along the rendered snapline, used to group visually nearby
// references before choosing which point snaps should win.
visualDistance: number;
offset: number;
};
@@ -163,14 +120,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
// -----------------------------------------------------------------------------
export class SnapCache {
private static referenceSnapPoints: SnapPoint[] | null = null;
private static referenceSnapPoints: GlobalPoint[] | null = null;
private static visibleGaps: {
verticalGaps: Gap[];
horizontalGaps: Gap[];
} | null = null;
public static setReferenceSnapPoints = (snapPoints: SnapPoint[] | null) => {
public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => {
SnapCache.referenceSnapPoints = snapPoints;
};
@@ -238,17 +195,6 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => {
return Math.abs(a - b) <= precision;
};
/**
* Keeps nearly identical snap offsets in the same winner set. This prevents
* snapline flicker when rounded geometry differs by a tiny floating-point tail.
*/
const isWithinSnapOffset = (offset: number, minOffset: number) => {
return (
Math.abs(offset) <= minOffset ||
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE)
);
};
export const getElementsCorners = (
elements: ExcalidrawElement[],
elementsMap: ElementsMap,
@@ -366,81 +312,18 @@ export const getElementsCorners = (
return result.map((p) => pointFrom(round(p[0]), round(p[1])));
};
const getElementsSnapPoints = (
elements: ExcalidrawElement[],
elementsMap: ElementsMap,
options: Parameters<typeof getElementsCorners>[2] = {},
snapSourceId = SELECTION_SNAP_SOURCE_ID,
): SnapPoint[] => {
const points = getElementsCorners(elements, elementsMap, options);
// getElementsCorners() appends the center point last unless omitCenter is set.
const hasCenterPoint = !options.omitCenter && points.length > 0;
return points.map((point, index) => ({
point,
type: hasCenterPoint && index === points.length - 1 ? "center" : "outer",
snapSourceId,
}));
};
/**
* Builds a stable identity for one snap reference group. Sorting keeps grouped
* elements represented by the same snap source id regardless of element order.
*/
const getSnapSourceId = (elements: ExcalidrawElement[]) => {
return elements
.map((element) => element.id)
.sort()
.join(",");
};
/**
* Finds frames that the current selection already belongs to. Their children
* are still valid snap references; children of unrelated frames are not.
*/
const getSelectedFrameIdsForSnapping = (
selectedElements: readonly ExcalidrawElement[],
) => {
const selectedFrameIds = new Set<ExcalidrawElement["id"]>();
for (const element of selectedElements) {
if (element.frameId) {
selectedFrameIds.add(element.frameId);
}
}
return selectedFrameIds;
};
/**
* Frame children are only snap references when the dragged selection is inside
* the same frame. This avoids snapping external elements to internals of a
* framed diagram.
*/
const canUseElementAsSnapReference = (
element: NonDeletedExcalidrawElement,
selectedFrameIds: Set<ExcalidrawElement["id"]>,
) => {
return !element.frameId || selectedFrameIds.has(element.frameId);
};
const getReferenceElements = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: NonDeletedExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) => {
const selectedFrameIds = getSelectedFrameIdsForSnapping(selectedElements);
return getVisibleAndNonSelectedElements(
) =>
getVisibleAndNonSelectedElements(
elements,
selectedElements,
appState,
elementsMap,
).filter((element) => {
return canUseElementAsSnapReference(element, selectedFrameIds);
});
};
);
export const getVisibleGaps = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -599,10 +482,7 @@ const getGapSnaps = (
const centerOffset = round(gapMidX - centerX);
const gapIsLargerThanSelection = gap.length > maxX - minX;
if (
gapIsLargerThanSelection &&
isWithinSnapOffset(centerOffset, minOffset.x)
) {
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.x) {
if (Math.abs(centerOffset) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -624,7 +504,7 @@ const getGapSnaps = (
const distanceToEndElementX = minX - endMaxX;
const sideOffsetRight = round(gap.length - distanceToEndElementX);
if (isWithinSnapOffset(sideOffsetRight, minOffset.x)) {
if (Math.abs(sideOffsetRight) <= minOffset.x) {
if (Math.abs(sideOffsetRight) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -645,7 +525,7 @@ const getGapSnaps = (
const distanceToStartElementX = startMinX - maxX;
const sideOffsetLeft = round(distanceToStartElementX - gap.length);
if (isWithinSnapOffset(sideOffsetLeft, minOffset.x)) {
if (Math.abs(sideOffsetLeft) <= minOffset.x) {
if (Math.abs(sideOffsetLeft) < minOffset.x) {
nearestSnapsX.length = 0;
}
@@ -671,10 +551,7 @@ const getGapSnaps = (
const centerOffset = round(gapMidY - centerY);
const gapIsLargerThanSelection = gap.length > maxY - minY;
if (
gapIsLargerThanSelection &&
isWithinSnapOffset(centerOffset, minOffset.y)
) {
if (gapIsLargerThanSelection && Math.abs(centerOffset) <= minOffset.y) {
if (Math.abs(centerOffset) < minOffset.y) {
nearestSnapsY.length = 0;
}
@@ -696,7 +573,7 @@ const getGapSnaps = (
const distanceToStartElementY = startMinY - maxY;
const sideOffsetTop = round(distanceToStartElementY - gap.length);
if (isWithinSnapOffset(sideOffsetTop, minOffset.y)) {
if (Math.abs(sideOffsetTop) <= minOffset.y) {
if (Math.abs(sideOffsetTop) < minOffset.y) {
nearestSnapsY.length = 0;
}
@@ -717,7 +594,7 @@ const getGapSnaps = (
const distanceToEndElementY = round(minY - endMaxY);
const sideOffsetBottom = gap.length - distanceToEndElementY;
if (isWithinSnapOffset(sideOffsetBottom, minOffset.y)) {
if (Math.abs(sideOffsetBottom) <= minOffset.y) {
if (Math.abs(sideOffsetBottom) < minOffset.y) {
nearestSnapsY.length = 0;
}
@@ -736,102 +613,6 @@ const getGapSnaps = (
}
};
/**
* Narrows point snaps to the nearest visual cluster, then keeps the best offset
* inside that cluster. If all references look continuous, this preserves the
* old smallest-offset behavior.
*/
const filterPointSnapsToNearestCluster = (
snaps: Snaps,
clusterBreakDistance: number,
) => {
const pointSnaps = snaps.filter(
(snap): snap is PointSnap => snap.type === "point",
);
if (pointSnaps.length < 2) {
return;
}
const snapsByOffset = new Map<number, PointSnap[]>();
for (const snap of pointSnaps) {
const offset = round(snap.offset);
const offsetSnaps = snapsByOffset.get(offset) ?? [];
offsetSnaps.push(snap);
snapsByOffset.set(offset, offsetSnaps);
}
const keptPointSnaps = new Set<PointSnap>();
const offsetGroups = Array.from(snapsByOffset.entries()).map(
([offset, offsetSnaps]) => ({
offset,
snaps: offsetSnaps,
visualDistance: Math.min(
...offsetSnaps.map((snap) => snap.visualDistance),
),
}),
);
const sortedOffsetGroups = offsetGroups
.slice()
.sort((a, b) => a.visualDistance - b.visualDistance);
let keepOffsetGroupsUntil = sortedOffsetGroups.length;
// Prefer a nearby reference cluster before comparing offset. If there is no
// clear distance break, this keeps all offsets and falls back to the old
// smallest-offset behavior.
for (let i = 1; i < sortedOffsetGroups.length; i++) {
const distanceGap =
sortedOffsetGroups[i].visualDistance -
sortedOffsetGroups[i - 1].visualDistance;
if (distanceGap > clusterBreakDistance) {
keepOffsetGroupsUntil = i;
break;
}
}
const nearestOffsetGroups = sortedOffsetGroups.slice(
0,
keepOffsetGroupsUntil,
);
const minOffset = Math.min(
...nearestOffsetGroups.map(({ offset }) => Math.abs(offset)),
);
const selectedOffsets = nearestOffsetGroups
.filter(({ offset }) =>
areRoughlyEqual(Math.abs(offset), minOffset, SNAP_OFFSET_TOLERANCE),
)
.map(({ offset }) => offset);
for (const offsetSnaps of snapsByOffset.values()) {
if (
!selectedOffsets.some((offset) =>
areRoughlyEqual(
round(offsetSnaps[0].offset),
offset,
SNAP_OFFSET_TOLERANCE,
),
)
) {
continue;
}
offsetSnaps.forEach((snap) => keptPointSnaps.add(snap));
}
let writeIndex = 0;
for (const snap of snaps) {
if (snap.type !== "point" || keptPointSnaps.has(snap)) {
snaps[writeIndex] = snap;
writeIndex++;
}
}
snaps.length = writeIndex;
};
export const getReferenceSnapPoints = (
elements: readonly NonDeletedExcalidrawElement[],
selectedElements: ExcalidrawElement[],
@@ -849,24 +630,12 @@ export const getReferenceSnapPoints = (
(elementsGroup) =>
!(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])),
)
.flatMap((elementGroup) =>
getElementsSnapPoints(
elementGroup,
elementsMap,
{},
getSnapSourceId(elementGroup),
),
);
.flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap));
};
/**
* Collects point snaps within snap distance. Unlike gap snaps, point snaps first
* go through visual-cluster filtering so a nearby reference can beat a slightly
* better offset from a far-away reference.
*/
const getPointSnaps = (
selectedElements: ExcalidrawElement[],
selectionSnapPoints: SnapPoint[],
selectionSnapPoints: GlobalPoint[],
app: AppClassProperties,
event: KeyboardModifiersObject,
nearestSnapsX: Snaps,
@@ -885,62 +654,39 @@ const getPointSnaps = (
if (referenceSnapPoints) {
for (const thisSnapPoint of selectionSnapPoints) {
for (const otherSnapPoint of referenceSnapPoints) {
const offsetX = otherSnapPoint.point[0] - thisSnapPoint.point[0];
const offsetY = otherSnapPoint.point[1] - thisSnapPoint.point[1];
const offsetX = otherSnapPoint[0] - thisSnapPoint[0];
const offsetY = otherSnapPoint[1] - thisSnapPoint[1];
if (Math.abs(offsetX) <= minOffset.x) {
if (Math.abs(offsetX) < minOffset.x) {
nearestSnapsX.length = 0;
}
if (isWithinSnapOffset(offsetX, minOffset.x)) {
nearestSnapsX.push({
type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs(
otherSnapPoint.point[1] - thisSnapPoint.point[1],
),
points: [thisSnapPoint, otherSnapPoint],
offset: offsetX,
});
minOffset.x = Math.abs(offsetX);
}
if (isWithinSnapOffset(offsetY, minOffset.y)) {
if (Math.abs(offsetY) <= minOffset.y) {
if (Math.abs(offsetY) < minOffset.y) {
nearestSnapsY.length = 0;
}
nearestSnapsY.push({
type: "point",
points: [thisSnapPoint.point, otherSnapPoint.point],
pointTypes: [thisSnapPoint.type, otherSnapPoint.type],
snapSourceIds: [
thisSnapPoint.snapSourceId,
otherSnapPoint.snapSourceId,
],
visualDistance: Math.abs(
otherSnapPoint.point[0] - thisSnapPoint.point[0],
),
points: [thisSnapPoint, otherSnapPoint],
offset: offsetY,
});
minOffset.y = Math.abs(offsetY);
}
}
}
}
const clusterBreakDistance = getSnapReferenceClusterBreakDistance(
app.state.zoom.value,
);
filterPointSnapsToNearestCluster(nearestSnapsX, clusterBreakDistance);
filterPointSnapsToNearestCluster(nearestSnapsY, clusterBreakDistance);
if (nearestSnapsX.length > 0) {
minOffset.x = Math.min(
...nearestSnapsX.map((snap) => Math.abs(snap.offset)),
);
}
if (nearestSnapsY.length > 0) {
minOffset.y = Math.min(
...nearestSnapsY.map((snap) => Math.abs(snap.offset)),
);
}
};
export const snapDraggedElements = (
@@ -974,7 +720,7 @@ export const snapDraggedElements = (
y: snapDistance,
};
const selectionPoints = getElementsSnapPoints(selectedElements, elementsMap, {
const selectionPoints = getElementsCorners(selectedElements, elementsMap, {
dragOffset,
});
@@ -1024,7 +770,7 @@ export const snapDraggedElements = (
getPointSnaps(
selectedElements,
getElementsSnapPoints(selectedElements, elementsMap, {
getElementsCorners(selectedElements, elementsMap, {
dragOffset: newDragOffset,
}),
app,
@@ -1079,185 +825,12 @@ const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
return Array.from(map.values());
};
// Point snaplines are collected from every winning point snap first. Multiple
// snap source pairs can collapse to the same rendered line, so candidates keep
// source-pair metadata until redundant center and inner outer lines are removed.
type PointSnapLineSourcePair = {
snapSourcePairKey: string;
pointType: SnapPointType;
};
type PointSnapLineCandidate = PointSnapLine & {
sourcePairs: PointSnapLineSourcePair[];
};
type PointSnapLineBucket = {
points: GlobalPoint[];
sourcePairs: PointSnapLineSourcePair[];
};
type OuterSnapLineCoordinateRange = {
count: number;
min: number;
max: number;
};
/**
* Describes which snap source pair produced a rendered point snapline, so later
* filtering can reason about redundancy without mixing unrelated references.
* Only center-to-center snaps count as center snaplines; a center-to-outer snap
* still explains an outer edge or midpoint alignment.
*/
const getPointSnapLineSourcePair = (
snap: PointSnap,
): PointSnapLineSourcePair => {
return {
snapSourcePairKey: snap.snapSourceIds.join("->"),
pointType: snap.pointTypes.every((pointType) => pointType === "center")
? "center"
: "outer",
};
};
/**
* Builds each snap source pair's outer coordinate range. Center snaplines use
* this range to decide whether the surrounding outer lines already explain
* them, and outer snaplines use it to keep only the two extremes.
*/
const getOuterCoordinateRangesBySnapSourcePairKey = (
snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number,
) => {
const ranges = new Map<string, OuterSnapLineCoordinateRange>();
for (const snapLine of snapLines) {
const coordinate = getCoordinate(snapLine);
for (const sourcePair of snapLine.sourcePairs) {
if (sourcePair.pointType === "center") {
continue;
}
const range = ranges.get(sourcePair.snapSourcePairKey);
ranges.set(sourcePair.snapSourcePairKey, {
count: (range?.count ?? 0) + 1,
min: range ? Math.min(range.min, coordinate) : coordinate,
max: range ? Math.max(range.max, coordinate) : coordinate,
});
}
}
return ranges;
};
/**
* A center snapline is redundant when outer snaplines from the same snap source
* pair exist on both sides, because the outer lines already imply center
* alignment.
*/
const isRedundantCenterSnapLine = (
coordinate: number,
outerCoordinateRange?: OuterSnapLineCoordinateRange,
) => {
if (!outerCoordinateRange) {
return false;
}
return (
outerCoordinateRange.min < coordinate &&
outerCoordinateRange.max > coordinate
);
};
/**
* When more than two outer snaplines come from the same snap source pair, the
* inner ones are redundant; the two extremes communicate the same shape
* alignment.
*/
const isRedundantOuterSnapLine = (
coordinate: number,
outerCoordinateRange?: OuterSnapLineCoordinateRange,
) => {
if (!outerCoordinateRange || outerCoordinateRange.count <= 2) {
return false;
}
return (
coordinate !== outerCoordinateRange.min &&
coordinate !== outerCoordinateRange.max
);
};
/**
* Keeps a visual snapline if at least one snap source pair represented by the
* line still needs it. This matters when multiple snap source pairs collapse to
* the same rendered coordinate.
*/
const isPointSnapLineNeededBySourcePair = (
sourcePair: PointSnapLineSourcePair,
coordinate: number,
outerCoordinateRangesBySnapSourcePairKey: Map<
string,
OuterSnapLineCoordinateRange
>,
) => {
const outerCoordinateRange = outerCoordinateRangesBySnapSourcePairKey.get(
sourcePair.snapSourcePairKey,
);
if (sourcePair.pointType === "center") {
return !isRedundantCenterSnapLine(coordinate, outerCoordinateRange);
}
return !isRedundantOuterSnapLine(coordinate, outerCoordinateRange);
};
/**
* Removes point snaplines that are visually redundant, while preserving any line
* that is still needed by at least one snap source pair represented on that
* line.
*/
const filterRedundantPointSnapLines = (
snapLines: PointSnapLineCandidate[],
getCoordinate: (snapLine: PointSnapLine) => number,
): PointSnapLine[] => {
if (snapLines.length < 3) {
return snapLines.map(({ sourcePairs, ...snapLine }) => snapLine);
}
// Track outer-line ranges per snap source pair. The same visual snapline can
// be produced by multiple snap source pairs, so a line is removed only when
// every pair represented by that line considers it redundant.
const outerCoordinateRangesBySnapSourcePairKey =
getOuterCoordinateRangesBySnapSourcePairKey(snapLines, getCoordinate);
return snapLines
.filter((snapLine) => {
const coordinate = getCoordinate(snapLine);
return snapLine.sourcePairs.some((sourcePair) =>
isPointSnapLineNeededBySourcePair(
sourcePair,
coordinate,
outerCoordinateRangesBySnapSourcePairKey,
),
);
})
.map(({ sourcePairs, ...snapLine }) => snapLine);
};
/**
* Merges point snaps that render on the same x/y coordinate, attaches their
* snap source pair metadata, then removes redundant center and inner outer
* lines.
*/
const createPointSnapLines = (
nearestSnapsX: Snaps,
nearestSnapsY: Snaps,
): PointSnapLine[] => {
const snapsX = {} as { [key: string]: PointSnapLineBucket };
const snapsY = {} as { [key: string]: PointSnapLineBucket };
const snapsX = {} as { [key: string]: GlobalPoint[] };
const snapsY = {} as { [key: string]: GlobalPoint[] };
if (nearestSnapsX.length > 0) {
for (const snap of nearestSnapsX) {
@@ -1265,14 +838,13 @@ const createPointSnapLines = (
// key = thisPoint.x
const key = round(snap.points[0][0]);
if (!snapsX[key]) {
snapsX[key] = { points: [], sourcePairs: [] };
snapsX[key] = [];
}
snapsX[key].points.push(
snapsX[key].push(
...snap.points.map((p) =>
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
),
);
snapsX[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
}
}
}
@@ -1283,55 +855,44 @@ const createPointSnapLines = (
// key = thisPoint.y
const key = round(snap.points[0][1]);
if (!snapsY[key]) {
snapsY[key] = { points: [], sourcePairs: [] };
snapsY[key] = [];
}
snapsY[key].points.push(
snapsY[key].push(
...snap.points.map((p) =>
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
),
);
snapsY[key].sourcePairs.push(getPointSnapLineSourcePair(snap));
}
}
}
const snapLinesX = Object.entries(snapsX).map(([key, snap]) => {
return {
type: "points",
points: dedupePoints(
snap.points
.map((p) => {
return pointFrom<GlobalPoint>(Number(key), p[1]);
})
.sort((a, b) => a[1] - b[1]),
),
sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate;
});
const snapLinesY = Object.entries(snapsY).map(([key, snap]) => {
return {
type: "points",
points: dedupePoints(
snap.points
.map((p) => {
return pointFrom<GlobalPoint>(p[0], Number(key));
})
.sort((a, b) => a[0] - b[0]),
),
sourcePairs: snap.sourcePairs,
} as PointSnapLineCandidate;
});
return filterRedundantPointSnapLines(
snapLinesX,
(snapLine) => snapLine.points[0][0],
).concat(
filterRedundantPointSnapLines(
snapLinesY,
(snapLine) => snapLine.points[0][1],
),
);
return Object.entries(snapsX)
.map(([key, points]) => {
return {
type: "points",
points: dedupePoints(
points
.map((p) => {
return pointFrom<GlobalPoint>(Number(key), p[1]);
})
.sort((a, b) => a[1] - b[1]),
),
} as PointSnapLine;
})
.concat(
Object.entries(snapsY).map(([key, points]) => {
return {
type: "points",
points: dedupePoints(
points
.map((p) => {
return pointFrom<GlobalPoint>(p[0], Number(key));
})
.sort((a, b) => a[0] - b[0]),
),
} as PointSnapLine;
}),
);
};
const dedupeGapSnapLines = (gapSnapLines: GapSnapLine[]) => {
@@ -1356,13 +917,10 @@ const createGapSnapLines = (
dragOffset: Vector2D,
gapSnaps: GapSnap[],
): GapSnapLine[] => {
// Use the same rounded bounds as getGapSnaps(). Otherwise a gap snap can be
// accepted during snapping but skipped here because the raw bounds miss the
// reference gap overlap by a tiny floating-point delta.
const [minX, minY, maxX, maxY] = getDraggedElementsBounds(
selectedElements,
dragOffset,
).map((bound) => round(bound));
);
const gapSnapLines: GapSnapLine[] = [];
@@ -1585,52 +1143,40 @@ export const snapResizingElements = (
}
}
const selectionSnapPoints: SnapPoint[] = [];
const selectionSnapPoints: GlobalPoint[] = [];
if (transformHandle) {
switch (transformHandle) {
case "e": {
selectionSnapPoints.push(
outerSnapPoint(pointFrom(maxX, minY)),
outerSnapPoint(pointFrom(maxX, maxY)),
);
selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY));
break;
}
case "w": {
selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, minY)),
outerSnapPoint(pointFrom(minX, maxY)),
);
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY));
break;
}
case "n": {
selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, minY)),
outerSnapPoint(pointFrom(maxX, minY)),
);
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY));
break;
}
case "s": {
selectionSnapPoints.push(
outerSnapPoint(pointFrom(minX, maxY)),
outerSnapPoint(pointFrom(maxX, maxY)),
);
selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY));
break;
}
case "ne": {
selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, minY)));
selectionSnapPoints.push(pointFrom(maxX, minY));
break;
}
case "nw": {
selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, minY)));
selectionSnapPoints.push(pointFrom(minX, minY));
break;
}
case "se": {
selectionSnapPoints.push(outerSnapPoint(pointFrom(maxX, maxY)));
selectionSnapPoints.push(pointFrom(maxX, maxY));
break;
}
case "sw": {
selectionSnapPoints.push(outerSnapPoint(pointFrom(minX, maxY)));
selectionSnapPoints.push(pointFrom(minX, maxY));
break;
}
}
@@ -1672,11 +1218,11 @@ export const snapResizingElements = (
round(bound),
);
const corners: SnapPoint[] = [
outerSnapPoint(pointFrom(x1, y1)),
outerSnapPoint(pointFrom(x1, y2)),
outerSnapPoint(pointFrom(x2, y1)),
outerSnapPoint(pointFrom(x2, y2)),
const corners: GlobalPoint[] = [
pointFrom(x1, y1),
pointFrom(x1, y2),
pointFrom(x2, y1),
pointFrom(x2, y2),
];
getPointSnaps(
@@ -1712,8 +1258,8 @@ export const snapNewElement = (
};
}
const selectionSnapPoints: SnapPoint[] = [
outerSnapPoint(pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y)),
const selectionSnapPoints: GlobalPoint[] = [
pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y),
];
const snapDistance = getSnapDistance(app.state.zoom.value);
@@ -1746,7 +1292,7 @@ export const snapNewElement = (
nearestSnapsX.length = 0;
nearestSnapsY.length = 0;
const corners = getElementsSnapPoints([newElement], elementsMap, {
const corners = getElementsCorners([newElement], elementsMap, {
boundingBoxCorners: true,
omitCenter: true,
});
@@ -961,6 +961,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -979,6 +980,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1157,6 +1159,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -1172,6 +1175,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1371,6 +1375,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1386,6 +1391,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1702,6 +1708,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1717,6 +1724,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2033,6 +2041,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -2048,6 +2057,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2247,6 +2257,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2260,6 +2271,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2488,6 +2500,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2503,6 +2516,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2788,6 +2802,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2806,6 +2821,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3158,6 +3174,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3173,6 +3190,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3651,6 +3669,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3666,6 +3685,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3974,6 +3994,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3989,6 +4010,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4299,6 +4321,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4315,6 +4338,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5584,6 +5608,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5600,6 +5625,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6801,6 +6827,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -6819,6 +6846,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7759,6 +7787,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7772,6 +7801,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8756,6 +8786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": true,
@@ -8771,6 +8802,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9750,6 +9782,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9765,6 +9798,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -85,6 +85,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -719,6 +720,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1282,6 +1284,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1293,6 +1296,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1642,6 +1646,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -1653,6 +1658,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2004,6 +2010,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2015,6 +2022,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2264,6 +2272,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2277,40 +2286,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": {
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id4",
"type": "arrow",
},
],
"customData": undefined,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 100,
"id": "id0",
"index": "a0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 493213705,
"width": 100,
"x": -100,
"y": -50,
},
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2754,6 +2730,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -2765,6 +2742,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3057,6 +3035,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3068,6 +3047,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3376,6 +3356,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3387,6 +3368,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3670,6 +3652,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3681,6 +3664,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3956,6 +3940,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -3967,6 +3952,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4191,6 +4177,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4202,6 +4189,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4448,6 +4436,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4459,6 +4448,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4719,6 +4709,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4730,6 +4721,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4948,6 +4940,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -4959,6 +4952,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5177,6 +5171,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5188,6 +5183,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5424,6 +5420,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5435,6 +5432,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5680,6 +5678,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5691,6 +5690,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5937,6 +5937,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -5948,6 +5949,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6266,6 +6268,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id8": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6277,6 +6280,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6693,6 +6697,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -6707,6 +6712,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7068,6 +7074,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7087,6 +7094,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7377,6 +7385,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7670,6 +7679,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7681,6 +7691,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7900,6 +7911,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -7911,6 +7923,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8252,6 +8265,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8263,6 +8277,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8607,6 +8622,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -8621,6 +8637,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9010,6 +9027,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9021,6 +9039,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9289,6 +9308,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9302,6 +9322,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9553,6 +9574,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9566,6 +9588,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9818,6 +9841,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -9831,6 +9855,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10053,6 +10078,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10064,6 +10090,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10347,6 +10374,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10665,6 +10693,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10676,6 +10705,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10904,6 +10934,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -10915,6 +10946,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11826,6 +11858,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -11839,6 +11872,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12086,6 +12120,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12097,6 +12132,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12321,6 +12357,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12334,6 +12371,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12558,6 +12596,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12569,6 +12608,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12952,6 +12992,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -12965,6 +13006,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13159,6 +13201,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13172,6 +13215,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13369,6 +13413,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13383,6 +13428,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13667,6 +13713,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -13681,6 +13728,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13968,6 +14016,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": -50,
"scrollY": -50,
"searchMatches": null,
@@ -13979,6 +14028,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14210,6 +14260,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14223,6 +14274,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14447,6 +14499,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14460,6 +14513,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14686,6 +14740,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14697,6 +14752,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14931,6 +14987,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -14944,6 +15001,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15265,6 +15323,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15276,6 +15335,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15432,6 +15492,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15448,6 +15509,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15719,6 +15781,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15730,6 +15793,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -15982,6 +16046,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -15993,6 +16058,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16134,6 +16200,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16147,6 +16214,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16417,6 +16485,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -16428,6 +16497,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -16578,6 +16648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -17327,6 +17398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -17974,6 +18046,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -18619,6 +18692,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -19371,6 +19445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20140,6 +20215,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20154,6 +20230,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -20620,6 +20697,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"id1": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -20636,6 +20714,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -21131,6 +21210,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -21142,6 +21222,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -21590,6 +21671,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"searchMatches": null,
@@ -88,6 +88,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -106,6 +107,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -514,6 +516,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -534,6 +537,7 @@ exports[`given element A and group of elements B and given both are selected whe
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -926,6 +930,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -941,6 +946,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1492,6 +1498,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1507,6 +1514,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -1701,6 +1709,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -1719,6 +1728,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2085,6 +2095,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2100,6 +2111,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2328,6 +2340,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2343,6 +2356,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2510,6 +2524,7 @@ exports[`regression tests > can drag element that covers another element, while
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2525,6 +2540,7 @@ exports[`regression tests > can drag element that covers another element, while
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -2833,6 +2849,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -2848,6 +2865,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3090,6 +3108,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3105,6 +3124,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3331,6 +3351,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3346,6 +3367,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3567,6 +3589,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3582,6 +3605,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -3825,6 +3849,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -3841,6 +3866,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4137,6 +4163,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4154,6 +4181,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4576,6 +4604,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4617,6 +4646,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -4859,6 +4889,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -4872,6 +4903,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5134,6 +5166,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5175,6 +5208,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5342,6 +5376,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5355,6 +5390,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5540,6 +5576,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5555,6 +5592,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -5936,6 +5974,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -5952,6 +5991,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -6230,6 +6270,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7022,6 +7063,7 @@ exports[`regression tests > given a group of selected elements with an element t
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7037,6 +7079,7 @@ exports[`regression tests > given a group of selected elements with an element t
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7355,6 +7398,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7371,6 +7415,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7634,6 +7679,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7649,6 +7695,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -7869,6 +7916,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -7884,6 +7932,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8107,6 +8156,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8122,6 +8172,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8287,6 +8338,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8302,6 +8354,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8467,6 +8520,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8482,6 +8536,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -8647,6 +8702,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -8880,6 +8936,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9111,6 +9168,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9124,6 +9182,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9303,6 +9362,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9536,6 +9596,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9551,6 +9612,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -9716,6 +9778,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9947,6 +10010,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -9962,6 +10026,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10127,6 +10192,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10140,6 +10206,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10319,6 +10386,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10334,6 +10402,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -10503,6 +10572,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -10522,6 +10592,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11032,6 +11103,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11047,6 +11119,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11310,6 +11383,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": "-6.25000",
"scrollY": 0,
"scrolledOutside": false,
@@ -11323,6 +11397,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11435,6 +11510,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11448,6 +11524,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11636,6 +11713,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11652,6 +11730,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -11956,6 +12035,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -11973,6 +12053,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -12386,6 +12467,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -12406,6 +12488,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13024,6 +13107,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 60,
"scrollY": 60,
"scrolledOutside": false,
@@ -13037,6 +13121,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13149,6 +13234,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -13164,6 +13250,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -13781,6 +13868,7 @@ exports[`regression tests > switches from group of selected elements to another
"id6": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -13824,6 +13912,7 @@ exports[`regression tests > switches from group of selected elements to another
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14119,6 +14208,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id3": true,
},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14162,6 +14252,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14381,6 +14472,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 20,
"scrollY": "-18.53553",
"scrolledOutside": false,
@@ -14394,6 +14486,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14504,6 +14597,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14869,6 +14963,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -14882,6 +14977,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -14995,6 +15091,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -15008,6 +15105,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
@@ -0,0 +1,135 @@
import React from "react";
import { vi } from "vitest";
import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { act, render, waitFor } from "./test-utils";
import type { ExcalidrawImperativeAPI, ScrollConstraints } from "../types";
const FIRST_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 0,
y: 0,
width: 400,
height: 300,
lockZoom: true,
overscrollAllowance: 0,
viewportZoomFactor: 1,
animateOnNextUpdate: false,
};
const SECOND_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 100,
y: 200,
width: 500,
height: 350,
lockZoom: false,
overscrollAllowance: 0.2,
viewportZoomFactor: 0.8,
animateOnNextUpdate: false,
};
describe("scrollConstraints", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("syncs prop updates after mount", async () => {
const { rerender } = await render(<Excalidraw />);
expect(window.h.state.scrollConstraints).toBe(null);
rerender(<Excalidraw scrollConstraints={FIRST_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
FIRST_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw scrollConstraints={SECOND_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
SECOND_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toBe(null);
});
});
it("ignores setScrollConstraints() when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
act(() => {
excalidrawAPI.setScrollConstraints(SECOND_SCROLL_CONSTRAINTS);
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
it("ignores scrollToContent() scrollLock when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
const rectangle = API.createElement({
x: 1000,
y: 1000,
width: 100,
height: 100,
});
API.setElements([rectangle]);
act(() => {
excalidrawAPI.scrollToContent(rectangle, {
animate: false,
fitToViewport: true,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
});
@@ -615,32 +615,6 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([]);
});
it("should not select a framed element when selection only overlaps its clipped-out outline", () => {
const frame = API.createElement({
type: "frame",
x: 100,
y: 100,
width: 100,
height: 100,
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 200,
height: 200,
frameId: frame.id,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(40, 170, 70, 220);
assertSelectedElements([]);
});
});
describe("inner box-selection", () => {
-833
View File
@@ -1,833 +0,0 @@
import { arrayToMap } from "@excalidraw/common";
import {
pointFrom,
rangeInclusive,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getDefaultAppState } from "../appState";
import {
getElementsCorners,
getVisibleGaps,
getReferenceSnapPoints,
SnapCache,
snapDraggedElements,
} from "../snapping";
import { API } from "./helpers/api";
import type {
AppClassProperties,
AppState,
NormalizedZoomValue,
} from "../types";
type ReferenceSnapPoints = NonNullable<
ReturnType<typeof SnapCache.getReferenceSnapPoints>
>;
const NO_MODIFIER_KEYS = {
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
};
const createSnappingApp = (appState: Partial<AppState> = {}) =>
({
props: {},
state: {
...getDefaultAppState(),
objectsSnapModeEnabled: true,
width: 1000,
height: 1000,
offsetLeft: 0,
offsetTop: 0,
...appState,
},
} as AppClassProperties);
const getHorizontalPointSnapLineCoordinates = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines
.filter((snapLine) => snapLine.type === "points")
.filter((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
})
.map((snapLine) => {
return snapLine.points[0][1];
})
.sort((a, b) => a - b);
};
const getVerticalPointSnapLineCoordinates = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines
.filter((snapLine) => snapLine.type === "points")
.filter((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[0] === lastPoint[0];
})
.map((snapLine) => {
return snapLine.points[0][0];
})
.sort((a, b) => a - b);
};
const getHorizontalPointSnapLineMaxX = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
const horizontalSnapLine = snapLines
.filter((snapLine) => snapLine.type === "points")
.find((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
});
if (!horizontalSnapLine) {
return null;
}
return horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0];
};
const getHorizontalPointSnapLineXRange = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
const horizontalSnapLine = snapLines
.filter((snapLine) => snapLine.type === "points")
.find((snapLine) => {
const [firstPoint, lastPoint] = snapLine.points;
return firstPoint[1] === lastPoint[1];
});
if (!horizontalSnapLine) {
return null;
}
return [
horizontalSnapLine.points[0][0],
horizontalSnapLine.points[horizontalSnapLine.points.length - 1][0],
] as const;
};
const getHorizontalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) =>
snapLine.type === "gap" && snapLine.direction === "horizontal",
);
};
const getVerticalGapSnapLines = (
snapLines: ReturnType<typeof snapDraggedElements>["snapLines"],
) => {
return snapLines.filter(
(snapLine) => snapLine.type === "gap" && snapLine.direction === "vertical",
);
};
const getPointKeys = (points: ReturnType<typeof getElementsCorners>) => {
return points.map((point) => point.join(","));
};
const getReferenceSnapPointKeys = (
elements: ExcalidrawElement[],
selectedElements: ExcalidrawElement[],
app: AppClassProperties,
) => {
return new Set(
getReferenceSnapPoints(
elements,
selectedElements,
app.state,
arrayToMap(elements),
).map((snapPoint) => snapPoint.point.join(",")),
);
};
const primeReferenceSnapPoints = (
elements: ExcalidrawElement[],
selectedElements: ExcalidrawElement[],
) => {
const selectedElementIds = new Set(
selectedElements.map((element) => element.id),
);
const elementsMap = arrayToMap(elements);
SnapCache.setReferenceSnapPoints(
elements
.filter((element) => !selectedElementIds.has(element.id))
.flatMap((element) => {
const corners = getElementsCorners([element], elementsMap);
return corners.map((point, index) => ({
point,
type: index === corners.length - 1 ? "center" : "outer",
snapSourceId: element.id,
}));
}) as Parameters<typeof SnapCache.setReferenceSnapPoints>[0],
);
};
describe("snapping", () => {
afterEach(() => {
SnapCache.destroy();
});
it("does not use frame children as references when snapping outside elements", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const frameChild = API.createElement({
type: "rectangle",
id: "frameChild",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 400,
y: 50,
width: 100,
height: 100,
});
const elements = [frame, frameChild, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[selected],
app,
);
const frameChildPointKeys = getPointKeys(
getElementsCorners([frameChild], arrayToMap(elements)),
);
expect(
frameChildPointKeys.some((pointKey) =>
referenceSnapPointKeys.has(pointKey),
),
).toBe(false);
});
it("uses frame siblings as references when snapping elements in the same frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const sibling = API.createElement({
type: "rectangle",
id: "sibling",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 150,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const elements = [frame, sibling, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[selected],
app,
);
const siblingPointKeys = getPointKeys(
getElementsCorners([sibling], arrayToMap(elements)),
);
expect(
siblingPointKeys.some((pointKey) => referenceSnapPointKeys.has(pointKey)),
).toBe(true);
});
it("does not use frame children as references when snapping the frame itself", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 300,
height: 300,
});
const frameChild = API.createElement({
type: "rectangle",
id: "frameChild",
x: 37,
y: 53,
width: 71,
height: 83,
frameId: frame.id,
});
const elements = [frame, frameChild];
const app = createSnappingApp({
selectedElementIds: { [frame.id]: true },
});
const referenceSnapPointKeys = getReferenceSnapPointKeys(
elements,
[frame],
app,
);
const frameChildPointKeys = getPointKeys(
getElementsCorners([frameChild], arrayToMap(elements)),
);
expect(
frameChildPointKeys.some((pointKey) =>
referenceSnapPointKeys.has(pointKey),
),
).toBe(false);
});
it("does not use frame children as visible gap references when snapping outside elements", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 500,
height: 300,
});
const frameChildA = API.createElement({
type: "rectangle",
id: "frameChildA",
x: 50,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const frameChildB = API.createElement({
type: "rectangle",
id: "frameChildB",
x: 250,
y: 50,
width: 100,
height: 100,
frameId: frame.id,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 700,
y: 50,
width: 100,
height: 100,
});
const elements = [frame, frameChildA, frameChildB, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const visibleGaps = getVisibleGaps(
elements,
[selected],
app.state,
arrayToMap(elements),
);
expect(visibleGaps.horizontalGaps).toHaveLength(0);
expect(visibleGaps.verticalGaps).toHaveLength(0);
});
it("filters center and inner outer point snaplines for the same reference", () => {
const angle = 0.68 as Radians;
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 140,
height: 140,
angle,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 0,
width: 140,
height: 140,
angle,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toHaveLength(2);
});
it("keeps a snapline that is redundant for one reference but needed for another", () => {
const angle = 0.68 as Radians;
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 0,
width: 140,
height: 140,
angle,
});
const elements = [selected];
const elementsMap = arrayToMap(elements);
const selectedSnapPoints = getElementsCorners([selected], elementsMap);
const outerSnapPoints = selectedSnapPoints.slice(0, -1);
const centerSnapPoint = selectedSnapPoints[selectedSnapPoints.length - 1];
const innerOuterSnapPoint = [...outerSnapPoints].sort(
(a, b) => a[1] - b[1],
)[1];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
const referenceSnapPoints: ReferenceSnapPoints = [
...outerSnapPoints.map((point) => ({
point: pointFrom<GlobalPoint>(point[0] - 200, point[1]),
type: "outer" as const,
snapSourceId: "referenceA",
})),
{
point: pointFrom<GlobalPoint>(
centerSnapPoint[0] - 200,
centerSnapPoint[1],
),
type: "center" as const,
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(
innerOuterSnapPoint[0] - 300,
innerOuterSnapPoint[1],
),
type: "outer" as const,
snapSourceId: "referenceB",
},
];
SnapCache.setReferenceSnapPoints(referenceSnapPoints);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
elementsMap,
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toHaveLength(3);
});
it("keeps a center snapline when no outer snaplines imply it", () => {
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 100,
height: 100,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 200,
y: 25,
width: 50,
height: 50,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineCoordinates(snapLines)).toEqual([50]);
});
it("filters center snaplines when matching outer offsets differ by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563984471,
y: -1553.9657067952232,
width: 140.1015625,
height: 140.1015625,
});
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 2532.2275640966914,
y: -1299.4323092037737,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalPointSnapLineCoordinates(snapLines)).toEqual([
2532.227564, 2672.329126,
]);
});
it("keeps outer snaplines stable while dragging a snapped element through rounding-equivalent offsets", () => {
const referenceMiddle = API.createElement({
type: "rectangle",
id: "referenceMiddle",
x: 2532.22756398447,
y: -1553.9657067952237,
width: 140.1015625,
height: 140.1015625,
});
const referenceAbove = API.createElement({
type: "rectangle",
id: "referenceAbove",
x: 2532.2275637826165,
y: -1779.7363232531268,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 2532.227563096691,
y: -1328.1950902037736,
width: 140.1015625,
height: 140.1015625,
});
const elements = [referenceAbove, referenceMiddle, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
primeReferenceSnapPoints(elements, [selected]);
for (const dragOffsetX of [-4, -1, -0.1, 0, 0.1, 1, 4]) {
const { snapLines } = snapDraggedElements(
elements,
{ x: dragOffsetX, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const coordinates = getVerticalPointSnapLineCoordinates(snapLines);
expect(coordinates).toHaveLength(2);
expect(coordinates[1] - coordinates[0]).toBeCloseTo(selected.width, 5);
}
});
it("keeps same-offset point snaps even across distant references", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(220, 50),
type: "center",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
snapSourceId: "far",
},
]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(900);
});
it("prefers a nearby point snap over a slightly better far offset", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(220, 54),
type: "center",
snapSourceId: "near",
},
{
point: pointFrom<GlobalPoint>(900, 50),
type: "center",
snapSourceId: "far",
},
]);
const { snapOffset, snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(snapOffset.y).toBe(4);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(220);
});
it("keeps same-offset point snaps when references form a continuous cluster", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(200, 50),
type: "center",
snapSourceId: "referenceA",
},
{
point: pointFrom<GlobalPoint>(350, 50),
type: "center",
snapSourceId: "referenceB",
},
{
point: pointFrom<GlobalPoint>(500, 50),
type: "center",
snapSourceId: "referenceC",
},
]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalPointSnapLineMaxX(snapLines)).toBe(500);
});
it("keeps same-source same-offset point snaps across zoom-scaled cluster breaks", () => {
const reference = API.createElement({
type: "rectangle",
id: "reference",
x: 0,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 338.608871112217,
y: 0,
width: 140.1015625,
height: 140.1015625,
});
const elements = [reference, selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
zoom: { value: 1.5 as NormalizedZoomValue },
});
primeReferenceSnapPoints(elements, [selected]);
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
const range = getHorizontalPointSnapLineXRange(snapLines);
expect(range).not.toBe(null);
expect(range![0]).toBeCloseTo(reference.x, 6);
expect(range![1]).toBeCloseTo(selected.x + selected.width, 6);
});
it("renders gap snaplines when rounded bounds touch the reference gap overlap", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 0.0000004,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setVisibleGaps({
horizontalGaps: [
{
startBounds: [200, -100, 300, 0],
endBounds: [400, -100, 500, 0],
startSide: [pointFrom(300, -100), pointFrom(300, 0)],
endSide: [pointFrom(400, -100), pointFrom(400, 0)],
overlap: rangeInclusive(-100, 0),
length: 100,
},
],
verticalGaps: [],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getHorizontalGapSnapLines(snapLines)).toHaveLength(2);
});
it("renders gap snaplines when the winning gap offset only differs by rounding precision", () => {
const selected = API.createElement({
type: "rectangle",
id: "selected",
x: 0,
y: 399.999999,
width: 100,
height: 100,
});
const elements = [selected];
const app = createSnappingApp({
selectedElementIds: { [selected.id]: true },
});
SnapCache.setReferenceSnapPoints([
{
point: pointFrom<GlobalPoint>(0, 399.999999),
type: "outer",
snapSourceId: "reference",
},
]);
SnapCache.setVisibleGaps({
horizontalGaps: [],
verticalGaps: [
{
startBounds: [0, 100, 100, 200],
endBounds: [0, 250, 100, 350],
startSide: [pointFrom(0, 200), pointFrom(100, 200)],
endSide: [pointFrom(0, 250), pointFrom(100, 250)],
overlap: rangeInclusive(0, 100),
length: 50,
},
],
});
const { snapLines } = snapDraggedElements(
elements,
{ x: 0, y: 0 },
app,
NO_MODIFIER_KEYS,
arrayToMap(elements),
);
expect(getVerticalGapSnapLines(snapLines)).toHaveLength(2);
});
});
+77 -17
View File
@@ -315,10 +315,7 @@ export interface AppState {
bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */
isMidpointSnappingEnabled: boolean;
/**
* The bindable element the UI highlights for the user when an arrow is
* dragged or otherwise its endpoint being close to said element.
*/
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBinding: {
element: NonDeleted<ExcalidrawBindableElement>;
midPoint?: GlobalPoint;
@@ -350,11 +347,8 @@ export interface AppState {
type: "selection" | "lasso";
initialized: boolean;
};
// Pen handling
penMode: boolean;
penDetected: boolean;
exportBackground: boolean;
exportEmbedScene: boolean;
exportWithDarkMode: boolean;
@@ -460,6 +454,7 @@ export interface AppState {
userToFollow: UserToFollow | null;
/** the socket ids of the users following the current user */
followedBy: Set<SocketId>;
scrollConstraints: ScrollConstraints | null;
/** image cropping */
isCropping: boolean;
@@ -478,9 +473,6 @@ export interface AppState {
// as elements are unlocked, we remove the groupId from the elements
// and also remove groupId from this map
lockedMultiSelections: { [groupId: string]: true };
// Stores the current bind mode which is detemined at various points during
// a drag operation (like pointer position vs bindable element) but needed
// globally for calculating the binding strategy
bindMode: BindMode;
}
@@ -496,7 +488,10 @@ export type SearchMatch = {
}[];
};
export type UIAppState = Omit<AppState, "cursorButton" | "scrollX" | "scrollY">;
export type UIAppState = Omit<
AppState,
"startBoundElement" | "cursorButton" | "scrollX" | "scrollY"
>;
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -552,6 +547,8 @@ export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
scrollX?: number;
scrollY?: number;
}
>;
@@ -668,6 +665,7 @@ export interface ExcalidrawProps {
onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
onUserFollow?: (payload: OnUserFollowedPayload) => void;
children?: React.ReactNode;
scrollConstraints?: AppState["scrollConstraints"];
validateEmbeddable?:
| boolean
| string[]
@@ -876,13 +874,8 @@ export type PointerDownState = Readonly<{
// Whether selected element(s) were duplicated, might change during the
// pointer interaction
hasBeenDuplicated: boolean;
// Whether the pointer is hitting the common bounding box of selected
// elements, which is useful for discriminating between selecitng
// the entire selection vs a specific element
hasHitCommonBoundingBoxOfSelectedElements: boolean;
};
// This is determined on the initial pointer down event to
// set various interaction modalities
withCmdOrCtrl: boolean;
drag: {
// Might change during the pointer interaction
@@ -908,7 +901,6 @@ export type PointerDownState = Readonly<{
onKeyUp: null | ((event: KeyboardEvent) => void);
};
boxSelection: {
// If the box selection tool is activated on pointer down
hasOccurred: boolean;
};
}>;
@@ -1000,6 +992,8 @@ export interface ExcalidrawImperativeAPI {
) => UnsubscribeCallback;
onStateChange: InstanceType<typeof App>["onStateChange"];
onEvent: InstanceType<typeof App>["onEvent"];
app: InstanceType<typeof App>;
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
}
export type FrameNameBounds = {
@@ -1066,3 +1060,69 @@ export type Offsets = Partial<{
bottom: number;
left: number;
}>;
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
animateOnNextUpdate?: boolean;
/**
* A factor that determines the minimum zoom level that should fit the
* constrained area into the viewport.
*/
viewportZoomFactor?: number;
/**
* If true, the user will not be able to zoom out beyond the scroll
* constraints (taking into account the viewportZoomFactor).
*/
lockZoom?: boolean;
/**
* <0-1> - how much can you scroll beyond the constrained area within the
* timeout window. Note you will still be snapped back to the constrained area
* after the timeout.
*/
overscrollAllowance?: number;
};
/**
* Optional scroll constraint settings derived from the final viewport computed
* by `scrollToContent()`.
*/
export type ScrollToContentLockOptions = {
lockZoom?: boolean;
overscrollAllowance?: number;
viewportZoomFactor?: number;
};
export type ScrollToContentOptions =
| ({
fitToContent?: boolean;
fitToViewport?: never;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
scrollLock?: boolean | ScrollToContentLockOptions;
} & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
})
| ({
fitToContent?: never;
fitToViewport?: boolean;
viewportZoomFactor?: number;
animate?: boolean;
duration?: number;
scrollLock?: boolean | ScrollToContentLockOptions;
} & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
});
export type AnimateTranslateCanvasValues = {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
zoom: AppState["zoom"]["value"];
};
+2
View File
@@ -1,3 +1,5 @@
export const PRECISION = 10e-5;
// Legendre-Gauss abscissae (x values) and weights for n=24
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
export const LegendreGaussN24TValues = [
@@ -85,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
},
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollConstraints": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
@@ -98,6 +99,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"showHyperlinkPopup": false,
"showWelcomeScreen": false,
"snapLines": [],
"startBoundElement": null,
"stats": {
"open": false,
"panels": 3,
-1
View File
@@ -1,5 +1,4 @@
{
"public": true,
"headers": [
{
"source": "/(.*)",