Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di 7e84a931e1 remove agent.md 2026-03-18 21:29:14 +11:00
Ryan Di 3b6de119b3 fix: clip frame children completely out of bounds 2026-03-18 21:16:40 +11:00
75 changed files with 543 additions and 5311 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 3 timeout-minutes: 3
steps: steps:
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 - uses: styfle/cancel-workflow-action@0.6.0
with: with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }} access_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
echo ::set-output name=body::$body echo ::set-output name=body::$body
- name: Update description with coverage - name: Update description with coverage
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 uses: kt3k/update-pr-description@v1.0.1
with: with:
pr_body: ${{ steps.getCommentBody.outputs.body }} pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin" pr_title: "chore: Update translations from Crowdin"
+4 -4
View File
@@ -13,16 +13,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 uses: docker/setup-buildx-action@v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
+1 -1
View File
@@ -11,6 +11,6 @@ jobs:
semantic: semantic:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 - uses: amannn/action-semantic-pull-request@v5
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
working-directory: packages/excalidraw working-directory: packages/excalidraw
env: env:
CI: true CI: true
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 - uses: andresz1/size-limit-action@v1
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
build_script: build:esm build_script: build:esm
+1 -1
View File
@@ -21,6 +21,6 @@ jobs:
run: yarn test:coverage run: yarn test:coverage
- name: "Report Coverage" - name: "Report Coverage"
if: always() # Also generate the report if tests are failing if: always() # Also generate the report if tests are failing
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 uses: davelosert/vitest-coverage-report-action@v2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -1,124 +0,0 @@
- 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
@@ -1,126 +0,0 @@
- 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
+1 -1
View File
@@ -29,7 +29,7 @@
<a href="https://docs.excalidraw.com/docs/introduction/contributing"> <a href="https://docs.excalidraw.com/docs/introduction/contributing">
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a> <img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="https://discord.gg/UexuTaE"> <a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widget=false"/></a> <img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
<a href="https://deepwiki.com/excalidraw/excalidraw"> <a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a> <img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
<a href="https://twitter.com/excalidraw"> <a href="https://twitter.com/excalidraw">
+1 -13
View File
@@ -4,20 +4,8 @@ import { Excalidraw } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
import type { ScrollConstraints } from "@excalidraw/excalidraw/types";
import App from "../../with-script-in-browser/components/ExampleApp"; 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 = () => { const ExcalidrawWrapper: React.FC = () => {
return ( return (
<> <>
@@ -26,7 +14,7 @@ const ExcalidrawWrapper: React.FC = () => {
useCustom={(api: any, args?: any[]) => {}} useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib} excalidrawLib={excalidrawLib}
> >
<Excalidraw scrollConstraints={scrollConstraints} /> <Excalidraw />
</App> </App>
</> </>
); );
@@ -178,8 +178,7 @@ export default function ExampleApp({
const newElement = cloneElement( const newElement = cloneElement(
Excalidraw, Excalidraw,
{ {
onExcalidrawAPI: (api: ExcalidrawImperativeAPI | null) => excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api),
setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise, initialData: initialStatePromiseRef.current.promise,
onChange: ( onChange: (
elements: NonDeletedExcalidrawElement[], elements: NonDeletedExcalidrawElement[],
@@ -821,48 +820,6 @@ export default function ExampleApp({
/> />
Export with embed scene Export with embed scene
</label> </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 <button
onClick={async () => { onClick={async () => {
if (!excalidrawAPI) { if (!excalidrawAPI) {
+1 -4
View File
@@ -1015,10 +1015,7 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action> </OverwriteConfirmDialog.Action>
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter <AppFooter onChange={() => excalidrawAPI?.refresh()} />
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger /> <TTDDialogTrigger />
+2 -168
View File
@@ -1,178 +1,13 @@
import { Footer } from "@excalidraw/excalidraw/index"; import { Footer } from "@excalidraw/excalidraw/index";
import { import React from "react";
getCommonBounds,
getSelectedElements,
getVisibleSceneBounds,
} from "@excalidraw/element";
import React, { useCallback, useState } from "react";
import type {
ExcalidrawImperativeAPI,
ScrollConstraints,
} from "@excalidraw/excalidraw/types";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
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( export const AppFooter = React.memo(
({ ({ onChange }: { onChange: () => void }) => {
excalidrawAPI,
onChange,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
onChange: () => void;
}) => {
return ( return (
<Footer> <Footer>
<div <div
@@ -182,7 +17,6 @@ export const AppFooter = React.memo(
alignItems: "center", alignItems: "center",
}} }}
> >
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />} {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />} {!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div> </div>
+4 -24
View File
@@ -680,9 +680,8 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>( export const getBoundsFromPoints = (
points: readonly P[], points: ExcalidrawFreeDrawElement["points"],
padding: number = 0,
): Bounds => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
@@ -696,7 +695,7 @@ export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX - padding, minY - padding, maxX + padding, maxY + padding]; return [minX, minY, maxX, maxY];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -1262,17 +1261,6 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
): boolean => ): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
// TODO make pointInsideBounds inclusive and remove this function once we
// test nothing is breaking
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
p: P,
bounds: Bounds,
): boolean =>
p[0] >= bounds[0] &&
p[0] <= bounds[2] &&
p[1] >= bounds[1] &&
p[1] <= bounds[3];
export const doBoundsIntersect = ( export const doBoundsIntersect = (
bounds1: Bounds | null, bounds1: Bounds | null,
bounds2: Bounds | null, bounds2: Bounds | null,
@@ -1287,21 +1275,13 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
}; };
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
[
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+17 -39
View File
@@ -154,11 +154,14 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first // Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true); const bounds = getElementBounds(element, elementsMap, true);
const hitBounds = isPointInRotatedBounds( const hitBounds = isPointWithinBounds(
point, pointFrom(bounds[0] - threshold, bounds[1] - threshold),
bounds, pointRotateRads(
element.angle, point,
threshold, getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
); );
// PERF: Bail out early if the point is not even in the // PERF: Bail out early if the point is not even in the
@@ -189,32 +192,18 @@ export const hitElementItself = ({
return result; return result;
}; };
const isPointInRotatedBounds = (
point: GlobalPoint,
bounds: Bounds,
angle: Radians,
tolerance = 0,
) => {
const adjustedPoint =
angle === 0
? point
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
return isPointWithinBounds(
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
adjustedPoint,
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
);
};
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
point: GlobalPoint, point: GlobalPoint,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
) => { ) => {
const bounds = getElementBounds(element, elementsMap, true); let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
return isPointInRotatedBounds(point, bounds, element.angle, tolerance); x1 -= tolerance;
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -476,12 +465,7 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment( return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -548,15 +532,11 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = ( const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment<GlobalPoint>, segment: LineSegment<GlobalPoint>,
elementsMap: ElementsMap,
onlyFirst = false, onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements // NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions. // rotated! This is due to taking advantage of roughjs definitions.
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -584,9 +564,7 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue; continue;
} }
const hits = curveIntersectLineSegment(c, segment, { const hits = curveIntersectLineSegment(c, segment);
iterLimit: 10,
});
if (hits.length > 0) { if (hits.length > 0) {
intersections.push(...hits); intersections.push(...hits);
+2 -6
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p); return distanceToLinearOrFreeDraElement(element, p);
} }
}; };
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
return Math.min( return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)), ...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)), ...curves.map((a) => curvePointDistance(a, p)),
+22 -11
View File
@@ -1,6 +1,7 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math"; import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox"; import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type { import type {
AppClassProperties, AppClassProperties,
@@ -17,8 +18,6 @@ import {
getElementLineSegments, getElementLineSegments,
getCommonBounds, getCommonBounds,
getElementAbsoluteCoords, getElementAbsoluteCoords,
doBoundsIntersect,
getElementBounds,
} from "./bounds"; } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement"; import { getBoundTextElement, getContainerElement } from "./textElement";
@@ -873,6 +872,19 @@ export const shouldApplyFrameClip = (
return true; return true;
} }
// Elements that belong to a frame should still render through that frame's
// clip, even when fully outside the frame bounds (e.g. generated content).
if (
!appState.selectedElementsAreBeingDragged &&
element.frameId === frame.id
) {
for (const groupId of element.groupIds) {
checkedGroups?.set(groupId, true);
}
return true;
}
// if an element is outside the frame, but is part of a group that has some elements // if an element is outside the frame, but is part of a group that has some elements
// "in" the frame, we should clip the element // "in" the frame, we should clip the element
if ( if (
@@ -921,17 +933,16 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => { ) => {
return elements.filter( return (
(el) => elementsOverlappingBBox({
// exclude elements which are overlapping, but are in a different frame, elements,
bounds: frame,
type: "overlap",
})
// removes elements who are overlapping, but are in a different frame,
// and thus invisible in target frame // and thus invisible in target frame
(!el.frameId || el.frameId === frame.id) && .filter((el) => !el.frameId || el.frameId === frame.id)
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
); );
}; };
+14 -31
View File
@@ -476,22 +476,16 @@ export class LinearElementEditor {
}); });
} }
if ( invariant(
lastClickedPoint < 0 || lastClickedPoint > -1 &&
!selectedPointsIndices.includes(lastClickedPoint) || selectedPointsIndices.includes(lastClickedPoint) &&
!element.points[lastClickedPoint] element.points[lastClickedPoint],
) { `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
console.error( selectedPointsIndices,
`There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify( )}) points(0..${
selectedPointsIndices, element.points.length - 1
)}) points(0..${ }) lastClickedPoint(${lastClickedPoint})`,
element.points.length - 1 );
}) lastClickedPoint(${lastClickedPoint})`,
);
// Fall back to the actual last point as a last resort.
lastClickedPoint = element.points.length - 1;
}
// point that's being dragged (out of all selected points) // point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint]; const draggingPoint = element.points[lastClickedPoint];
@@ -800,7 +794,6 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -810,7 +803,6 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
midpoints.push(segmentMidPoint); midpoints.push(segmentMidPoint);
index++; index++;
@@ -898,7 +890,6 @@ export class LinearElementEditor {
endPoint: P, endPoint: P,
index: number, index: number,
zoom: Zoom, zoom: Zoom,
elementsMap: ElementsMap,
) { ) {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) { if (index >= 0 && index < element.points.length) {
@@ -913,10 +904,7 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint); let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) { if (element.points.length > 2 && element.roundness) {
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -936,7 +924,6 @@ export class LinearElementEditor {
static getSegmentMidPoint( static getSegmentMidPoint(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
index: number, index: number,
elementsMap: ElementsMap,
): GlobalPoint { ): GlobalPoint {
if (isElbowArrow(element)) { if (isElbowArrow(element)) {
invariant( invariant(
@@ -949,10 +936,7 @@ export class LinearElementEditor {
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]); return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
} }
const [lines, curves] = deconstructLinearOrFreeDrawElement( const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1867,7 +1851,6 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element, element,
index + 1, index + 1,
elementsMap,
); );
x = midSegmentMidpoint[0] - boundTextElement.width / 2; x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2417,7 +2400,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2448,7 +2431,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
nextArrow, element,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+36 -278
View File
@@ -1,71 +1,33 @@
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common"; import { arrayToMap, isShallowEqual } from "@excalidraw/common";
import {
lineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint,
} from "@excalidraw/math";
import type { import type {
AppState, AppState,
BoxSelectionMode,
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types"; } from "@excalidraw/excalidraw/types";
import { import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
boundsContainBounds,
doBoundsIntersect,
elementCenterPoint,
getElementAbsoluteCoords,
getElementBounds,
pointInsideBounds,
} from "./bounds";
import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers"; import { isElementInViewport } from "./sizeHelpers";
import { import {
isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isFreeDrawElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
getFrameChildren, getFrameChildren,
isElementIntersectingFrame,
} from "./frame"; } from "./frame";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups"; import { selectGroupsForSelectedElements } from "./groups";
import { getBoundTextElement } from "./textElement";
import type { import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
const shouldIgnoreElementFromSelection = (
element: NonDeletedExcalidrawElement,
) => element.locked || isBoundToContainer(element);
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
selectedElements: readonly T[],
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
) => {
return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
};
/** /**
* Frames and their containing elements are not to be selected at the same time. * Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements * Given an array of selected elements, if there are frames and their containing elements
@@ -85,243 +47,55 @@ export const excludeElementsInFramesFromSelection = <
} }
}); });
return excludeElementsFromFrames(selectedElements, framesInSelection); return selectedElements.filter((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
return false;
}
return true;
});
}; };
export const getElementsWithinSelection = ( export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement, selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
// TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true, excludeElementsInFrames: boolean = true,
boxSelectionMode: BoxSelectionMode = "contain", ) => {
): NonDeletedExcalidrawElement[] => { const [selectionX1, selectionY1, selectionX2, selectionY2] =
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap); getElementAbsoluteCoords(selection, elementsMap);
const selectionX1 = Math.min(selectionStartX, selectionEndX);
const selectionY1 = Math.min(selectionStartY, selectionEndY);
const selectionX2 = Math.max(selectionStartX, selectionEndX);
const selectionY2 = Math.max(selectionStartY, selectionEndY);
const selectionBounds = [
selectionX1,
selectionY1,
selectionX2,
selectionY2,
] as Bounds;
const selectionEdges = [
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY1),
pointFrom(selectionX2, selectionY1),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY1),
pointFrom(selectionX2, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX2, selectionY2),
pointFrom(selectionX1, selectionY2),
),
lineSegment<GlobalPoint>(
pointFrom(selectionX1, selectionY2),
pointFrom(selectionX1, selectionY1),
),
];
const framesInSelection = excludeElementsInFrames let elementsInSelection = elements.filter((element) => {
? new Set<NonDeletedExcalidrawElement["id"]>() let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
: null; element,
let elementsInSelection: NonDeletedExcalidrawElement[] = []; elementsMap,
);
for (const element of elements) { const containingFrame = getContainingFrame(element, elementsMap);
if (shouldIgnoreElementFromSelection(element)) { if (containingFrame) {
continue; const [fx1, fy1, fx2, fy2] = getElementBounds(
} containingFrame,
const strokeWidth = element.strokeWidth;
let labelAABB: Bounds | null = null;
let elementAABB = getElementBounds(element, elementsMap);
elementAABB = [
elementAABB[0] - strokeWidth / 2,
elementAABB[1] - strokeWidth / 2,
elementAABB[2] + strokeWidth / 2,
elementAABB[3] + strokeWidth / 2,
] as Bounds;
// Whether the element bounds should include the bound text element bounds
const boundTextElement =
isArrowElement(element) && getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElement,
elementsMap, elementsMap,
); );
labelAABB = [
x, elementX1 = Math.max(fx1, elementX1);
y, elementY1 = Math.max(fy1, elementY1);
x + boundTextElement.width, elementX2 = Math.min(fx2, elementX2);
y + boundTextElement.height, elementY2 = Math.min(fy2, elementY2);
] as Bounds;
} }
// Clip element bounds by its containing frame (if any), since only the return (
// visible (frame-clipped) portion of the element is relevant for selection. element.locked === false &&
const associatedFrame = getContainingFrame(element, elementsMap); element.type !== "selection" &&
if ( !isBoundToContainer(element) &&
associatedFrame && selectionX1 <= elementX1 &&
isElementIntersectingFrame(element, associatedFrame, elementsMap) selectionY1 <= elementY1 &&
) { selectionX2 >= elementX2 &&
const frameAABB = getElementBounds(associatedFrame, elementsMap); selectionY2 >= elementY2
elementAABB = [ );
Math.max(elementAABB[0], frameAABB[0]), });
Math.max(elementAABB[1], frameAABB[1]),
Math.min(elementAABB[2], frameAABB[2]),
Math.min(elementAABB[3], frameAABB[3]),
] as Bounds;
labelAABB = labelAABB elementsInSelection = excludeElementsInFrames
? ([ ? excludeElementsInFramesFromSelection(elementsInSelection)
Math.max(labelAABB[0], frameAABB[0]),
Math.max(labelAABB[1], frameAABB[1]),
Math.min(labelAABB[2], frameAABB[2]),
Math.min(labelAABB[3], frameAABB[3]),
] as Bounds)
: null;
}
const commonAABB = labelAABB
? ([
Math.min(labelAABB[0], elementAABB[0]),
Math.min(labelAABB[1], elementAABB[1]),
Math.max(labelAABB[2], elementAABB[2]),
Math.max(labelAABB[3], elementAABB[3]),
] as Bounds)
: elementAABB;
// ============== Evaluation ==============
// 1. If the selection box WRAPs the element's AABB, then add it to the
// selection and move on, regardless of the selection mode.
//
// PERF: This trick only works with axis-aligned box selection and the
// current convex element shapes!
if (boundsContainBounds(selectionBounds, commonAABB)) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
} else {
elementsInSelection.push(element);
continue;
}
}
// 2. Handle the case where the label is overlapped by the selection box
if (
boxSelectionMode === "overlap" &&
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.push(element);
continue;
}
// 3. Handle the case where the selection is not wrapping the element, but
// it does intersect the element's outline (non-AABB).
if (
boxSelectionMode === "overlap" &&
doBoundsIntersect(selectionBounds, elementAABB)
) {
let hasIntersection = false;
// Preliminary check potential intersection imprecision
if (isLinearElement(element) || isFreeDrawElement(element)) {
const center = elementCenterPoint(element, elementsMap);
hasIntersection = element.points.some((point) => {
const rotatedPoint = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointInsideBounds(rotatedPoint, selectionBounds);
});
} else {
const nonRotatedElementBounds = getElementBounds(
element,
elementsMap,
true,
);
const center = elementCenterPoint(element, elementsMap);
hasIntersection = [
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[2],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
nonRotatedElementBounds[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
nonRotatedElementBounds[0],
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
),
center,
element.angle,
),
].some((point) => {
return pointInsideBounds(
pointRotateRads(point, center, element.angle),
selectionBounds,
);
});
}
if (!hasIntersection) {
hasIntersection = selectionEdges.some(
(selectionEdge) =>
intersectElementWithLineSegment(
element,
elementsMap,
selectionEdge,
strokeWidth / 2,
true, // Stop at first hit for better performance
).length > 0,
);
}
if (hasIntersection) {
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
continue;
}
}
// 4. We don't need to handle when the selection is inside the element
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection; : elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => { elementsInSelection = elementsInSelection.filter((element) => {
@@ -514,19 +288,3 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+16 -7
View File
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { import {
elementCenterPoint,
getArrowheadPoints, getArrowheadPoints,
getCenterForBounds,
getDiamondPoints, getDiamondPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./bounds"; } from "./bounds";
@@ -583,11 +583,7 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = ( export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap, ) => {
): {
op: string;
data: number[];
}[] => {
const generator = new RoughGenerator(); const generator = new RoughGenerator();
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -596,7 +592,20 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = elementCenterPoint(element, elementsMap); const center = getCenterForBounds(
// Need a non-rotated center point
element.points.reduce(
(acc, point) => {
return [
Math.min(element.x + point[0], acc[0]),
Math.min(element.y + point[1], acc[1]),
Math.max(element.x + point[0], acc[2]),
Math.max(element.y + point[1], acc[3]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
),
);
switch (element.type) { switch (element.type) {
case "line": case "line":
+1 -3
View File
@@ -347,7 +347,6 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint( midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container, container,
index + 1, index + 1,
elementsMap,
); );
} }
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -442,8 +441,7 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}): element is ExcalidrawTextContainer => }) => VALID_CONTAINER_TYPES.has(element.type);
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+37 -207
View File
@@ -4,22 +4,6 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -374,10 +358,6 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -390,120 +370,56 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return getHardLineBreaks(text); return text;
} }
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
let offset = 0; const originalLines = text.split("\n");
for (const originalLine of text.split("\n")) { for (const originalLine of originalLines) {
const originalLineWidth = getLineWidth(originalLine, font); const currentLineWidth = getLineWidth(originalLine, font);
if (originalLineWidth <= maxWidth) { if (currentLineWidth <= maxWidth) {
lines.push({ lines.push(originalLine);
text: originalLine, continue;
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
offset += originalLine.length + 1; const wrappedLine = wrapLine(originalLine, font, maxWidth);
lines.push(...wrappedLine);
} }
return lines; return lines.join("\n");
}; };
/** /**
* Wraps a single hard line into one or more visual lines. * Wraps the original line into the lines based on the given width.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
lineStart: number, ): string[] => {
): WrappedTextLine[] => { const lines: Array<string> = [];
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
while (tokenIndex < tokens.length) { let iterator = tokenIterator.next();
const token = tokens[tokenIndex];
const tokenStart = tokenOffset; while (!iterator.done) {
const tokenEnd = tokenStart + token.length; const token = iterator.value;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -513,59 +429,37 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
tokenOffset = tokenEnd; iterator = tokenIterator.next();
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart); const wrappedWord = wrapWord(token, font, maxWidth);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? { const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine.text; currentLine = trailingLine;
currentLineStart = trailingLine.start; currentLineWidth = getLineWidth(trailingLine, font);
currentLineEnd = trailingLine.end; iterator = tokenIterator.next();
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push( lines.push(currentLine.trimEnd());
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine( const trailingLine = trimLine(currentLine, font, maxWidth);
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -573,100 +467,59 @@ const wrapLine = (
}; };
/** /**
* Wraps a single word that could not be placed on an empty line as-is. * Wraps the word into the lines based on the given width.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
wordStart: number, ): Array<string> => {
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [ return [word];
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: WrappedTextLine[] = []; const lines: Array<string> = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push({ lines.push(currentLine);
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Trims trailing whitespace that is exceeding the `maxWidth`. * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = ( const trimLine = (line: string, font: FontString, maxWidth: number) => {
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return { return line;
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -690,30 +543,7 @@ const trimLine = (
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return { return trimmedLine;
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
+4 -2
View File
@@ -124,7 +124,6 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
*/ */
export function deconstructLinearOrFreeDrawElement( export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0); const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -132,7 +131,10 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element, elementsMap); const ops = generateLinearCollisionShape(element) as {
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+1 -3
View File
@@ -1,4 +1,4 @@
import { arrayToMap, reseed } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,7 +12,6 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => { describe("check rotated elements can be hit:", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
@@ -57,7 +56,6 @@ describe("hitElementItself cache", () => {
}); });
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
+78
View File
@@ -2,6 +2,7 @@ import {
convertToExcalidrawElements, convertToExcalidrawElements,
Excalidraw, Excalidraw,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui"; import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
@@ -10,6 +11,8 @@ import {
render, render,
} from "@excalidraw/excalidraw/tests/test-utils"; } from "@excalidraw/excalidraw/tests/test-utils";
import { shouldApplyFrameClip } from "../src/frame";
import type { ExcalidrawElement } from "../src/types"; import type { ExcalidrawElement } from "../src/types";
const { h } = window; const { h } = window;
@@ -561,3 +564,78 @@ describe("adding elements to frames", () => {
}); });
}); });
}); });
describe("frame clipping", () => {
const getAppStateForFrameClip = () =>
({
frameRendering: {
enabled: true,
clip: true,
},
selectedElementsAreBeingDragged: false,
selectedElementIds: {},
frameToHighlight: null,
editingGroupId: null,
} as any);
it("clips a frame child even when fully outside the frame bounds", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideChild = API.createElement({
type: "rectangle",
id: "outside-child",
x: 250,
y: 250,
width: 50,
height: 50,
frameId: frame.id,
});
const elementsMap = arrayToMap([outsideChild, frame]);
expect(
shouldApplyFrameClip(
outsideChild,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(true);
});
it("does not clip an outside element that does not belong to the frame", () => {
const frame = API.createElement({
type: "frame",
id: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
});
const outsideElement = API.createElement({
type: "rectangle",
id: "outside",
x: 250,
y: 250,
width: 50,
height: 50,
});
const elementsMap = arrayToMap([outsideElement, frame]);
expect(
shouldApplyFrameClip(
outsideElement,
frame,
getAppStateForFrameClip(),
elementsMap,
),
).toBe(false);
});
});
+1 -70
View File
@@ -1,8 +1,4 @@
import { import { wrapText, parseTokens } from "../src/textWrapping";
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -106,71 +102,6 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
+9 -63
View File
@@ -40,7 +40,6 @@ import {
ZoomResetIcon, ZoomResetIcon,
} from "../components/icons"; } from "../components/icons";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { constrainScrollState } from "../scene/scrollConstraints";
import { t } from "../i18n"; import { t } from "../i18n";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
@@ -52,8 +51,6 @@ import { register } from "./register";
import type { AppState, Offsets } from "../types"; import type { AppState, Offsets } from "../types";
const canUseZoomActions = (appState: AppState) => !appState.scrollConstraints;
export const actionChangeViewBackgroundColor = register<Partial<AppState>>({ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
label: "labels.canvasBackground", label: "labels.canvasBackground",
@@ -142,7 +139,7 @@ export const actionZoomIn = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: constrainScrollState({ appState: {
...appState, ...appState,
...getStateForZoom( ...getStateForZoom(
{ {
@@ -153,7 +150,7 @@ export const actionZoomIn = register({
appState, appState,
), ),
userToFollow: null, userToFollow: null,
}), },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
@@ -183,7 +180,7 @@ export const actionZoomOut = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: constrainScrollState({ appState: {
...appState, ...appState,
...getStateForZoom( ...getStateForZoom(
{ {
@@ -194,7 +191,7 @@ export const actionZoomOut = register({
appState, appState,
), ),
userToFollow: null, userToFollow: null,
}), },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
@@ -246,7 +243,6 @@ export const actionResetZoom = register({
className="reset-zoom-button zoom-button" className="reset-zoom-button zoom-button"
title={t("buttons.resetZoom")} title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")} aria-label={t("buttons.resetZoom")}
disabled={!canUseZoomActions(appState)}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
@@ -255,7 +251,6 @@ export const actionResetZoom = register({
</ToolButton> </ToolButton>
</Tooltip> </Tooltip>
), ),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) => keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) && (event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey), (event[KEYS.CTRL_OR_CMD] || event.shiftKey),
@@ -279,7 +274,7 @@ const zoomValueToFitBoundsOnViewport = (
return Math.min(adjustedZoomValue, 1); return Math.min(adjustedZoomValue, 1);
}; };
export function resolveViewportForBounds({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
canvasOffsets, canvasOffsets,
@@ -297,7 +292,7 @@ export function resolveViewportForBounds({
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number; minZoom?: number;
maxZoom?: number; maxZoom?: number;
}) { }) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM); viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
@@ -350,58 +345,12 @@ export function resolveViewportForBounds({
zoom: { value: newZoomValue }, 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 { return {
appState: { appState: {
...appState, ...appState,
scrollX: resolvedViewport.scrollX, scrollX: centerScroll.scrollX,
scrollY: resolvedViewport.scrollY, scrollY: centerScroll.scrollY,
zoom: resolvedViewport.zoom, zoom: { value: newZoomValue },
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
@@ -459,7 +408,6 @@ export const actionZoomToFitSelectionInViewport = register({
canvasOffsets: app.getEditorUIOffsets(), canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE shift-2 should have been assigned actionZoomToFitSelection. // NOTE shift-2 should have been assigned actionZoomToFitSelection.
// TBD on how proceed // TBD on how proceed
keyTest: (event) => keyTest: (event) =>
@@ -486,7 +434,6 @@ export const actionZoomToFitSelection = register({
canvasOffsets: app.getEditorUIOffsets(), canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
predicate: (_elements, appState) => canUseZoomActions(appState),
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
keyTest: (event) => keyTest: (event) =>
event.code === CODES.THREE && event.code === CODES.THREE &&
@@ -511,7 +458,6 @@ export const actionZoomToFit = register({
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(), canvasOffsets: app.getEditorUIOffsets(),
}), }),
predicate: (_elements, appState) => canUseZoomActions(appState),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
event.shiftKey && event.shiftKey &&
@@ -1,145 +0,0 @@
import {
getElementsInGroup,
isSomeElementSelected,
makeNextSelectedElementIds,
selectGroupsForSelectedElements,
} from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
import type { GroupId } from "@excalidraw/element/types";
import { register } from "./register";
import type { AppClassProperties, AppState } from "../types";
const getNextActiveTool = (
appState: Readonly<AppState>,
app: AppClassProperties,
) => {
if (appState.activeTool.type === "eraser") {
return updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.state.preferredSelectionTool.type,
}),
lastActiveToolBeforeEraser: null,
});
}
return updateActiveTool(appState, {
type: app.state.preferredSelectionTool.type,
});
};
const getParentEditingGroupId = (
appState: Readonly<AppState>,
app: AppClassProperties,
selectedElementIds: AppState["selectedElementIds"],
): GroupId | null => {
if (!appState.editingGroupId) {
return null;
}
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElements = app.scene.getSelectedElements({
selectedElementIds,
elements: nonDeletedElements,
});
const candidateElements = selectedElements.length
? selectedElements
: getElementsInGroup(nonDeletedElements, appState.editingGroupId);
for (const element of candidateElements) {
const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
return element.groupIds[editingGroupIndex + 1] as GroupId;
}
}
return null;
};
export const actionDeselect = register({
name: "deselect",
label: "",
trackEvent: false,
perform: (_elements, appState, _, app) => {
const activeTool = getNextActiveTool(appState, app);
if (appState.editingGroupId) {
const nonDeletedElements = app.scene.getNonDeletedElements();
const selectedElementIds =
Object.keys(appState.selectedElementIds).length > 0
? appState.selectedElementIds
: getElementsInGroup(
nonDeletedElements,
appState.editingGroupId,
).reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<string, true>);
return {
appState: {
...appState,
...selectGroupsForSelectedElements(
{
editingGroupId: getParentEditingGroupId(
appState,
app,
selectedElementIds,
),
selectedElementIds,
},
nonDeletedElements,
appState,
app,
),
activeEmbeddable: null,
activeTool,
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
return {
appState: {
...appState,
activeEmbeddable: null,
activeTool,
editingGroupId: null,
selectedElementIds: makeNextSelectedElementIds({}, appState),
selectedGroupIds: {},
selectedLinearElement: null,
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
keyTest: (event, appState, _, app) => {
if (event.key !== KEYS.ESCAPE) {
return false;
}
if (isWritableElement(event.target)) {
return false;
}
return (
!appState.newElement &&
appState.multiElement === null &&
!appState.selectedLinearElement?.isEditing &&
(appState.activeEmbeddable !== null ||
appState.activeTool.type !== app.state.preferredSelectionTool.type ||
!!appState.editingGroupId ||
!!appState.selectedLinearElement ||
isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
);
},
});
@@ -348,7 +348,9 @@ export const actionFinalize = register<FormData>({
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) || (event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null), appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => ( PanelComponent: ({ appState, updateData, data }) => (
+3 -12
View File
@@ -49,18 +49,9 @@ import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
const allElementsInSameGroup = ( const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
elements: readonly ExcalidrawElement[],
editingGroupId: AppState["editingGroupId"],
) => {
if (elements.length >= 2) { if (elements.length >= 2) {
const editingGroupIndex = editingGroupId const groupIds = elements[0].groupIds;
? elements[0].groupIds.indexOf(editingGroupId)
: -1;
const groupIds =
editingGroupIndex > -1
? elements[0].groupIds.slice(0, editingGroupIndex)
: elements[0].groupIds;
for (const groupId of groupIds) { for (const groupId of groupIds) {
if ( if (
elements.reduce( elements.reduce(
@@ -87,7 +78,7 @@ const enableActionGroup = (
return ( return (
selectedElements.length >= 2 && selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements, appState.editingGroupId) && !allElementsInSameGroup(selectedElements) &&
!frameAndChildrenSelectedTogether(selectedElements) !frameAndChildrenSelectedTogether(selectedElements)
); );
}; };
@@ -191,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
elementPredicate: true | ((element: ExcalidrawElement) => boolean), isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -209,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
elementPredicate === true isRelevantElement === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => elementPredicate(el)); : selectedElements.filter((el) => isRelevantElement(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,28 +730,9 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, app, updateData }) => { PanelComponent: ({ app, updateData }) => (
const opacity = getFormValue( <Range updateData={updateData} app={app} testId="opacity" />
elements, ),
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { isExcalidrawElement, newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown) => { predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,18 +26,13 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, targetElement) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === targetTextElement?.id && isTextElement(element)) { if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
@@ -53,5 +53,8 @@ export const actionToggleSearchMenu = register({
}; };
}, },
checked: (appState: AppState) => appState.gridModeEnabled, checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
}); });
-1
View File
@@ -34,7 +34,6 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable"; export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize"; export { actionFinalize } from "./actionFinalize";
export { actionDeselect } from "./actionDeselect";
export { export {
actionChangeProjectName, actionChangeProjectName,
-7
View File
@@ -95,13 +95,6 @@ export class ActionManager {
(action.name in canvasActions (action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions] ? canvasActions[action.name as keyof typeof canvasActions]
: true) && : true) &&
(!action.predicate ||
action.predicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
this.app.props,
this.app,
)) &&
action.keyTest && action.keyTest &&
action.keyTest( action.keyTest(
event, event,
-1
View File
@@ -114,7 +114,6 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
-4
View File
@@ -122,14 +122,12 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
scrollConstraints: null,
isCropping: false, isCropping: false,
croppingElementId: null, croppingElementId: null,
searchMatches: null, searchMatches: null,
lockedMultiSelections: {}, lockedMultiSelections: {},
activeLockedId: null, activeLockedId: null,
bindMode: "orbit", bindMode: "orbit",
boxSelectionMode: "contain",
}; };
}; };
@@ -195,7 +193,6 @@ const APP_STATE_STORAGE_CONF = (<
gridModeEnabled: { browser: true, export: true, server: true }, gridModeEnabled: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false }, height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: true, export: false, server: false }, isBindingEnabled: { browser: true, export: false, server: false },
boxSelectionMode: { browser: true, export: false, server: false },
bindingPreference: { browser: true, export: false, server: false }, bindingPreference: { browser: true, export: false, server: false },
isMidpointSnappingEnabled: { browser: true, export: false, server: false }, isMidpointSnappingEnabled: { browser: true, export: false, server: false },
defaultSidebarDockedPreference: { defaultSidebarDockedPreference: {
@@ -251,7 +248,6 @@ const APP_STATE_STORAGE_CONF = (<
objectsSnapModeEnabled: { browser: true, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false }, userToFollow: { browser: false, export: false, server: false },
followedBy: { 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 }, isCropping: { browser: false, export: false, server: false },
croppingElementId: { browser: false, export: false, server: false }, croppingElementId: { browser: false, export: false, server: false },
searchMatches: { browser: false, export: false, server: false }, searchMatches: { browser: false, export: false, server: false },
File diff suppressed because it is too large Load Diff
@@ -46,7 +46,6 @@ import {
import { fontPickerKeyHandler } from "./keyboardNavHandlers"; import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import type { JSX } from "react"; import type { JSX } from "react";
import type { ExcalidrawFontFace } from "../../fonts/ExcalidrawFontFace";
export interface FontDescriptor { export interface FontDescriptor {
value: number; value: number;
@@ -87,15 +86,6 @@ const getFontFamilyIcon = (fontFamily: FontFamilyValues): JSX.Element => {
} }
}; };
const getFontFamilyLabel = (
fontFamily: FontFamilyValues,
fontFaces: ExcalidrawFontFace[],
) =>
// prefer our config as the browser resolved names may be wrapped in quotes and such
Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ??
fontFaces[0]?.fontFace?.family ??
"Unknown";
export const FontPickerList = React.memo( export const FontPickerList = React.memo(
({ ({
selectedFontFamily, selectedFontFamily,
@@ -124,7 +114,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: getFontFamilyIcon(familyId), icon: getFontFamilyIcon(familyId),
text: getFontFamilyLabel(familyId, fontFaces), text: fontFaces[0]?.fontFace?.family ?? "Unknown",
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -26,16 +26,13 @@
background: var(--RadioGroup-background); background: var(--RadioGroup-background);
border: 1px solid var(--RadioGroup-border); border: 1px solid var(--RadioGroup-border);
gap: 2px;
&__choice { &__choice {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 20px; width: 32px;
height: 24px; height: 24px;
padding: 0 0.375rem;
color: var(--RadioGroup-choice-color-off); color: var(--RadioGroup-choice-color-off);
background: var(--RadioGroup-choice-background-off); background: var(--RadioGroup-choice-background-off);
+33 -37
View File
@@ -1,78 +1,74 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
label: React.ReactNode; updateData: (value: number) => void;
value: number; app: AppClassProperties;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ export const Range = ({ updateData, app, testId }: RangeProps) => {
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null); const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
const valueElement = valueRef.current; const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth; const inputWidth = rangeElement.offsetWidth;
const thumbWidth = const thumbWidth = 15; // 15 is the width of the thumb
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`; valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`; rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
} }
}, [max, min, value]); }, [value]);
return ( return (
<label className="control-label"> <label className="control-label">
{label} {t("labels.opacity")}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonValue ["--color-slider-track" as string]: hasCommonOpacity
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min={min} min="0"
max={max} max="100"
step={step} step="10"
onChange={(event) => { onChange={(event) => {
onChange(+event.target.value); updateData(+event.target.value);
}} }}
value={value} value={value}
className="range-input" className="range-input"
data-testid={testId} data-testid={testId}
/> />
<div className="value-bubble" ref={valueRef}> <div className="value-bubble" ref={valueRef}>
{value !== min ? value : null} {value !== 0 ? value : null}
</div> </div>
<div className="zero-label">{minLabel}</div> <div className="zero-label">0</div>
</div> </div>
</label> </label>
); );
@@ -361,10 +361,12 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(); const editor = await getTextEditor();
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
Keyboard.exitTextEditor(editor); act(() => {
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
API.setSelectedElements([text]); mouse.clickOn(text);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -750,7 +752,7 @@ describe("frame resizing behavior", () => {
x: 0, x: 0,
y: 0, y: 0,
width: 100, width: 100,
height: 103, height: 100,
}); });
// Create a rectangle outside the frame // Create a rectangle outside the frame
@@ -54,7 +54,6 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined undefined
>; >;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude< onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"], DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined undefined
@@ -214,7 +213,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
@@ -2,7 +2,7 @@
.excalidraw { .excalidraw {
.dropdown-menu { .dropdown-menu {
max-width: 20rem; max-width: 16rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,5 +1,4 @@
import { useEditorInterface } from "../App"; import { useEditorInterface } from "../App";
import { Ellipsify } from "../Ellipsify";
import { RadioGroup } from "../RadioGroup"; import { RadioGroup } from "../RadioGroup";
type Props<T> = { type Props<T> = {
@@ -13,7 +12,6 @@ type Props<T> = {
onChange: (value: T) => void; onChange: (value: T) => void;
children: React.ReactNode; children: React.ReactNode;
name: string; name: string;
icon?: React.ReactNode;
}; };
const DropdownMenuItemContentRadio = <T,>({ const DropdownMenuItemContentRadio = <T,>({
@@ -23,17 +21,13 @@ const DropdownMenuItemContentRadio = <T,>({
choices, choices,
children, children,
name, name,
icon,
}: Props<T>) => { }: Props<T>) => {
const editorInterface = useEditorInterface(); const editorInterface = useEditorInterface();
return ( return (
<> <>
<div className="dropdown-menu-item-base dropdown-menu-item-bare"> <div className="dropdown-menu-item-base dropdown-menu-item-bare">
{icon && <div className="dropdown-menu-item__icon">{icon}</div>} <label className="dropdown-menu-item__text">{children}</label>
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,13 +39,7 @@ import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons";
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -433,39 +427,6 @@ const PreferencesToggleToolLockItem = () => {
); );
}; };
const PreferencesBoxSelectionModeItem = () => {
const { t } = useI18n();
const appState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
return (
<DropdownMenuItemContentRadio<"contain" | "overlap">
name="boxSelectionMode"
icon={emptyIcon}
value={appState.boxSelectionMode}
onChange={(value) => {
setAppState({
boxSelectionMode: value,
});
}}
choices={[
{
value: "contain",
label: t("labels.boxSelectionContain"),
ariaLabel: t("labels.boxSelectionContain"),
},
{
value: "overlap",
label: t("labels.boxSelectionOverlap"),
ariaLabel: t("labels.boxSelectionOverlap"),
},
]}
>
{t("labels.boxSelectionMode")}
</DropdownMenuItemContentRadio>
);
};
const PreferencesToggleSnapModeItem = () => { const PreferencesToggleSnapModeItem = () => {
const { t } = useI18n(); const { t } = useI18n();
const actionManager = useExcalidrawActionManager(); const actionManager = useExcalidrawActionManager();
@@ -607,7 +568,6 @@ export const Preferences = ({
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu"> <DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
{children || ( {children || (
<> <>
<PreferencesBoxSelectionModeItem />
<PreferencesToggleToolLockItem /> <PreferencesToggleToolLockItem />
<PreferencesToggleSnapModeItem /> <PreferencesToggleSnapModeItem />
<PreferencesToggleGridModeItem /> <PreferencesToggleGridModeItem />
@@ -625,7 +585,6 @@ export const Preferences = ({
}; };
Preferences.ToggleToolLock = PreferencesToggleToolLockItem; Preferences.ToggleToolLock = PreferencesToggleToolLockItem;
Preferences.BoxSelectionMode = PreferencesBoxSelectionModeItem;
Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem;
Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem; Preferences.ToggleArrowBinding = PreferencesToggleArrowBindingItem;
Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem; Preferences.ToggleMidpointSnapping = PreferencesToggleMidpointSnappingItem;
+1 -7
View File
@@ -6,7 +6,6 @@ import {
MIME_TYPES, MIME_TYPES,
cloneJSON, cloneJSON,
SVG_DOCUMENT_PREAMBLE, SVG_DOCUMENT_PREAMBLE,
arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements } from "@excalidraw/element";
@@ -50,7 +49,6 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean, exportSelectionOnly: boolean,
) => { ) => {
elements = getNonDeletedElements(elements); elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection = const isExportingSelection =
exportSelectionOnly && exportSelectionOnly &&
@@ -73,11 +71,7 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0]) isFrameLikeElement(exportedElements[0])
) { ) {
exportingFrame = exportedElements[0]; exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame( exportedElements = getElementsOverlappingFrame(elements, exportingFrame);
elements,
exportingFrame,
elementsMap,
);
} else if (exportedElements.length > 1) { } else if (exportedElements.length > 1) {
exportedElements = getSelectedElements( exportedElements = getSelectedElements(
elements, elements,
+1 -9
View File
@@ -251,9 +251,7 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
} }
console.error( console.error(`could not repair binding for element`);
`Could not repair binding for element "${boundElement?.id}" out of (${elementsMap?.size}) elements`,
);
} catch (error) { } catch (error) {
console.error("Error repairing binding:", error); console.error("Error repairing binding:", error);
} }
@@ -936,12 +934,6 @@ export const restoreAppState = (
: defaultValue; : defaultValue;
} }
const boxSelectionMode =
appState.boxSelectionMode ?? localAppState?.boxSelectionMode;
if (boxSelectionMode !== undefined) {
nextAppState.boxSelectionMode = boxSelectionMode;
}
return { return {
...nextAppState, ...nextAppState,
cursorButton: localAppState?.cursorButton || "up", cursorButton: localAppState?.cursorButton || "up",
-4
View File
@@ -98,7 +98,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
scrollConstraints,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -209,7 +208,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
scrollConstraints={scrollConstraints}
> >
{children} {children}
</App> </App>
@@ -384,8 +382,6 @@ export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToC
export { getDataURL } from "./data/blob"; export { getDataURL } from "./data/blob";
export { isElementLink } from "@excalidraw/element"; export { isElementLink } from "@excalidraw/element";
export { Fonts } from "./fonts/Fonts";
export { setCustomTextMetricsProvider } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element";
export { CommandPalette } from "./components/CommandPalette/CommandPalette"; export { CommandPalette } from "./components/CommandPalette/CommandPalette";
+1 -4
View File
@@ -185,9 +185,6 @@
"shapeSwitch": "Switch shape", "shapeSwitch": "Switch shape",
"preferences": "Preferences", "preferences": "Preferences",
"preferences_toolLock": "Tool lock", "preferences_toolLock": "Tool lock",
"boxSelectionMode": "Select on",
"boxSelectionContain": "Wrap",
"boxSelectionOverlap": "Overlap",
"arrowBinding": "Arrow binding", "arrowBinding": "Arrow binding",
"midpointSnapping": "Snap to midpoints" "midpointSnapping": "Snap to midpoints"
}, },
@@ -664,7 +661,7 @@
"placeholder": { "placeholder": {
"title": "Let's design your diagram", "title": "Let's design your diagram",
"description": "Describe the diagram you want to create, and we'll generate it for you.", "description": "Describe the diagram you want to create, and we'll generate it for you.",
"hint": "At the moment we know Flowchart, Sequence, Class, State, and Entity Relationship diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, and Entity Relationship diagrams."
}, },
"preview": "Preview", "preview": "Preview",
"insert": "Insert", "insert": "Insert",
+1 -1
View File
@@ -88,7 +88,7 @@
"@excalidraw/element": "0.18.0", "@excalidraw/element": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1", "@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/math": "0.18.0", "@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "2.2.2", "@excalidraw/mermaid-to-excalidraw": "2.1.1",
"@excalidraw/random-username": "1.1.0", "@excalidraw/random-username": "1.1.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
@@ -41,7 +41,6 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element";
@@ -59,8 +58,6 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -89,10 +86,6 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import {
getTextAutoResizeHandle,
getTextBoxPadding,
} from "../textAutoResizeHandle";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -1156,7 +1149,6 @@ const renderLinearPointHandles = (
points[idx], points[idx],
idx, idx,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
renderSingleLinearPoint( renderSingleLinearPoint(
@@ -1497,58 +1489,21 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = getTextBoxPadding(appState.zoom.value); const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
const width = text.width + padding * 2; const width = text.width + padding * 2;
const height = text.height + padding * 2; const height = text.height + padding * 2;
const cx = text.x + text.width / 2; const cx = text.x + width / 2;
const cy = text.y + text.height / 2; const cy = text.y + height / 2;
const shiftX = -(text.width / 2 + padding); const shiftX = -(width / 2 + padding);
const shiftY = -(text.height / 2 + padding); const shiftY = -(height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY); context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle); context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor; context.strokeStyle = selectionColor;
context.globalAlpha = 0.5;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeRect(shiftX, shiftY, width, height); context.strokeRect(shiftX, shiftY, width, height);
context.restore(); context.restore();
}; };
const renderResetAutoResizeHandle = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
formFactor: EditorInterface["formFactor"],
) => {
const autoResizeHandle = getTextAutoResizeHandle(
text,
appState.zoom.value,
formFactor,
);
if (!autoResizeHandle) {
return;
}
context.save();
context.globalAlpha = 0.5;
context.lineWidth = 1.5 / appState.zoom.value;
context.lineCap = "round";
context.strokeStyle = selectionColor;
context.beginPath();
context.moveTo(
autoResizeHandle.start[0] + appState.scrollX,
autoResizeHandle.start[1] + appState.scrollY,
);
context.lineTo(
autoResizeHandle.end[0] + appState.scrollX,
autoResizeHandle.end[1] + appState.scrollY,
);
context.stroke();
context.restore();
};
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
app, app,
canvas, canvas,
@@ -1629,19 +1584,10 @@ const _renderInteractiveScene = ({
} }
} }
const activeTextElement = getActiveTextElement(selectedElements, appState); if (
appState.editingTextElement &&
if (activeTextElement && !activeTextElement.autoResize) { isTextElement(appState.editingTextElement)
renderResetAutoResizeHandle( ) {
activeTextElement,
context,
appState,
renderConfig.selectionColor,
editorInterface.formFactor,
);
}
if (appState.editingTextElement) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
+1 -5
View File
@@ -157,11 +157,7 @@ const prepareElementsForRender = ({
let nextElements: readonly ExcalidrawElement[]; let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) { if (exportingFrame) {
nextElements = getElementsOverlappingFrame( nextElements = getElementsOverlappingFrame(elements, exportingFrame);
elements,
exportingFrame,
arrayToMap(elements),
);
} else if (frameRendering.enabled && frameRendering.name) { } else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, { nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode, exportWithDarkMode,
@@ -1,590 +0,0 @@
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,67 +1,5 @@
import { constrainScrollState } from "./scrollConstraints";
import type { AppState, NormalizedZoomValue } from "../types"; 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 = ( export const getStateForZoom = (
{ {
viewportX, viewportX,
@@ -13,7 +13,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -961,7 +960,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1088,7 +1086,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1159,7 +1156,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -1304,7 +1300,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1375,7 +1370,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1637,7 +1631,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1708,7 +1701,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1970,7 +1962,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2041,7 +2032,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -2186,7 +2176,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2257,7 +2246,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2429,7 +2417,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2500,7 +2487,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2729,7 +2715,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2802,7 +2787,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3103,7 +3087,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3174,7 +3157,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3598,7 +3580,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3669,7 +3650,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3923,7 +3903,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3994,7 +3973,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4248,7 +4226,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4321,7 +4298,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4382,7 +4358,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1006504105, "versionNonce": 760410951,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4407,14 +4383,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 289600103, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4661,7 +4637,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -5608,7 +5583,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5880,7 +5854,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -6827,7 +6800,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6892,7 +6864,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 747212839,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6919,14 +6891,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 400692809, "seed": 238820263,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 760410951, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -7150,7 +7122,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -7787,7 +7758,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7841,7 +7811,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -8786,7 +8755,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -8834,7 +8802,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": { "contextMenu": {
"items": [ "items": [
@@ -9782,7 +9749,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
File diff suppressed because it is too large Load Diff
@@ -13,7 +13,6 @@ exports[`given element A and group of elements B and given both are selected whe
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -88,7 +87,6 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -441,7 +439,6 @@ exports[`given element A and group of elements B and given both are selected whe
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -516,7 +513,6 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -859,7 +855,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -930,7 +925,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1427,7 +1421,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1498,7 +1491,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1636,7 +1628,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -1709,7 +1700,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2022,7 +2012,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2095,7 +2084,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2269,7 +2257,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2340,7 +2327,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2451,7 +2437,6 @@ exports[`regression tests > can drag element that covers another element, while
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2524,7 +2509,6 @@ exports[`regression tests > can drag element that covers another element, while
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2778,7 +2762,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -2849,7 +2832,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3035,7 +3017,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3108,7 +3089,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3278,7 +3258,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3351,7 +3330,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3516,7 +3494,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3589,7 +3566,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3776,7 +3752,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -3849,7 +3824,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4092,7 +4066,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4163,7 +4136,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4530,7 +4502,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4604,7 +4575,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4815,7 +4785,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -4889,7 +4858,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5093,7 +5061,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5166,7 +5133,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5303,7 +5269,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5376,7 +5341,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5505,7 +5469,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5576,7 +5539,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5900,7 +5862,6 @@ exports[`regression tests > drags selected elements from point inside common bou
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -5974,7 +5935,6 @@ exports[`regression tests > drags selected elements from point inside common bou
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6199,7 +6159,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -6270,7 +6229,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6989,7 +6947,6 @@ exports[`regression tests > given a group of selected elements with an element t
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7063,7 +7020,6 @@ exports[`regression tests > given a group of selected elements with an element t
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7325,7 +7281,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7398,7 +7353,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7606,7 +7560,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7679,7 +7632,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7843,7 +7795,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -7916,7 +7867,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8085,7 +8035,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8156,7 +8105,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8267,7 +8215,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8338,7 +8285,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8449,7 +8395,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8520,7 +8465,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8631,7 +8575,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8702,7 +8645,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8865,7 +8807,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -8936,7 +8877,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9097,7 +9037,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9168,7 +9107,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9291,7 +9229,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9362,7 +9299,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9525,7 +9461,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9596,7 +9531,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9707,7 +9641,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -9778,7 +9711,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9939,7 +9871,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10010,7 +9941,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10121,7 +10051,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10192,7 +10121,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10315,7 +10243,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10386,7 +10313,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10497,7 +10423,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -10572,7 +10497,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11030,7 +10954,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11103,7 +11026,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11312,7 +11234,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11383,7 +11304,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": "-6.25000", "scrollX": "-6.25000",
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11437,7 +11357,6 @@ exports[`regression tests > shift click on selected element should deselect it o
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11510,7 +11429,6 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11639,7 +11557,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -11713,7 +11630,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11960,7 +11876,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -12035,7 +11950,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -12391,7 +12305,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -12467,7 +12380,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -13033,7 +12945,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -13107,7 +13018,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 60, "scrollX": 60,
"scrollY": 60, "scrollY": 60,
"scrolledOutside": false, "scrolledOutside": false,
@@ -13161,7 +13071,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -13234,7 +13143,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -13794,7 +13702,6 @@ exports[`regression tests > switches from group of selected elements to another
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -13868,7 +13775,6 @@ exports[`regression tests > switches from group of selected elements to another
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14135,7 +14041,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14208,7 +14113,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14401,7 +14305,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14472,7 +14375,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 20, "scrollX": 20,
"scrollY": "-18.53553", "scrollY": "-18.53553",
"scrolledOutside": false, "scrolledOutside": false,
@@ -14526,7 +14428,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14597,7 +14498,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14892,7 +14792,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -14963,7 +14862,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -15017,7 +14915,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -15091,7 +14988,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
+2 -12
View File
@@ -4,7 +4,6 @@ import {
elementCenterPoint, elementCenterPoint,
getCommonBounds, getCommonBounds,
getElementPointsCoords, getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
@@ -21,7 +20,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common"; import { KEYS, arrayToMap } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -517,17 +516,8 @@ export class UI {
UI.clickTool(type); UI.clickTool(type);
if (type === "text") { if (type === "text") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset(); mouse.reset();
mouse.click(x, clickY); mouse.click(x, y);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();
+1 -77
View File
@@ -314,7 +314,7 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, isDeleted: true }), expect.objectContaining({ id: rect2.id, isDeleted: true }),
]); ]);
mouse.downAt(-10, -10); mouse.downAt(0, 0);
mouse.moveTo(25, 25); mouse.moveTo(25, 25);
mouse.moveTo(50, 50); mouse.moveTo(50, 50);
mouse.upAt(50, 50); mouse.upAt(50, 50);
@@ -2971,82 +2971,6 @@ describe("history", () => {
expect(h.state.editingGroupId).toBeNull(); expect(h.state.editingGroupId).toBeNull();
}); });
// TODO mark with "noncritical" tag once we migrate to vitest 4
it.skip("should support undo and redo when escape unwinds nested group editing", async () => {
const rectA = API.createElement({
type: "rectangle",
groupIds: ["inner", "outer"],
x: 0,
});
const rectB = API.createElement({
type: "rectangle",
groupIds: ["outer"],
x: 100,
});
const rectC = API.createElement({
type: "rectangle",
groupIds: ["inner", "outer"],
x: 200,
});
API.setElements([rectA, rectB, rectC]);
mouse.select(rectA);
mouse.doubleClickOn(rectA);
mouse.doubleClickOn(rectA);
assertSelectedElements([rectA]);
expect(h.state.editingGroupId).toBe("inner");
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
expect(API.getUndoStack().length).toBe(5);
expect(API.getRedoStack().length).toBe(0);
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
expect(API.getUndoStack().length).toBe(6);
expect(API.getRedoStack().length).toBe(0);
Keyboard.undo();
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.undo();
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.undo();
assertSelectedElements([rectA]);
expect(h.state.editingGroupId).toBe("inner");
Keyboard.redo();
assertSelectedElements([rectA, rectC]);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.redo();
assertSelectedElements([rectA, rectB, rectC]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.redo();
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
});
it("should iterate through the history when selected or editing linear element was remotely deleted", async () => { it("should iterate through the history when selected or editing linear element was remotely deleted", async () => {
// create three point arrow // create three point arrow
UI.clickTool("arrow"); UI.clickTool("arrow");
@@ -468,7 +468,7 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
@@ -519,7 +519,7 @@ describe("regression tests", () => {
mouse.reset(); mouse.reset();
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
for (const element of h.elements) { for (const element of h.elements) {
@@ -537,7 +537,7 @@ describe("regression tests", () => {
mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further
mouse.down(); mouse.down();
mouse.move(-1000, -1000); mouse.move(-1000, -1000);
mouse.restorePosition(end[0] + 3, end[1] + 3); mouse.restorePosition(...end);
mouse.up(); mouse.up();
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@@ -1,135 +0,0 @@
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);
});
});
+6 -568
View File
@@ -1,9 +1,7 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common"; import { KEYS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes"; import { SHAPES } from "../components/shapes";
@@ -14,7 +12,6 @@ import * as StaticScene from "../renderer/staticScene";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { import {
act,
render, render,
fireEvent, fireEvent,
mockBoundingClientRect, mockBoundingClientRect,
@@ -42,19 +39,6 @@ const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const getOutlineBounds = (element: ReturnType<typeof API.createElement>) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const points = getElementLineSegments(sceneElement, elementsMap).flat();
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
] as const;
};
describe("box-selection", () => { describe("box-selection", () => {
beforeEach(async () => { beforeEach(async () => {
await render(<Excalidraw />); await render(<Excalidraw />);
@@ -124,497 +108,6 @@ describe("box-selection", () => {
assertSelectedElements([]); assertSelectedElements([]);
}); });
it("should not select an element when the selection box only partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
mouse.downAt(25, -20);
mouse.move(-1000, -1000);
mouse.moveTo(75, 70);
mouse.up();
assertSelectedElements([]);
});
});
describe("lasso reselection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("should allow ctrl+alt lasso reselection when starting inside the active common bounds", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
const rectB = API.createElement({
type: "rectangle",
x: 220,
y: 0,
width: 100,
height: 100,
backgroundColor: "blue",
fillStyle: "solid",
});
API.setElements([rectA, rectB]);
mouse.select([rectA, rectB]);
act(() => {
h.app.setActiveTool({ type: "lasso" });
});
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
mouse.downAt(110, 50);
mouse.moveTo(50, -20);
expect(h.app.lassoTrail.hasCurrentTrail).toBe(true);
mouse.moveTo(-20, 50);
mouse.moveTo(50, 120);
mouse.moveTo(110, 50);
mouse.up();
});
assertSelectedElements([rectA.id]);
});
});
describe("box-selection overlap mode", () => {
const boxSelect = (
startX: number,
startY: number,
endX: number,
endY: number,
) => {
mouse.downAt(startX, startY);
mouse.move(-1000, -1000);
mouse.moveTo(endX, endY);
mouse.up();
};
const boxSelectTopLeftAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneElement, elementsMap);
boxSelect(x1 + 2, y1 + 2, x1 + 12, y1 + 12);
};
const boxSelectTopRightAabbCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [, y1, x2] = getElementBounds(sceneElement, elementsMap);
boxSelect(x2 - 12, y1 + 2, x2 - 2, y1 + 12);
};
const boxSelectTopLeftRotatedLocalBoundsCorner = (
element: ReturnType<typeof API.createElement>,
) => {
const sceneElement = API.getElement(element);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1, x2, y2] = getElementBounds(sceneElement, elementsMap, true);
const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
const [cornerX, cornerY] = pointRotateRads(
pointFrom(x1, y1),
center,
sceneElement.angle,
);
boxSelect(cornerX - 4, cornerY - 4, cornerX + 4, cornerY + 4);
};
beforeEach(async () => {
await render(
<Excalidraw
initialData={{ appState: { boxSelectionMode: "overlap" } }}
/>,
);
});
it("should select an element when the selection box partially overlaps it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 75, 75);
assertSelectedElements([]);
});
it("should select a transparent rectangle when the selection box crosses its outline", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(25, 25, 125, 75);
assertSelectedElements([rect1.id]);
});
it("should not select a rotated transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([rect1]);
boxSelect(40, 40, 60, 60);
assertSelectedElements([]);
});
it("should select a rotated rounded rectangle when the selection box contains its outline but not its bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 180,
angle: Math.PI / 6,
backgroundColor: "transparent",
fillStyle: "solid",
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS },
roughness: 0,
});
API.setElements([rect]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [boundsX1, boundsY1, boundsX2, boundsY2] = getElementBounds(
sceneRect,
elementsMap,
);
const [outlineX1, outlineY1, outlineX2, outlineY2] = getOutlineBounds(rect);
expect(outlineX1).toBeGreaterThan(boundsX1 - 1);
expect(outlineY1).toBeGreaterThan(boundsY1 - 1);
expect(outlineX2).toBeLessThan(boundsX2 + 1);
expect(outlineY2).toBeLessThan(boundsY2 + 1);
boxSelect(
outlineX1 - (outlineX1 - boundsX1) / 2,
outlineY1 - (outlineY1 - boundsY1) / 2,
outlineX2 + (boundsX2 - outlineX2) / 2,
outlineY2 + (boundsY2 - outlineY2) / 2,
);
assertSelectedElements([rect.id]);
});
it("should not select a filled rotated rectangle when the selection box only overlaps its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
boxSelectTopLeftAabbCorner(rect);
assertSelectedElements([]);
});
it("should not select a filled ellipse when the selection box only overlaps its bounds corner", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopRightAabbCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled diamond when the selection box only overlaps its bounds corner", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopRightAabbCorner(diamond);
assertSelectedElements([]);
});
it("should not select a filled rotated ellipse when the selection box only overlaps its axis-aligned bounds", () => {
const ellipse = API.createElement({
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([ellipse]);
boxSelectTopLeftRotatedLocalBoundsCorner(ellipse);
assertSelectedElements([]);
});
it("should not select a filled rotated diamond when the selection box only overlaps its rotated local bounds", () => {
const diamond = API.createElement({
type: "diamond",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([diamond]);
boxSelectTopLeftRotatedLocalBoundsCorner(diamond);
assertSelectedElements([]);
});
it("should not select rotated text when the selection box only overlaps its axis-aligned bounds", () => {
const text = API.createElement({
type: "text",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
text: "test",
});
API.setElements([text]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should not select rotated image when the selection box only overlaps its axis-aligned bounds", () => {
const image = API.createElement({
type: "image",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
fileId: "file_A",
status: "saved",
});
API.setElements([image]);
boxSelect(-18, -18, -8, -8);
assertSelectedElements([]);
});
it("should deselect a selected rotated rectangle when clicking in the empty corner of its axis-aligned bounds", () => {
const rect = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "red",
fillStyle: "solid",
});
API.setElements([rect]);
mouse.clickAt(50, 50);
assertSelectedElements([rect.id]);
const sceneRect = API.getElement(rect);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneRect, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a line when the selection box only overlaps its bounds", () => {
const line = API.createElement({
type: "line",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(100, 100)],
});
API.setElements([line]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not click-select rotated freedraw in the corner of its axis-aligned bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
angle: Math.PI / 4,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(100, 0),
pointFrom<LocalPoint>(100, 100),
pointFrom<LocalPoint>(0, 100),
pointFrom<LocalPoint>(0, 0),
],
});
API.setElements([freedraw]);
const sceneFreedraw = API.getElement(freedraw);
const elementsMap = h.scene.getNonDeletedElementsMap();
const [x1, y1] = getElementBounds(sceneFreedraw, elementsMap);
mouse.clickAt(x1 + 2, y1 + 2);
assertSelectedElements([]);
});
it("should not select a freedraw when the selection box only overlaps its bounds", () => {
const freedraw = API.createElement({
type: "freedraw",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
points: [
pointFrom<LocalPoint>(0, 0),
pointFrom<LocalPoint>(50, 50),
pointFrom<LocalPoint>(100, 100),
],
});
API.setElements([freedraw]);
boxSelect(20, 50, 30, 60);
assertSelectedElements([]);
});
it("should not select a transparent framed element when the selection box stays inside its clipped bounds", () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 100,
height: 100,
backgroundColor: "transparent",
fillStyle: "solid",
});
const rect1 = API.createElement({
type: "rectangle",
x: 50,
y: 10,
width: 100,
height: 80,
frameId: frame.id,
backgroundColor: "transparent",
fillStyle: "solid",
});
API.setElements([frame, rect1]);
boxSelect(60, 20, 90, 60);
assertSelectedElements([]);
});
}); });
describe("inner box-selection", () => { describe("inner box-selection", () => {
@@ -833,7 +326,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -866,7 +359,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -899,7 +392,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -945,7 +438,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -990,7 +483,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(10); expect(renderInteractiveScene).toHaveBeenCalledTimes(10);
expect(renderStaticScene).toHaveBeenCalledTimes(9); expect(renderStaticScene).toHaveBeenCalledTimes(8);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -1065,58 +558,3 @@ describe("selectedElementIds stability", () => {
expect(h.state.selectedElementIds).toBe(selectedElementIds_2); expect(h.state.selectedElementIds).toBe(selectedElementIds_2);
}); });
}); });
describe("deselecting", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("esc unwinds nested group editing before deselecting", () => {
const rectA = API.createElement({
type: "rectangle",
x: 0,
y: 0,
groupIds: ["inner", "outer"],
});
const rectB = API.createElement({
type: "rectangle",
x: 100,
y: 0,
groupIds: ["outer"],
});
const rectC = API.createElement({
type: "rectangle",
x: 200,
y: 0,
groupIds: ["inner", "outer"],
});
API.setElements([rectA, rectB, rectC]);
mouse.select(rectA);
assertSelectedElements(rectA, rectB, rectC);
expect(h.state.editingGroupId).toBeNull();
mouse.doubleClickOn(rectA);
assertSelectedElements(rectA, rectC);
expect(h.state.editingGroupId).toBe("outer");
mouse.doubleClickOn(rectA);
assertSelectedElements(rectA);
expect(h.state.editingGroupId).toBe("inner");
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements(rectA, rectC);
expect(h.state.editingGroupId).toBe("outer");
Keyboard.keyPress(KEYS.ESCAPE);
assertSelectedElements(rectA, rectB, rectC);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ outer: true });
Keyboard.keyPress(KEYS.ESCAPE);
expect(API.getSelectedElements()).toEqual([]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({});
});
});
@@ -1,88 +0,0 @@
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common";
import {
pointFrom,
pointRotateRads,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { EditorInterface } from "@excalidraw/common";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
const TEXT_AUTO_RESIZE_HANDLE_GAP = 12;
const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT =
TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2;
const MAX_HANDLE_HEIGHT_RATIO = 0.8;
export const getTextBoxPadding = (zoomValue: number) =>
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue;
export const getTextAutoResizeHandle = (
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
if (
formFactor !== "desktop" ||
TEXT_AUTO_RESIZE_HANDLE_LENGTH >
textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO
) {
return null;
}
const padding = getTextBoxPadding(zoomValue);
const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue;
const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue;
const center = pointFrom(
textElement.x + textElement.width / 2,
textElement.y + textElement.height / 2,
);
const handleCenter = pointRotateRads(
pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]),
center,
textElement.angle,
);
return {
center: handleCenter,
start: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] - length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
end: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] + length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue,
hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue,
};
};
export const isPointHittingTextAutoResizeHandle = (
point: Readonly<{ x: number; y: number }>,
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor);
if (!handle) {
return false;
}
const unrotatedPoint = pointRotateRads(
pointFrom(point.x, point.y),
handle.center,
-textElement.angle as Radians,
);
return (
Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 &&
Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2
);
};
+4 -80
View File
@@ -32,7 +32,6 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -269,8 +268,6 @@ export type ObservedElementsAppState = {
activeLockedId: AppState["activeLockedId"]; activeLockedId: AppState["activeLockedId"];
}; };
export type BoxSelectionMode = "contain" | "overlap";
export interface AppState { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
@@ -309,8 +306,6 @@ export interface AppState {
* `bindingPreference` and keyboard modifiers (ctrl/alt) * `bindingPreference` and keyboard modifiers (ctrl/alt)
*/ */
isBindingEnabled: boolean; isBindingEnabled: boolean;
/** user box selection preference; defaults to "contain" when unset */
boxSelectionMode: BoxSelectionMode;
/** user arrow binding preference */ /** user arrow binding preference */
bindingPreference: "enabled" | "disabled"; bindingPreference: "enabled" | "disabled";
/** user preference whether arrow snap to midpoints while binding */ /** user preference whether arrow snap to midpoints while binding */
@@ -332,7 +327,7 @@ export interface AppState {
/** /**
* set when a new text is created or when an existing text is being edited * set when a new text is created or when an existing text is being edited
*/ */
editingTextElement: ExcalidrawTextElement | null; editingTextElement: NonDeletedExcalidrawElement | null;
activeTool: { activeTool: {
/** /**
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
@@ -454,7 +449,6 @@ export interface AppState {
userToFollow: UserToFollow | null; userToFollow: UserToFollow | null;
/** the socket ids of the users following the current user */ /** the socket ids of the users following the current user */
followedBy: Set<SocketId>; followedBy: Set<SocketId>;
scrollConstraints: ScrollConstraints | null;
/** image cropping */ /** image cropping */
isCropping: boolean; isCropping: boolean;
@@ -547,8 +541,6 @@ export type ExcalidrawInitialDataState = Merge<
ImportedDataState, ImportedDataState,
{ {
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>; libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
scrollX?: number;
scrollY?: number;
} }
>; >;
@@ -665,7 +657,6 @@ export interface ExcalidrawProps {
onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void; onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
onUserFollow?: (payload: OnUserFollowedPayload) => void; onUserFollow?: (payload: OnUserFollowedPayload) => void;
children?: React.ReactNode; children?: React.ReactNode;
scrollConstraints?: AppState["scrollConstraints"];
validateEmbeddable?: validateEmbeddable?:
| boolean | boolean
| string[] | string[]
@@ -885,8 +876,9 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// explicit flag for specific scenarios such as: // Whether to block drag after lasso selection
// - after lasso selection until the next pointer down // this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean; blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
@@ -992,8 +984,6 @@ export interface ExcalidrawImperativeAPI {
) => UnsubscribeCallback; ) => UnsubscribeCallback;
onStateChange: InstanceType<typeof App>["onStateChange"]; onStateChange: InstanceType<typeof App>["onStateChange"];
onEvent: InstanceType<typeof App>["onEvent"]; onEvent: InstanceType<typeof App>["onEvent"];
app: InstanceType<typeof App>;
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
} }
export type FrameNameBounds = { export type FrameNameBounds = {
@@ -1060,69 +1050,3 @@ export type Offsets = Partial<{
bottom: number; bottom: number;
left: 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"];
};
@@ -1,10 +1,7 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { import { getOriginalContainerHeightFromCache } from "@excalidraw/element";
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -45,28 +42,6 @@ unmountComponent();
const tab = " "; const tab = " ";
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const exitTextEditorAndAssertSelection = async ({
editor,
selectedIds,
nextText,
}: {
editor: HTMLTextAreaElement;
selectedIds: string[];
nextText?: string;
}) => {
if (nextText !== undefined) {
updateTextEditor(editor, nextText);
}
Keyboard.exitTextEditor(editor);
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
expect(window.h.state.editingTextElement).toBeNull();
expect(API.getSelectedElements().map((element) => element.id)).toEqual(
selectedIds,
);
};
describe("textWysiwyg", () => { describe("textWysiwyg", () => {
describe("start text editing", () => { describe("start text editing", () => {
const { h } = window; const { h } = window;
@@ -235,42 +210,6 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
it("should vertically center newly created text on the cursor when clicked with text tool", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
});
UI.clickTool("text");
mouse.clickAt(120, 80);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight);
expect(editor).not.toBe(null);
expect(text.y + lineHeightPx / 2).toBe(80);
});
it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
gridModeEnabled: true,
gridSize: 24,
});
UI.clickTool("text");
mouse.clickAt(113, 86);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
expect(editor).not.toBe(null);
expect(text.x).toBe(96);
expect(text.y).toBe(72);
});
it("should edit text under cursor when double-clicked with selection tool", async () => { it("should edit text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -293,94 +232,6 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
it("should reselect text after exiting wysiwyg with escape", async () => {
const text = API.createElement({
type: "text",
text: "ola",
x: 60,
y: 0,
width: 100,
height: 100,
});
API.setElements([text]);
API.setSelectedElements([text]);
UI.clickTool("selection");
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).toBe(text.id);
await exitTextEditorAndAssertSelection({
editor,
selectedIds: [text.id],
});
});
it("should edit selected bound text on single click", async () => {
const container = API.createElement({
type: "rectangle",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(text.x + 26, text.y + 10);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
});
it("should not edit selected bound text container when only the container was single-clicked", async () => {
const container = API.createElement({
type: "rectangle",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(container.x + 5, container.y + 10);
expect(h.state.editingTextElement).toBe(null);
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
});
// FIXME too flaky. No one knows why. // FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => { it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
@@ -1354,40 +1205,6 @@ describe("textWysiwyg", () => {
); );
}); });
it.each([
{
label: "container",
createElements: () => API.createTextContainer(),
},
{
label: "arrow",
createElements: () => API.createLabeledArrow(),
},
])(
"should reselect $label after deleting bound text with escape",
async ({ createElements }) => {
const [selectedElement, text] = createElements();
API.setElements([selectedElement, text]);
API.setSelectedElements([selectedElement]);
Keyboard.keyPress(KEYS.ENTER);
const editor = await getTextEditor();
await exitTextEditorAndAssertSelection({
editor,
nextText: "",
selectedIds: [selectedElement.id],
});
expect(selectedElement.boundElements).toStrictEqual([]);
expect(h.elements[1]).toEqual(
expect.objectContaining({
isDeleted: true,
}),
);
},
);
it("should restore original container height and clear cache once text is unbind", async () => { it("should restore original container height and clear cache once text is unbind", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
@@ -1694,7 +1511,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 12.5, y: 25,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+2 -203
View File
@@ -10,9 +10,7 @@ import {
isTestEnv, isTestEnv,
MIME_TYPES, MIME_TYPES,
applyDarkModeFilter, applyDarkModeFilter,
isRTL,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import { import {
getTextFromElements, getTextFromElements,
@@ -35,11 +33,8 @@ import {
getBoundTextElement, getBoundTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getTextWidth } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import { getLineWidth } from "@excalidraw/element";
import { normalizeText } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element";
import { wrapText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element";
import { getWrappedTextLines } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
@@ -96,103 +91,6 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
const getLineDirection = (text: string, offset: number) => {
const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
const hardLineEnd = text.indexOf("\n", offset);
const hardLineText = text.slice(
hardLineStart,
hardLineEnd === -1 ? text.length : hardLineEnd,
);
return isRTL(hardLineText) ? "rtl" : "ltr";
};
const getCaretBoundaryOffsets = (text: string) => {
const offsets = [0];
let offset = 0;
for (const char of Array.from(text)) {
offset += char.length;
offsets.push(offset);
}
return offsets;
};
const getLineCaretOffsetFromNativeLayout = ({
text,
font,
lineHeightPx,
direction,
targetX,
}: {
text: string;
font: ReturnType<typeof getFontString>;
lineHeightPx: number;
direction: "ltr" | "rtl";
targetX: number;
}) => {
if (!text || !document.body || typeof document.createRange !== "function") {
return null;
}
const offsets = getCaretBoundaryOffsets(text);
const mirror = document.createElement("div");
const textNode = document.createTextNode(text);
const range = document.createRange();
const positions: number[] = [];
mirror.dir = direction;
Object.assign(mirror.style, {
position: "fixed",
top: "0",
left: "0",
margin: 0,
padding: 0,
border: 0,
opacity: "0",
pointerEvents: "none",
whiteSpace: "pre",
font,
lineHeight: `${lineHeightPx}px`,
});
mirror.append(textNode);
document.body.append(mirror);
try {
for (const offset of offsets) {
range.setStart(textNode, offset);
range.setEnd(textNode, offset);
const caretRect = range.getBoundingClientRect();
if (!Number.isFinite(caretRect.left)) {
return null;
}
positions.push(caretRect.left);
}
} catch {
return null;
} finally {
mirror.remove();
}
const leftEdge = Math.min(...positions);
let closestOffset = offsets[0];
let closestDistance = Infinity;
for (let index = 0; index < offsets.length; index++) {
const distance = Math.abs(positions[index] - leftEdge - targetX);
if (distance < closestDistance) {
closestDistance = distance;
closestOffset = offsets[index];
}
}
return closestOffset;
};
type SubmitHandler = () => void; type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
@@ -205,7 +103,6 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -222,19 +119,7 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
initialCaretSceneCoords?: { x: number; y: number } | null;
}): SubmitHandler => { }): SubmitHandler => {
let currentTextLayout: {
angle: Radians;
font: ReturnType<typeof getFontString>;
height: number;
lineHeightPx: number;
textAlign: ExcalidrawTextElement["textAlign"];
width: number;
x: number;
y: number;
} | null = null;
const textPropertiesUpdated = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@@ -369,7 +254,6 @@ export const textWysiwyg = ({
height *= 1.05; height *= 1.05;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
const angle = getTextElementAngle(updatedTextElement, container);
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
@@ -385,7 +269,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
angle, getTextElementAngle(updatedTextElement, container),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -399,19 +283,6 @@ export const textWysiwyg = ({
opacity: updatedTextElement.opacity / 100, opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`, maxHeight: `${editorMaxHeight}px`,
}); });
currentTextLayout = {
angle: angle as Radians,
font,
height: updatedTextElement.height,
lineHeightPx: getLineHeightInPx(
updatedTextElement.fontSize,
updatedTextElement.lineHeight,
),
textAlign,
width: updatedTextElement.width,
x: coordX,
y: coordY,
};
editable.scrollTop = 0; editable.scrollTop = 0;
// For some reason updating font attribute doesn't set font family // For some reason updating font attribute doesn't set font family
// hence updating font family explicitly for test environment // hence updating font family explicitly for test environment
@@ -462,71 +333,6 @@ export const textWysiwyg = ({
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
const getCaretIndexFromInitialSceneCoords = () => {
if (!initialCaretSceneCoords || !currentTextLayout) {
return null;
}
const layout = currentTextLayout;
const center = pointFrom(
layout.x + layout.width / 2,
layout.y + layout.height / 2,
);
const [unrotatedX, unrotatedY] = pointRotateRads(
pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y),
center,
-layout.angle as Radians,
);
const localX = unrotatedX - layout.x;
const localY = unrotatedY - layout.y;
const lines = getWrappedTextLines(
editable.value,
layout.font,
whiteSpace === "pre-wrap" ? layout.width : Infinity,
);
const lineIndex = Math.max(
0,
Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)),
);
const line = lines[lineIndex];
const direction = getLineDirection(editable.value, line.start);
const lineWidth = getLineWidth(line.text, layout.font);
const lineStartX =
layout.textAlign === "center"
? (layout.width - lineWidth) / 2
: layout.textAlign === "right"
? layout.width - lineWidth
: 0;
const relativeX = localX - lineStartX;
if (!line.text) {
return line.start;
}
const lineCaretOffset = getLineCaretOffsetFromNativeLayout({
text: line.text,
font: layout.font,
lineHeightPx: layout.lineHeightPx,
direction,
targetX: relativeX,
});
return line.start + (lineCaretOffset || 0);
};
let pendingInitialSelection = (() => {
const caretIndex = getCaretIndexFromInitialSceneCoords();
if (caretIndex === null) {
return null;
}
return {
start: caretIndex,
end: caretIndex,
};
})();
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
// we need to synchronously get the MIME types so we can preventDefault() // we need to synchronously get the MIME types so we can preventDefault()
@@ -890,13 +696,6 @@ export const textWysiwyg = ({
// Otherwise, re-enable submit on blur and refocus the editor. // Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
editable.focus(); editable.focus();
if (pendingInitialSelection) {
editable.setSelectionRange(
pendingInitialSelection.start,
pendingInitialSelection.end,
);
pendingInitialSelection = null;
}
}); });
}; };
@@ -987,7 +786,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect && !pendingInitialSelection) { if (autoSelect) {
// select on init (focusing is done separately inside the bindBlurEvent() // select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`) // because we need it to happen *after* the blur event from `pointerdown`)
editable.select(); editable.select();
+5 -39
View File
@@ -137,17 +137,8 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
[t0, s0]: [number, number], [t0, s0]: [number, number],
l: LineSegment<Point>, l: LineSegment<Point>,
c: Curve<Point>, c: Curve<Point>,
tolerance: number = 1e-2,
iterLimit: number = 4,
) => { ) => {
const solution = solveWithAnalyticalJacobian( const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 4);
c,
l,
t0,
s0,
tolerance,
iterLimit,
);
if (!solution) { if (!solution) {
return null; return null;
@@ -167,43 +158,18 @@ const calculate = <Point extends GlobalPoint | LocalPoint>(
*/ */
export function curveIntersectLineSegment< export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint, Point extends GlobalPoint | LocalPoint,
>( >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
c: Curve<Point>, let solution = calculate(initial_guesses[0], l, c);
l: LineSegment<Point>,
opts?: {
tolerance?: number;
iterLimit?: number;
},
): Point[] {
let solution = calculate(
initial_guesses[0],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate( solution = calculate(initial_guesses[1], l, c);
initial_guesses[1],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate( solution = calculate(initial_guesses[2], l, c);
initial_guesses[2],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
+2 -7
View File
@@ -123,15 +123,10 @@ export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
* @returns The rotated point * @returns The rotated point
*/ */
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>( export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
point: Point, [x, y]: Point,
center: Point, [cx, cy]: Point,
angle: Radians, angle: Radians,
): Point { ): Point {
if (!angle) {
return point;
}
const [x, y] = point;
const [cx, cy] = center;
return pointFrom( return pointFrom(
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+1 -1
View File
@@ -40,7 +40,7 @@ export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
Array.isArray(segment) && Array.isArray(segment) &&
segment.length === 2 && segment.length === 2 &&
isPoint(segment[0]) && isPoint(segment[0]) &&
isPoint(segment[1]); isPoint(segment[0]);
/** /**
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians * Return the coordinates resulting from rotating the given line about an origin by an angle in radians
+1 -25
View File
@@ -1,9 +1,5 @@
import { pointFrom } from "../src/point"; import { pointFrom } from "../src/point";
import { import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment";
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => { describe("line-segment intersections", () => {
it("should correctly detect intersection", () => { it("should correctly detect intersection", () => {
@@ -23,23 +19,3 @@ describe("line-segment intersections", () => {
).toEqual(null); ).toEqual(null);
}); });
}); });
describe("isLineSegment validation", () => {
it("should return true for a valid segment", () => {
expect(
isLineSegment([
[0, 0],
[1, 1],
]),
).toBe(true);
});
it("should return false if second element is not a point", () => {
const invalidSegment = [[0, 0], "not-a-point"] as any;
expect(isLineSegment(invalidSegment)).toBe(false);
});
it("should return false for wrong length", () => {
expect(isLineSegment([[0, 0]])).toBe(false);
});
});
@@ -13,7 +13,6 @@ exports[`exportToSvg > with default arguments 1`] = `
}, },
"bindMode": "orbit", "bindMode": "orbit",
"bindingPreference": "enabled", "bindingPreference": "enabled",
"boxSelectionMode": "contain",
"collaborators": Map {}, "collaborators": Map {},
"contextMenu": null, "contextMenu": null,
"croppingElementId": null, "croppingElementId": null,
@@ -85,7 +84,6 @@ exports[`exportToSvg > with default arguments 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
-7
View File
@@ -90,13 +90,6 @@ module.exports.woff2BrowserPlugin = () => {
type="font/woff2" type="font/woff2"
crossorigin="anonymous" crossorigin="anonymous"
/> />
<link
rel="preload"
href="${OSS_FONTS_CDN}fonts/Assistant/Assistant-SemiBold.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link <link
rel="preload" rel="preload"
href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2" href="${OSS_FONTS_CDN}fonts/ComicShanns/ComicShanns-Regular-279a7b317d12eb88de06167bd672b4b4.woff2"
+1
View File
@@ -1,4 +1,5 @@
{ {
"public": true,
"headers": [ "headers": [
{ {
"source": "/(.*)", "source": "/(.*)",
+4 -4
View File
@@ -1531,10 +1531,10 @@
resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
"@excalidraw/mermaid-to-excalidraw@2.2.2": "@excalidraw/mermaid-to-excalidraw@2.1.0":
version "2.2.2" version "2.1.0"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358"
integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg== integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw==
dependencies: dependencies:
"@excalidraw/markdown-to-text" "0.1.2" "@excalidraw/markdown-to-text" "0.1.2"
"@mermaid-js/parser" "^0.6.3" "@mermaid-js/parser" "^0.6.3"