Compare commits

...

22 Commits

Author SHA1 Message Date
dwelle b9b0b4c0d4 debug 2026-06-26 11:21:11 +02:00
dwelle 08827b18f8 wip 2026-06-26 10:22:23 +02:00
dwelle 470d28bedd remove sus predicate 2026-04-10 13:36:36 +02:00
dwelle 61bb357f8a fix group action regression 2026-04-10 13:36:15 +02:00
barnabasmolnar 4ade2172ee lint again, i guess... 2026-04-08 00:11:26 +02:00
barnabasmolnar 6021adab73 forward scrollConstraints prop from Excalidraw to App + fixed example app 2026-04-08 00:04:20 +02:00
barnabasmolnar fe3ba884f6 lint 2026-04-07 23:44:27 +02:00
barnabasmolnar 7dbd62a30c implement snap-free scroll constraints 2026-04-07 19:19:43 +02:00
Nand Gopal Sharma e18c1dd213 Fix typo in Discord badge URL parameter (#11096) 2026-04-02 10:37:02 +02:00
David Luzar d9e8a33aa4 feat(editor): implement overlap box selection (#11053)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-04-01 18:41:11 +02:00
dagecko 4be4cc0ed0 fix: pin 9 actions to commit SHA (#11075) 2026-03-30 16:49:27 +02:00
David Luzar 4a5c9e990c fix(editor): ensure font picker font names are not quoted (#11036) 2026-03-25 17:56:48 +01:00
David Luzar c09e170bdd feat(editor): deselect on esc (#11035)
Co-authored-by: Jawahar <jawahars_16@live.in>
Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
2026-03-25 17:14:24 +01:00
David Luzar c1082923ee feat(editor): support mermaid staate diagrams (#11031) 2026-03-24 20:20:28 +01:00
Kundan 1c292e4936 fix(math): correctly validate second point in isLineSegment (#11007)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-24 19:01:08 +01:00
Márk Tolmács d6f0f34fe9 fix: Rotated rounded arrow center point (#10962) 2026-03-23 15:54:59 +01:00
Márk Tolmács 75789f620d fix: Other endpoint is not immediately updated on midpoint snap (#10933)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-03-23 14:54:44 +00:00
David Luzar a9ca16eb42 chore(packages/excalidraw): export Fonts helper class (#11008) 2026-03-21 22:44:27 +01:00
Márk Tolmács 987173b52f fix: Arrow point index Out-of-Bounds (#10922)
* fix: Make OOB not fatal

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: More conservative temp arrow state update

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Capture condition variables in binding restoration failure

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 19:26:47 +01:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
75 changed files with 5311 additions and 456 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@0.6.0 - uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 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@v1.0.1 uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # 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@v2 uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # 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@v3 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # 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@v5 - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # 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@v1 - uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # 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@v2 uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,124 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [active] [ref=e5]:
- generic:
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection — V or 1" [ref=e38] [cursor=pointer]:
- radio "Selection" [checked]
- generic [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: "1"
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- generic [ref=e47]:
- img [ref=e48]
- generic [ref=e52]: "2"
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- generic [ref=e54]:
- img [ref=e55]
- generic [ref=e59]: "3"
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- generic [ref=e61]:
- img [ref=e62]
- generic [ref=e66]: "4"
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- generic [ref=e68]:
- img [ref=e69]
- generic [ref=e74]: "5"
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- generic [ref=e76]:
- img [ref=e77]
- generic [ref=e78]: "6"
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- generic [ref=e80]:
- img [ref=e81]
- generic [ref=e85]: "7"
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- generic [ref=e87]:
- img [ref=e88]
- generic [ref=e93]: "8"
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- generic [ref=e95]:
- img [ref=e96]
- generic [ref=e101]: "9"
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- generic [ref=e103]:
- img [ref=e104]
- generic [ref=e109]: "0"
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- generic [ref=e119]:
- link "Excalidraw+" [ref=e120] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [ref=e140] [cursor=pointer]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "lock scroll" [ref=e160] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
@@ -0,0 +1,126 @@
- generic [ref=e1]:
- banner:
- heading "Excalidraw" [level=1] [ref=e2]
- generic [ref=e5]:
- generic:
- generic:
- generic:
- generic:
- img
- img
- generic:
- text: Your drawings are saved in your browser's storage.
- text: Browser storage can be cleared unexpectedly.
- text: Save your work to a file regularly to avoid losing it.
- generic:
- button "Open Ctrl+O" [ref=e181] [cursor=pointer]:
- img [ref=e183]
- generic [ref=e185]: Open
- generic [ref=e186]: Ctrl+O
- button "Help ?" [ref=e187] [cursor=pointer]:
- img [ref=e189]
- generic [ref=e194]: Help
- generic [ref=e195]: "?"
- button "Live collaboration..." [ref=e196] [cursor=pointer]:
- img [ref=e198]
- generic [ref=e205]: Live collaboration...
- link "Sign up" [ref=e206] [cursor=pointer]:
- /url: https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest
- img [ref=e208]
- generic [ref=e214]: Sign up
- generic:
- generic:
- button [ref=e9] [cursor=pointer]:
- img [ref=e10]
- region "Shapes":
- generic [ref=e16]:
- generic:
- generic:
- text: To move canvas, hold
- generic: Scroll wheel
- text: or
- generic: Space
- text: while dragging, or use the hand tool
- heading "Shapes" [level=2] [ref=e17]
- generic [ref=e18]:
- generic "Keep selected tool active after drawing — Q" [ref=e19] [cursor=pointer]:
- checkbox "Keep selected tool active after drawing"
- img [ref=e21]
- generic "Hand (panning tool) — H or null" [ref=e29] [cursor=pointer]:
- radio "Hand (panning tool)"
- img [ref=e31]
- generic "Selection" [ref=e215] [cursor=pointer]:
- radio "Selection" [checked]
- img [ref=e217]
- generic "Rectangle — R or 2" [ref=e46] [cursor=pointer]:
- radio "Rectangle"
- img [ref=e48]
- generic "Diamond — D or 3" [ref=e53] [cursor=pointer]:
- radio "Diamond"
- img [ref=e55]
- generic "Ellipse — O or 4" [ref=e60] [cursor=pointer]:
- radio "Ellipse"
- img [ref=e62]
- generic "Arrow — A or 5" [ref=e67] [cursor=pointer]:
- radio "Arrow"
- img [ref=e69]
- generic "Line — L or 6" [ref=e75] [cursor=pointer]:
- radio "Line"
- img [ref=e77]
- generic "Draw — P or 7" [ref=e79] [cursor=pointer]:
- radio "Draw"
- img [ref=e81]
- generic "Text — T or 8" [ref=e86] [cursor=pointer]:
- radio "Text"
- img [ref=e88]
- generic "Insert image — 9" [ref=e94] [cursor=pointer]:
- radio "Insert image"
- img [ref=e96]
- generic "Eraser — E or 0" [ref=e102] [cursor=pointer]:
- radio "Eraser"
- img [ref=e104]
- button "More tools" [ref=e112] [cursor=pointer]:
- img [ref=e113]
- generic:
- button "Live collaboration..." [ref=e121] [cursor=pointer]:
- img [ref=e122]
- generic "Library" [ref=e124]:
- checkbox "Library"
- img [ref=e127] [cursor=pointer]
- contentinfo:
- region "Canvas actions" [ref=e132]:
- heading "Canvas actions" [level=2] [ref=e133]
- generic [ref=e135]:
- button "Zoom out" [ref=e136] [cursor=pointer]:
- img [ref=e138]
- button "Reset zoom" [disabled] [ref=e140]: 100%
- button "Zoom in" [ref=e141] [cursor=pointer]:
- img [ref=e143]
- generic [ref=e145]:
- button "Undo" [disabled] [ref=e148]:
- img [ref=e150]
- button "Redo" [disabled] [ref=e154]:
- img [ref=e156]
- generic [ref=e158]:
- generic [ref=e159]:
- button "disable scroll lock" [active] [ref=e222] [cursor=pointer]
- generic [ref=e161]:
- text: overscroll
- spinbutton "overscroll" [ref=e162]: "0.2"
- generic [ref=e163]:
- text: zoom factor
- spinbutton "zoom factor" [ref=e164]: "0.2"
- generic [ref=e165]:
- checkbox "lock zoom" [ref=e166]
- text: lock zoom
- generic [ref=e167]:
- checkbox "animate" [ref=e168]
- text: animate
- link "Blog post on end-to-end encryption in Excalidraw" [ref=e169] [cursor=pointer]:
- /url: https://plus.excalidraw.com/blog/end-to-end-encryption
- img [ref=e171]
- button "Help" [ref=e174] [cursor=pointer]:
- img [ref=e175]
- generic:
- img
- generic [ref=e180]: Drawing canvas
+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&widge=false"/></a> <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>
<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">
+13 -1
View File
@@ -4,8 +4,20 @@ 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 (
<> <>
@@ -14,7 +26,7 @@ const ExcalidrawWrapper: React.FC = () => {
useCustom={(api: any, args?: any[]) => {}} useCustom={(api: any, args?: any[]) => {}}
excalidrawLib={excalidrawLib} excalidrawLib={excalidrawLib}
> >
<Excalidraw /> <Excalidraw scrollConstraints={scrollConstraints} />
</App> </App>
</> </>
); );
@@ -178,7 +178,8 @@ export default function ExampleApp({
const newElement = cloneElement( const newElement = cloneElement(
Excalidraw, Excalidraw,
{ {
excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api), onExcalidrawAPI: (api: ExcalidrawImperativeAPI | null) =>
setExcalidrawAPI(api),
initialData: initialStatePromiseRef.current.promise, initialData: initialStatePromiseRef.current.promise,
onChange: ( onChange: (
elements: NonDeletedExcalidrawElement[], elements: NonDeletedExcalidrawElement[],
@@ -820,6 +821,48 @@ 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) {
+4 -1
View File
@@ -1015,7 +1015,10 @@ const ExcalidrawWrapper = () => {
</OverwriteConfirmDialog.Action> </OverwriteConfirmDialog.Action>
)} )}
</OverwriteConfirmDialog> </OverwriteConfirmDialog>
<AppFooter onChange={() => excalidrawAPI?.refresh()} /> <AppFooter
excalidrawAPI={excalidrawAPI}
onChange={() => excalidrawAPI?.refresh()}
/>
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
<TTDDialogTrigger /> <TTDDialogTrigger />
+168 -2
View File
@@ -1,13 +1,178 @@
import { Footer } from "@excalidraw/excalidraw/index"; import { Footer } from "@excalidraw/excalidraw/index";
import React from "react"; import {
getCommonBounds,
getSelectedElements,
getVisibleSceneBounds,
} from "@excalidraw/element";
import React, { useCallback, useState } from "react";
import type {
ExcalidrawImperativeAPI,
ScrollConstraints,
} from "@excalidraw/excalidraw/types";
import { isExcalidrawPlusSignedUser } from "../app_constants"; import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas"; import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { EncryptedIcon } from "./EncryptedIcon"; import { EncryptedIcon } from "./EncryptedIcon";
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
@@ -17,6 +182,7 @@ export const AppFooter = React.memo(
alignItems: "center", alignItems: "center",
}} }}
> >
<ScrollConstraintsDebugFooter excalidrawAPI={excalidrawAPI} />
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />} {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />} {!isExcalidrawPlusSignedUser && <EncryptedIcon />}
</div> </div>
+24 -4
View File
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
export const getBoundsFromPoints = ( export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
points: ExcalidrawFreeDrawElement["points"], points: readonly P[],
padding: number = 0,
): Bounds => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y); maxY = Math.max(maxY, y);
} }
return [minX, minY, maxX, maxY]; return [minX - padding, minY - padding, maxX + padding, maxY + padding];
}; };
const getFreeDrawElementAbsoluteCoords = ( const getFreeDrawElementAbsoluteCoords = (
@@ -1261,6 +1262,17 @@ 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,
@@ -1275,13 +1287,21 @@ 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)) { if (isLinearElement(element) || isFreeDrawElement(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);
+39 -17
View File
@@ -154,14 +154,11 @@ 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 = isPointWithinBounds( const hitBounds = isPointInRotatedBounds(
pointFrom(bounds[0] - threshold, bounds[1] - threshold), point,
pointRotateRads( bounds,
point, element.angle,
getCenterForBounds(bounds), threshold,
-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
@@ -192,18 +189,32 @@ 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,
) => { ) => {
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const bounds = getElementBounds(element, elementsMap, true);
x1 -= tolerance; return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
y1 -= tolerance;
x2 += tolerance;
y2 += tolerance;
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
}; };
export const hitElementBoundingBoxOnly = ( export const hitElementBoundingBoxOnly = (
@@ -465,7 +476,12 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst); return intersectLinearOrFreeDrawWithLineSegment(
element,
line,
elementsMap,
onlyFirst,
);
} }
}; };
@@ -532,11 +548,15 @@ 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(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections: GlobalPoint[] = []; const intersections: GlobalPoint[] = [];
for (const l of lines) { for (const l of lines) {
@@ -564,7 +584,9 @@ 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);
+6 -2
View File
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line": case "line":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
return distanceToLinearOrFreeDraElement(element, p); return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = ( const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
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)),
+11 -9
View File
@@ -1,7 +1,6 @@
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,
@@ -18,6 +17,8 @@ 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";
@@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = ( export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => { ) => {
return ( return elements.filter(
elementsOverlappingBBox({ (el) =>
elements, // exclude elements which are overlapping, but are in a different frame,
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
.filter((el) => !el.frameId || el.frameId === frame.id) (!el.frameId || el.frameId === frame.id) &&
doBoundsIntersect(
getElementBounds(el, elementsMap),
getElementBounds(frame, elementsMap),
),
); );
}; };
+31 -14
View File
@@ -476,16 +476,22 @@ export class LinearElementEditor {
}); });
} }
invariant( if (
lastClickedPoint > -1 && lastClickedPoint < 0 ||
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( ) {
selectedPointsIndices, console.error(
)}) points(0..${ `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
element.points.length - 1 selectedPointsIndices,
}) lastClickedPoint(${lastClickedPoint})`, )}) points(0..${
); 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];
@@ -794,6 +800,7 @@ export class LinearElementEditor {
element.points[index + 1], element.points[index + 1],
index, index,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
midpoints.push(null); midpoints.push(null);
@@ -803,6 +810,7 @@ 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++;
@@ -890,6 +898,7 @@ 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) {
@@ -904,7 +913,10 @@ 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(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant( invariant(
lines.length === 0 && curves.length > 0, lines.length === 0 && curves.length > 0,
@@ -924,6 +936,7 @@ 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(
@@ -936,7 +949,10 @@ 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(element); const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
invariant( invariant(
(lines.length === 0 && curves.length > 0) || (lines.length === 0 && curves.length > 0) ||
@@ -1851,6 +1867,7 @@ 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;
@@ -2400,7 +2417,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0] ? nextArrow.points[0]
: endBindable : endBindable
? updateBoundPoint( ? updateBoundPoint(
element, nextArrow,
"endBinding", "endBinding",
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
@@ -2431,7 +2448,7 @@ const pointDraggingUpdates = (
? endLocalPoint ? endLocalPoint
: startBindable : startBindable
? updateBoundPoint( ? updateBoundPoint(
element, nextArrow,
"startBinding", "startBinding",
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
+280 -38
View File
@@ -1,33 +1,71 @@
import { arrayToMap, isShallowEqual } from "@excalidraw/common"; import { arrayToMap, isShallowEqual, type Bounds } 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 { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import {
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
@@ -47,55 +85,243 @@ export const excludeElementsInFramesFromSelection = <
} }
}); });
return selectedElements.filter((element) => { return excludeElementsFromFrames(selectedElements, framesInSelection);
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",
const [selectionX1, selectionY1, selectionX2, selectionY2] = ): NonDeletedExcalidrawElement[] => {
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),
),
];
let elementsInSelection = elements.filter((element) => { const framesInSelection = excludeElementsInFrames
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( ? new Set<NonDeletedExcalidrawElement["id"]>()
element, : null;
elementsMap, let elementsInSelection: NonDeletedExcalidrawElement[] = [];
);
const containingFrame = getContainingFrame(element, elementsMap); for (const element of elements) {
if (containingFrame) { if (shouldIgnoreElementFromSelection(element)) {
const [fx1, fy1, fx2, fy2] = getElementBounds( continue;
containingFrame,
elementsMap,
);
elementX1 = Math.max(fx1, elementX1);
elementY1 = Math.max(fy1, elementY1);
elementX2 = Math.min(fx2, elementX2);
elementY2 = Math.min(fy2, elementY2);
} }
return ( const strokeWidth = element.strokeWidth;
element.locked === false && let labelAABB: Bounds | null = null;
element.type !== "selection" && let elementAABB = getElementBounds(element, elementsMap);
!isBoundToContainer(element) &&
selectionX1 <= elementX1 &&
selectionY1 <= elementY1 &&
selectionX2 >= elementX2 &&
selectionY2 >= elementY2
);
});
elementsInSelection = excludeElementsInFrames elementAABB = [
? excludeElementsInFramesFromSelection(elementsInSelection) 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,
);
labelAABB = [
x,
y,
x + boundTextElement.width,
y + boundTextElement.height,
] as Bounds;
}
// Clip element bounds by its containing frame (if any), since only the
// visible (frame-clipped) portion of the element is relevant for selection.
const associatedFrame = getContainingFrame(element, elementsMap);
if (
associatedFrame &&
isElementIntersectingFrame(element, associatedFrame, elementsMap)
) {
const frameAABB = getElementBounds(associatedFrame, elementsMap);
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
? ([
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) => {
@@ -288,3 +514,19 @@ 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;
};
+7 -16
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,7 +583,11 @@ 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,
@@ -592,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0, roughness: 0,
preserveVertices: true, preserveVertices: true,
}; };
const center = getCenterForBounds( const center = elementCenterPoint(element, elementsMap);
// 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":
+3 -1
View File
@@ -347,6 +347,7 @@ 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] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ 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;
@@ -358,6 +374,10 @@ 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();
@@ -370,56 +390,120 @@ 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 text; return getHardLineBreaks(text);
} }
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const originalLines = text.split("\n"); let offset = 0;
for (const originalLine of originalLines) { for (const originalLine of text.split("\n")) {
const currentLineWidth = getLineWidth(originalLine, font); const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (originalLineWidth <= maxWidth) {
lines.push(originalLine); lines.push({
continue; text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
const wrappedLine = wrapLine(originalLine, font, maxWidth); offset += originalLine.length + 1;
lines.push(...wrappedLine);
} }
return lines.join("\n"); return lines;
}; };
/** /**
* Wraps the original line into the lines based on the given width. * Wraps a single hard line into one or more visual lines.
*
* 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,
): string[] => { lineStart: number,
const lines: Array<string> = []; ): WrappedTextLine[] => {
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;
let iterator = tokenIterator.next(); while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
while (!iterator.done) { const tokenStart = tokenOffset;
const token = iterator.value; const tokenEnd = tokenStart + token.length;
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
@@ -429,37 +513,59 @@ 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;
iterator = tokenIterator.next(); tokenOffset = tokenEnd;
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); const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
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; currentLine = trailingLine.text;
currentLineWidth = getLineWidth(trailingLine, font); currentLineStart = trailingLine.start;
iterator = tokenIterator.next(); currentLineEnd = trailingLine.end;
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(currentLine.trimEnd()); lines.push(
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(currentLine, font, maxWidth); const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -467,59 +573,100 @@ const wrapLine = (
}; };
/** /**
* Wraps the word into the lines based on the given width. * Wraps a single word that could not be placed on an empty line as-is.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): Array<string> => { wordStart: number,
): 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 [word]; return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
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(currentLine); lines.push({
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(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Trims trailing whitespace that is 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 = (line: string, font: FontString, maxWidth: number) => { const trimLine = (
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 line; return {
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
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return trimmedLine; return {
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),
};
}; };
/** /**
+2 -4
View File
@@ -124,6 +124,7 @@ 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);
@@ -131,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape; return cachedShape;
} }
const ops = generateLinearCollisionShape(element) as { const ops = generateLinearCollisionShape(element, elementsMap);
op: string;
data: number[];
}[];
const lines = []; const lines = [];
const curves = []; const curves = [];
+3 -1
View File
@@ -1,4 +1,4 @@
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap, reseed } 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,6 +12,7 @@ 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} />);
}); });
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
}); });
localStorage.clear(); localStorage.clear();
reseed(7);
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
}); });
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping"; import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ 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"
+63 -9
View File
@@ -40,6 +40,7 @@ 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";
@@ -51,6 +52,8 @@ 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",
@@ -139,7 +142,7 @@ export const actionZoomIn = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: constrainScrollState({
...appState, ...appState,
...getStateForZoom( ...getStateForZoom(
{ {
@@ -150,7 +153,7 @@ export const actionZoomIn = register({
appState, appState,
), ),
userToFollow: null, userToFollow: null,
}, }),
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
@@ -180,7 +183,7 @@ export const actionZoomOut = register({
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => { perform: (_elements, appState, _, app) => {
return { return {
appState: { appState: constrainScrollState({
...appState, ...appState,
...getStateForZoom( ...getStateForZoom(
{ {
@@ -191,7 +194,7 @@ export const actionZoomOut = register({
appState, appState,
), ),
userToFollow: null, userToFollow: null,
}, }),
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
@@ -243,6 +246,7 @@ 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);
}} }}
@@ -251,6 +255,7 @@ 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),
@@ -274,7 +279,7 @@ const zoomValueToFitBoundsOnViewport = (
return Math.min(adjustedZoomValue, 1); return Math.min(adjustedZoomValue, 1);
}; };
export const zoomToFitBounds = ({ export function resolveViewportForBounds({
bounds, bounds,
appState, appState,
canvasOffsets, canvasOffsets,
@@ -292,7 +297,7 @@ export const zoomToFitBounds = ({
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;
@@ -345,12 +350,58 @@ export const zoomToFitBounds = ({
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: centerScroll.scrollX, scrollX: resolvedViewport.scrollX,
scrollY: centerScroll.scrollY, scrollY: resolvedViewport.scrollY,
zoom: { value: newZoomValue }, zoom: resolvedViewport.zoom,
}, },
captureUpdate: CaptureUpdateAction.EVENTUALLY, captureUpdate: CaptureUpdateAction.EVENTUALLY,
}; };
@@ -408,6 +459,7 @@ 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) =>
@@ -434,6 +486,7 @@ 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 &&
@@ -458,6 +511,7 @@ 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 &&
@@ -0,0 +1,145 @@
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,9 +348,7 @@ export const actionFinalize = register<FormData>({
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE && (event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
(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 }) => (
+12 -3
View File
@@ -49,9 +49,18 @@ import { register } from "./register";
import type { AppClassProperties, AppState } from "../types"; import type { AppClassProperties, AppState } from "../types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (
elements: readonly ExcalidrawElement[],
editingGroupId: AppState["editingGroupId"],
) => {
if (elements.length >= 2) { if (elements.length >= 2) {
const groupIds = elements[0].groupIds; const editingGroupIndex = editingGroupId
? elements[0].groupIds.indexOf(editingGroupId)
: -1;
const groupIds =
editingGroupIndex > -1
? elements[0].groupIds.slice(0, editingGroupIndex)
: elements[0].groupIds;
for (const groupId of groupIds) { for (const groupId of groupIds) {
if ( if (
elements.reduce( elements.reduce(
@@ -78,7 +87,7 @@ const enableActionGroup = (
return ( return (
selectedElements.length >= 2 && selectedElements.length >= 2 &&
!allElementsInSameGroup(selectedElements) && !allElementsInSameGroup(selectedElements, appState.editingGroupId) &&
!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,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), elementPredicate: 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 =
isRelevantElement === true elementPredicate === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => isRelevantElement(el)); : selectedElements.filter((el) => elementPredicate(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ app, updateData }) => ( PanelComponent: ({ elements, appState, app, updateData }) => {
<Range updateData={updateData} app={app} testId="opacity" /> const opacity = getFormValue(
), 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 { newElementWith } from "@excalidraw/element"; import { isExcalidrawElement, 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, app: AppClassProperties) => { predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, targetElement) => {
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 === selectedElements[0].id && isTextElement(element)) { if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
@@ -53,8 +53,5 @@ 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,6 +34,7 @@ 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,6 +95,13 @@ 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,6 +114,7 @@ export type ActionName =
| "distributeVertically" | "distributeVertically"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "deselect"
| "viewMode" | "viewMode"
| "exportWithDarkMode" | "exportWithDarkMode"
| "toggleTheme" | "toggleTheme"
+4
View File
@@ -122,12 +122,14 @@ 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",
}; };
}; };
@@ -193,6 +195,7 @@ 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: {
@@ -248,6 +251,7 @@ 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,6 +46,7 @@ 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;
@@ -86,6 +87,15 @@ 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,
@@ -114,7 +124,7 @@ export const FontPickerList = React.memo(
const fontDescriptor = { const fontDescriptor = {
value: familyId, value: familyId,
icon: getFontFamilyIcon(familyId), icon: getFontFamilyIcon(familyId),
text: fontFaces[0]?.fontFace?.family ?? "Unknown", text: getFontFamilyLabel(familyId, fontFaces),
}; };
if (metadata.deprecated) { if (metadata.deprecated) {
@@ -26,13 +26,16 @@
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;
width: 32px; min-width: 20px;
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);
+37 -33
View File
@@ -1,74 +1,78 @@
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 = {
updateData: (value: number) => void; label: React.ReactNode;
app: AppClassProperties; value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ updateData, app, testId }: RangeProps) => { export const Range = ({
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 = 15; // 15 is the width of the thumb const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (progress / 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) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; 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%)`;
} }
}, [value]); }, [max, min, value]);
return ( return (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {label}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonOpacity ["--color-slider-track" as string]: hasCommonValue
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min="0" min={min}
max="100" max={max}
step="10" step={step}
onChange={(event) => { onChange={(event) => {
updateData(+event.target.value); onChange(+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 !== 0 ? value : null} {value !== min ? value : null}
</div> </div>
<div className="zero-label">0</div> <div className="zero-label">{minLabel}</div>
</div> </div>
</label> </label>
); );
@@ -361,12 +361,10 @@ 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!");
act(() => { Keyboard.exitTextEditor(editor);
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text); API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -752,7 +750,7 @@ describe("frame resizing behavior", () => {
x: 0, x: 0,
y: 0, y: 0,
width: 100, width: 100,
height: 100, height: 103,
}); });
// Create a rectangle outside the frame // Create a rectangle outside the frame
@@ -54,6 +54,7 @@ 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
@@ -213,6 +214,7 @@ 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: 16rem; max-width: 20rem;
z-index: 1; z-index: 1;
&--placement-top { &--placement-top {
@@ -1,4 +1,5 @@
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> = {
@@ -12,6 +13,7 @@ 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,>({
@@ -21,13 +23,17 @@ 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">
<label className="dropdown-menu-item__text">{children}</label> {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<label className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
</label>
<RadioGroup <RadioGroup
name={name} name={name}
value={value} value={value}
@@ -39,7 +39,13 @@ 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 { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons"; import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
emptyIcon,
} from "../icons";
import { import {
boltIcon, boltIcon,
DeviceDesktopIcon, DeviceDesktopIcon,
@@ -427,6 +433,39 @@ 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();
@@ -568,6 +607,7 @@ 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 />
@@ -585,6 +625,7 @@ 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;
+7 -1
View File
@@ -6,6 +6,7 @@ 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";
@@ -49,6 +50,7 @@ export const prepareElementsForExport = (
exportSelectionOnly: boolean, exportSelectionOnly: boolean,
) => { ) => {
elements = getNonDeletedElements(elements); elements = getNonDeletedElements(elements);
const elementsMap = arrayToMap(elements);
const isExportingSelection = const isExportingSelection =
exportSelectionOnly && exportSelectionOnly &&
@@ -71,7 +73,11 @@ export const prepareElementsForExport = (
isFrameLikeElement(exportedElements[0]) isFrameLikeElement(exportedElements[0])
) { ) {
exportingFrame = exportedElements[0]; exportingFrame = exportedElements[0];
exportedElements = getElementsOverlappingFrame(elements, exportingFrame); exportedElements = getElementsOverlappingFrame(
elements,
exportingFrame,
elementsMap,
);
} else if (exportedElements.length > 1) { } else if (exportedElements.length > 1) {
exportedElements = getSelectedElements( exportedElements = getSelectedElements(
elements, elements,
-4
View File
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+9 -1
View File
@@ -251,7 +251,9 @@ const repairBinding = <T extends ExcalidrawArrowElement>(
}; };
} }
console.error(`could not repair binding for element`); console.error(
`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);
} }
@@ -934,6 +936,12 @@ 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,6 +98,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled, aiEnabled,
showDeprecatedFonts, showDeprecatedFonts,
renderScrollbars, renderScrollbars,
scrollConstraints,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -208,6 +209,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
aiEnabled={aiEnabled !== false} aiEnabled={aiEnabled !== false}
showDeprecatedFonts={showDeprecatedFonts} showDeprecatedFonts={showDeprecatedFonts}
renderScrollbars={renderScrollbars} renderScrollbars={renderScrollbars}
scrollConstraints={scrollConstraints}
> >
{children} {children}
</App> </App>
@@ -382,6 +384,8 @@ 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";
+4 -1
View File
@@ -185,6 +185,9 @@
"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"
}, },
@@ -661,7 +664,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, and Entity Relationship diagrams." "hint": "At the moment we know Flowchart, Sequence, Class, State, 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.1.1", "@excalidraw/mermaid-to-excalidraw": "2.2.2",
"@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,6 +41,7 @@ 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";
@@ -58,6 +59,8 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -86,6 +89,10 @@ 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,
@@ -1149,6 +1156,7 @@ const renderLinearPointHandles = (
points[idx], points[idx],
idx, idx,
appState.zoom, appState.zoom,
elementsMap,
) )
) { ) {
renderSingleLinearPoint( renderSingleLinearPoint(
@@ -1489,21 +1497,58 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; const padding = getTextBoxPadding(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 + width / 2; const cx = text.x + text.width / 2;
const cy = text.y + height / 2; const cy = text.y + text.height / 2;
const shiftX = -(width / 2 + padding); const shiftX = -(text.width / 2 + padding);
const shiftY = -(height / 2 + padding); const shiftY = -(text.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,
@@ -1584,10 +1629,19 @@ const _renderInteractiveScene = ({
} }
} }
if ( const activeTextElement = getActiveTextElement(selectedElements, appState);
appState.editingTextElement &&
isTextElement(appState.editingTextElement) if (activeTextElement && !activeTextElement.autoResize) {
) { 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;
+5 -1
View File
@@ -157,7 +157,11 @@ const prepareElementsForRender = ({
let nextElements: readonly ExcalidrawElement[]; let nextElements: readonly ExcalidrawElement[];
if (exportingFrame) { if (exportingFrame) {
nextElements = getElementsOverlappingFrame(elements, exportingFrame); nextElements = getElementsOverlappingFrame(
elements,
exportingFrame,
arrayToMap(elements),
);
} else if (frameRendering.enabled && frameRendering.name) { } else if (frameRendering.enabled && frameRendering.name) {
nextElements = addFrameLabelsAsTextElements(elements, { nextElements = addFrameLabelsAsTextElements(elements, {
exportWithDarkMode, exportWithDarkMode,
@@ -0,0 +1,590 @@
import { MAX_ZOOM } from "@excalidraw/common";
import { isShallowEqual } from "@excalidraw/common";
import { clamp } from "@excalidraw/math";
import { getNormalizedZoom } from "./normalize";
import type {
AnimateTranslateCanvasValues,
AppState,
ScrollConstraints,
ScrollToContentLockOptions,
} from "../types";
// Constants for viewport zoom factor and overscroll allowance
const MIN_VIEWPORT_ZOOM_FACTOR = 0.1;
const MAX_VIEWPORT_ZOOM_FACTOR = MAX_ZOOM;
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.2;
const DEFAULT_OVERSCROLL_ALLOWANCE = 0.2;
// Memoization variable to cache constraints for performance optimization
let memoizedValues: {
previousState: Pick<
AppState,
"zoom" | "width" | "height" | "scrollConstraints"
>;
constraints: ReturnType<typeof calculateConstraints>;
allowOverscroll: boolean;
} | null = null;
type CanvasTranslate = Pick<AppState, "scrollX" | "scrollY" | "zoom">;
export const getScrollConstraintsForBounds = ({
bounds,
zoom,
viewportDimensions,
scrollLock,
}: {
bounds: readonly [number, number, number, number];
zoom: AppState["zoom"];
viewportDimensions: Pick<AppState, "width" | "height">;
scrollLock?: boolean | ScrollToContentLockOptions;
}): ScrollConstraints => {
const [x1, y1, x2, y2] = bounds;
const width = x2 - x1;
const height = y2 - y1;
const baseZoom = Math.min(
viewportDimensions.width / width,
viewportDimensions.height / height,
);
const lockOptions =
scrollLock && typeof scrollLock === "object" ? scrollLock : undefined;
const viewportZoomFactor =
lockOptions?.viewportZoomFactor ?? zoom.value / baseZoom;
return {
x: x1,
y: y1,
width,
height,
animateOnNextUpdate: false,
lockZoom: !!lockOptions?.lockZoom,
overscrollAllowance: lockOptions?.overscrollAllowance,
viewportZoomFactor,
};
};
/**
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
*
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param width - The width of the viewport.
* @param height - The height of the viewport.
* @returns An object containing the calculated zoom levels for the X and Y axes, and the initial zoom level.
*/
const calculateZoomLevel = (
scrollConstraints: ScrollConstraints,
width: AppState["width"],
height: AppState["height"],
) => {
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? clamp(
scrollConstraints.viewportZoomFactor,
MIN_VIEWPORT_ZOOM_FACTOR,
MAX_VIEWPORT_ZOOM_FACTOR,
)
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
const scrollableWidth = scrollConstraints.width;
const scrollableHeight = scrollConstraints.height;
const zoomLevelX = width / scrollableWidth;
const zoomLevelY = height / scrollableHeight;
const initialZoomLevel = getNormalizedZoom(
Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor,
);
return { zoomLevelX, zoomLevelY, initialZoomLevel };
};
/**
* Calculates the effective zoom level based on the scroll constraints and current zoom.
*
* @param params - Object containing scrollConstraints, width, height, and zoom.
* @returns An object with the effective zoom level, initial zoom level, and zoom levels for X and Y axes.
*/
const calculateZoom = ({
scrollConstraints,
width,
height,
zoom,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
}) => {
const { zoomLevelX, zoomLevelY, initialZoomLevel } = calculateZoomLevel(
scrollConstraints,
width,
height,
);
const effectiveZoom = scrollConstraints.lockZoom
? Math.max(initialZoomLevel, zoom.value)
: zoom.value;
return {
effectiveZoom: getNormalizedZoom(effectiveZoom),
initialZoomLevel,
zoomLevelX,
zoomLevelY,
};
};
/**
* Calculates the scroll bounds (min and max scroll values) based on the scroll constraints and zoom level.
*
* @param params - Object containing scrollConstraints, width, height, effectiveZoom, zoomLevelX, zoomLevelY, and allowOverscroll.
* @returns An object with min and max scroll values for X and Y axes.
*/
const calculateScrollBounds = ({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
effectiveZoom: number;
zoomLevelX: number;
zoomLevelY: number;
allowOverscroll: boolean;
}) => {
const overscrollAllowance =
scrollConstraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE;
const validatedOverscroll = clamp(overscrollAllowance, 0, 1);
const calculateCenter = (zoom: number) => {
const centerX =
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
const centerY =
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
return { centerX, centerY };
};
const { centerX, centerY } = calculateCenter(effectiveZoom);
const overscrollValue = Math.min(
validatedOverscroll * scrollConstraints.width,
validatedOverscroll * scrollConstraints.height,
);
const fitsX = effectiveZoom <= zoomLevelX;
const fitsY = effectiveZoom <= zoomLevelY;
const getScrollRange = (
axis: "x" | "y",
fits: boolean,
constraint: ScrollConstraints,
viewportSize: number,
zoom: number,
overscroll: number,
) => {
const { pos, size } =
axis === "x"
? { pos: constraint.x, size: constraint.width }
: { pos: constraint.y, size: constraint.height };
const center = axis === "x" ? centerX : centerY;
if (allowOverscroll) {
return fits
? { min: center - overscroll, max: center + overscroll }
: {
min: pos - size + viewportSize / zoom - overscroll,
max: pos + overscroll,
};
}
return fits
? { min: center, max: center }
: { min: pos - size + viewportSize / zoom, max: pos };
};
const xRange = getScrollRange(
"x",
fitsX,
scrollConstraints,
width,
effectiveZoom,
overscrollValue,
);
const yRange = getScrollRange(
"y",
fitsY,
scrollConstraints,
height,
effectiveZoom,
overscrollValue,
);
return {
minScrollX: xRange.min,
maxScrollX: xRange.max,
minScrollY: yRange.min,
maxScrollY: yRange.max,
};
};
/**
* Calculates the scroll constraints including min and max scroll values and the effective zoom level.
*
* @param params - Object containing scrollConstraints, width, height, zoom, and allowOverscroll.
* @returns An object with min and max scroll values, effective zoom, and initial zoom level.
*/
const calculateConstraints = ({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
allowOverscroll: boolean;
}) => {
const { effectiveZoom, initialZoomLevel, zoomLevelX, zoomLevelY } =
calculateZoom({ scrollConstraints, width, height, zoom });
const scrollBounds = calculateScrollBounds({
scrollConstraints,
width,
height,
effectiveZoom,
zoomLevelX,
zoomLevelY,
allowOverscroll,
});
return {
...scrollBounds,
effectiveZoom: { value: effectiveZoom },
initialZoomLevel,
};
};
/**
* Constrains the scroll values within the provided min and max bounds.
*
* @param params - Object containing scrollX, scrollY, minScrollX, maxScrollX, minScrollY, maxScrollY, and constrainedZoom.
* @returns An object with constrained scrollX, scrollY, and zoom.
*/
const constrainScrollValues = ({
scrollX,
scrollY,
minScrollX,
maxScrollX,
minScrollY,
maxScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
minScrollX: number;
maxScrollX: number;
minScrollY: number;
maxScrollY: number;
constrainedZoom: AppState["zoom"];
}): CanvasTranslate => {
const constrainedScrollX = clamp(scrollX, minScrollX, maxScrollX);
const constrainedScrollY = clamp(scrollY, minScrollY, maxScrollY);
return {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
};
/**
* Inverts the scroll constraints to align with the state scrollX and scrollY values, which are inverted.
* This is a temporary fix and should be removed once issue #5965 is resolved.
*
* @param originalScrollConstraints - The original scroll constraints.
* @returns The aligned scroll constraints with inverted x and y coordinates.
*/
const alignScrollConstraints = (
originalScrollConstraints: ScrollConstraints,
): ScrollConstraints => {
return {
...originalScrollConstraints,
x: originalScrollConstraints.x * -1,
y: originalScrollConstraints.y * -1,
};
};
/**
* Determines whether the current viewport is outside the constrained area.
*
* @param state - The application state.
* @returns True if the viewport is outside the constrained area, false otherwise.
*/
const isViewportOutsideOfConstrainedArea = (state: AppState): boolean => {
if (!state.scrollConstraints) {
return false;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const adjustedWidth = width / zoom.value;
const adjustedHeight = height / zoom.value;
return (
scrollX > scrollConstraints.x ||
scrollX - adjustedWidth < scrollConstraints.x - scrollConstraints.width ||
scrollY > scrollConstraints.y ||
scrollY - adjustedHeight < scrollConstraints.y - scrollConstraints.height
);
};
/**
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
*
* @param state - The application state.
* @param scroll - Object containing current scrollX and scrollY.
* @returns An object with the calculated scrollX, scrollY, and zoom.
*/
export const calculateConstrainedScrollCenter = (
state: AppState,
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
): CanvasTranslate => {
const { width, height, scrollConstraints } = state;
if (!scrollConstraints) {
return { scrollX, scrollY, zoom: state.zoom };
}
const adjustedConstraints = alignScrollConstraints(scrollConstraints);
const zoomLevels = calculateZoomLevel(adjustedConstraints, width, height);
const initialZoom = { value: zoomLevels.initialZoomLevel };
const constraints = calculateConstraints({
scrollConstraints: adjustedConstraints,
width,
height,
zoom: initialZoom,
allowOverscroll: false,
});
return {
scrollX: constraints.minScrollX,
scrollY: constraints.minScrollY,
zoom: constraints.effectiveZoom,
};
};
/**
* Encodes scroll constraints into a compact string.
*
* @param constraints - The scroll constraints to encode.
* @returns A compact encoded string representing the scroll constraints.
*/
export const encodeConstraints = (constraints: ScrollConstraints): string => {
const payload = {
x: constraints.x,
y: constraints.y,
w: constraints.width,
h: constraints.height,
a: !!constraints.animateOnNextUpdate,
l: !!constraints.lockZoom,
v: constraints.viewportZoomFactor ?? 1,
oa: constraints.overscrollAllowance ?? DEFAULT_OVERSCROLL_ALLOWANCE,
};
const serialized = JSON.stringify(payload);
return encodeURIComponent(window.btoa(serialized).replace(/=+/, ""));
};
/**
* Decodes a compact string back into scroll constraints.
*
* @param encoded - The encoded string representing the scroll constraints.
* @returns The decoded scroll constraints object.
*/
export const decodeConstraints = (encoded: string): ScrollConstraints => {
try {
const decodedStr = window.atob(decodeURIComponent(encoded));
const parsed = JSON.parse(decodedStr) as {
x: number;
y: number;
w: number;
h: number;
a: boolean;
l: boolean;
v: number;
oa: number;
};
return {
x: parsed.x || 0,
y: parsed.y || 0,
width: parsed.w || 0,
height: parsed.h || 0,
lockZoom: parsed.l || false,
viewportZoomFactor: parsed.v || 1,
animateOnNextUpdate: parsed.a || false,
overscrollAllowance: parsed.oa || DEFAULT_OVERSCROLL_ALLOWANCE,
};
} catch (error) {
return {
x: 0,
y: 0,
width: 0,
height: 0,
animateOnNextUpdate: false,
lockZoom: false,
viewportZoomFactor: 1,
overscrollAllowance: DEFAULT_OVERSCROLL_ALLOWANCE,
};
}
};
type Options = { allowOverscroll: boolean; disableAnimation: boolean };
const DEFAULT_OPTION: Options = {
allowOverscroll: true,
disableAnimation: false,
};
/**
* Constrains the AppState scroll values within the defined scroll constraints.
*
* constraintMode can be "elastic", "rigid", or "loose":
* - "elastic": snaps to constraints but allows overscroll
* - "rigid": snaps to constraints without overscroll
* - "loose": allows overscroll and disables animation/snapping to constraints
*
* @param state - The original AppState.
* @param options - Options for allowing overscroll and disabling animation.
* @returns A new AppState object with constrained scroll values.
*/
export const constrainScrollState = (
state: AppState,
constraintMode: "elastic" | "rigid" | "loose" = "elastic",
): AppState => {
if (!state.scrollConstraints) {
return state;
}
const {
scrollX,
scrollY,
width,
height,
scrollConstraints: inverseScrollConstraints,
zoom,
} = state;
let allowOverscroll: boolean;
let disableAnimation: boolean;
switch (constraintMode) {
case "elastic":
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
case "rigid":
allowOverscroll = false;
disableAnimation = false;
break;
case "loose":
allowOverscroll = true;
disableAnimation = true;
break;
default:
({ allowOverscroll, disableAnimation } = DEFAULT_OPTION);
break;
}
const scrollConstraints = alignScrollConstraints(inverseScrollConstraints);
const canUseMemoizedValues =
memoizedValues &&
memoizedValues.previousState.scrollConstraints &&
memoizedValues.allowOverscroll === allowOverscroll &&
isShallowEqual(
state.scrollConstraints,
memoizedValues.previousState.scrollConstraints,
) &&
isShallowEqual(
{ zoom: zoom.value, width, height },
{
zoom: memoizedValues.previousState.zoom.value,
width: memoizedValues.previousState.width,
height: memoizedValues.previousState.height,
},
);
const constraints = canUseMemoizedValues
? memoizedValues!.constraints
: calculateConstraints({
scrollConstraints,
width,
height,
zoom,
allowOverscroll,
});
if (!canUseMemoizedValues) {
memoizedValues = {
previousState: {
zoom: state.zoom,
width: state.width,
height: state.height,
scrollConstraints: state.scrollConstraints,
},
constraints,
allowOverscroll,
};
}
const constrainedValues =
zoom.value >= constraints.effectiveZoom.value
? constrainScrollValues({
scrollX,
scrollY,
minScrollX: constraints.minScrollX,
maxScrollX: constraints.maxScrollX,
minScrollY: constraints.minScrollY,
maxScrollY: constraints.maxScrollY,
constrainedZoom: constraints.effectiveZoom,
})
: calculateConstrainedScrollCenter(state, { scrollX, scrollY });
return {
...state,
scrollConstraints: {
...state.scrollConstraints,
animateOnNextUpdate: disableAnimation
? false
: isViewportOutsideOfConstrainedArea(state),
},
...constrainedValues,
};
};
/**
* Checks if two canvas translate values are close within a threshold.
*
* @param from - First set of canvas translate values.
* @param to - Second set of canvas translate values.
* @returns True if the values are close, false otherwise.
*/
export const areCanvasTranslatesClose = (
from: AnimateTranslateCanvasValues,
to: AnimateTranslateCanvasValues,
): boolean => {
const threshold = 0.1;
return (
Math.abs(from.scrollX - to.scrollX) < threshold &&
Math.abs(from.scrollY - to.scrollY) < threshold &&
Math.abs(from.zoom - to.zoom) < threshold
);
};
+62
View File
@@ -1,5 +1,67 @@
import { constrainScrollState } from "./scrollConstraints";
import type { AppState, NormalizedZoomValue } from "../types"; 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,6 +13,7 @@ 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": [
@@ -960,6 +961,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1086,6 +1088,7 @@ 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,
@@ -1156,6 +1159,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -1300,6 +1304,7 @@ 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,
@@ -1370,6 +1375,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1631,6 +1637,7 @@ 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,
@@ -1701,6 +1708,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1962,6 +1970,7 @@ 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,
@@ -2032,6 +2041,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -2176,6 +2186,7 @@ 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,
@@ -2246,6 +2257,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2417,6 +2429,7 @@ 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,
@@ -2487,6 +2500,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2715,6 +2729,7 @@ 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,
@@ -2787,6 +2802,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3087,6 +3103,7 @@ 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,
@@ -3157,6 +3174,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3580,6 +3598,7 @@ 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,
@@ -3650,6 +3669,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3903,6 +3923,7 @@ 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,
@@ -3973,6 +3994,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4226,6 +4248,7 @@ 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,
@@ -4298,6 +4321,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4358,7 +4382,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 760410951, "versionNonce": 1006504105,
"width": 20, "width": 20,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -4383,14 +4407,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": 238820263, "seed": 400692809,
"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": 1006504105, "versionNonce": 289600103,
"width": 20, "width": 20,
"x": 20, "x": 20,
"y": 30, "y": 30,
@@ -4637,6 +4661,7 @@ 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": [
@@ -5583,6 +5608,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5854,6 +5880,7 @@ 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": [
@@ -6800,6 +6827,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6864,7 +6892,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": 747212839, "versionNonce": 1723083209,
"width": 10, "width": 10,
"x": -10, "x": -10,
"y": 0, "y": 0,
@@ -6891,14 +6919,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": 238820263, "seed": 400692809,
"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": 1723083209, "versionNonce": 760410951,
"width": 10, "width": 10,
"x": 12, "x": 12,
"y": 0, "y": 0,
@@ -7122,6 +7150,7 @@ 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": [
@@ -7758,6 +7787,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7811,6 +7841,7 @@ 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": [
@@ -8755,6 +8786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": true, "scrolledOutside": true,
@@ -8802,6 +8834,7 @@ 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": [
@@ -9749,6 +9782,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ 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,
@@ -87,6 +88,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -439,6 +441,7 @@ 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,
@@ -513,6 +516,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -855,6 +859,7 @@ 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,
@@ -925,6 +930,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1421,6 +1427,7 @@ 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,
@@ -1491,6 +1498,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -1628,6 +1636,7 @@ 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,
@@ -1700,6 +1709,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2012,6 +2022,7 @@ 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,
@@ -2084,6 +2095,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2257,6 +2269,7 @@ 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,
@@ -2327,6 +2340,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2437,6 +2451,7 @@ 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,
@@ -2509,6 +2524,7 @@ exports[`regression tests > can drag element that covers another element, while
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -2762,6 +2778,7 @@ 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,
@@ -2832,6 +2849,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3017,6 +3035,7 @@ 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,
@@ -3089,6 +3108,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3258,6 +3278,7 @@ 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,
@@ -3330,6 +3351,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3494,6 +3516,7 @@ 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,
@@ -3566,6 +3589,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -3752,6 +3776,7 @@ 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,
@@ -3824,6 +3849,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4066,6 +4092,7 @@ 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,
@@ -4136,6 +4163,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4502,6 +4530,7 @@ 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,
@@ -4575,6 +4604,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -4785,6 +4815,7 @@ 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,
@@ -4858,6 +4889,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5061,6 +5093,7 @@ 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,
@@ -5133,6 +5166,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5269,6 +5303,7 @@ 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,
@@ -5341,6 +5376,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5469,6 +5505,7 @@ 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,
@@ -5539,6 +5576,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -5862,6 +5900,7 @@ 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,
@@ -5935,6 +5974,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6159,6 +6199,7 @@ 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,
@@ -6229,6 +6270,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -6947,6 +6989,7 @@ 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,
@@ -7020,6 +7063,7 @@ exports[`regression tests > given a group of selected elements with an element t
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7281,6 +7325,7 @@ 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,
@@ -7353,6 +7398,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7560,6 +7606,7 @@ 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,
@@ -7632,6 +7679,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -7795,6 +7843,7 @@ 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,
@@ -7867,6 +7916,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8035,6 +8085,7 @@ 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,
@@ -8105,6 +8156,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8215,6 +8267,7 @@ 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,
@@ -8285,6 +8338,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8395,6 +8449,7 @@ 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,
@@ -8465,6 +8520,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8575,6 +8631,7 @@ 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,
@@ -8645,6 +8702,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -8807,6 +8865,7 @@ 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,
@@ -8877,6 +8936,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9037,6 +9097,7 @@ 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,
@@ -9107,6 +9168,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9229,6 +9291,7 @@ 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,
@@ -9299,6 +9362,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9461,6 +9525,7 @@ 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,
@@ -9531,6 +9596,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9641,6 +9707,7 @@ 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,
@@ -9711,6 +9778,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -9871,6 +9939,7 @@ 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,
@@ -9941,6 +10010,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10051,6 +10121,7 @@ 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,
@@ -10121,6 +10192,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10243,6 +10315,7 @@ 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,
@@ -10313,6 +10386,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10423,6 +10497,7 @@ 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,
@@ -10497,6 +10572,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -10954,6 +11030,7 @@ 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,
@@ -11026,6 +11103,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11234,6 +11312,7 @@ 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,
@@ -11304,6 +11383,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": "-6.25000", "scrollX": "-6.25000",
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11357,6 +11437,7 @@ 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,
@@ -11429,6 +11510,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11557,6 +11639,7 @@ 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,
@@ -11630,6 +11713,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -11876,6 +11960,7 @@ 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,
@@ -11950,6 +12035,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -12305,6 +12391,7 @@ 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,
@@ -12380,6 +12467,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -12945,6 +13033,7 @@ 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,
@@ -13018,6 +13107,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 60, "scrollX": 60,
"scrollY": 60, "scrollY": 60,
"scrolledOutside": false, "scrolledOutside": false,
@@ -13071,6 +13161,7 @@ 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,
@@ -13143,6 +13234,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -13702,6 +13794,7 @@ 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,
@@ -13775,6 +13868,7 @@ exports[`regression tests > switches from group of selected elements to another
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14041,6 +14135,7 @@ 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,
@@ -14113,6 +14208,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id3": true, "id3": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14305,6 +14401,7 @@ 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,
@@ -14375,6 +14472,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 20, "scrollX": 20,
"scrollY": "-18.53553", "scrollY": "-18.53553",
"scrolledOutside": false, "scrolledOutside": false,
@@ -14428,6 +14526,7 @@ 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,
@@ -14498,6 +14597,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14792,6 +14892,7 @@ 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,
@@ -14862,6 +14963,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@@ -14915,6 +15017,7 @@ 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,
@@ -14988,6 +15091,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
+12 -2
View File
@@ -4,6 +4,7 @@ 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 {
@@ -20,7 +21,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -516,8 +517,17 @@ 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, y); mouse.click(x, clickY);
} 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();
+77 -1
View File
@@ -314,7 +314,7 @@ describe("history", () => {
expect.objectContaining({ id: rect2.id, isDeleted: true }), expect.objectContaining({ id: rect2.id, isDeleted: true }),
]); ]);
mouse.downAt(0, 0); mouse.downAt(-10, -10);
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,6 +2971,82 @@ 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); mouse.restorePosition(end[0] + 3, end[1] + 3);
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); mouse.restorePosition(end[0] + 3, end[1] + 3);
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); mouse.restorePosition(end[0] + 3, end[1] + 3);
mouse.up(); mouse.up();
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => { Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
@@ -0,0 +1,135 @@
import React from "react";
import { vi } from "vitest";
import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { act, render, waitFor } from "./test-utils";
import type { ExcalidrawImperativeAPI, ScrollConstraints } from "../types";
const FIRST_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 0,
y: 0,
width: 400,
height: 300,
lockZoom: true,
overscrollAllowance: 0,
viewportZoomFactor: 1,
animateOnNextUpdate: false,
};
const SECOND_SCROLL_CONSTRAINTS: ScrollConstraints = {
x: 100,
y: 200,
width: 500,
height: 350,
lockZoom: false,
overscrollAllowance: 0.2,
viewportZoomFactor: 0.8,
animateOnNextUpdate: false,
};
describe("scrollConstraints", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("syncs prop updates after mount", async () => {
const { rerender } = await render(<Excalidraw />);
expect(window.h.state.scrollConstraints).toBe(null);
rerender(<Excalidraw scrollConstraints={FIRST_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
FIRST_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw scrollConstraints={SECOND_SCROLL_CONSTRAINTS} />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toEqual(
SECOND_SCROLL_CONSTRAINTS,
);
});
rerender(<Excalidraw />);
await waitFor(() => {
expect(window.h.state.scrollConstraints).toBe(null);
});
});
it("ignores setScrollConstraints() when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
act(() => {
excalidrawAPI.setScrollConstraints(SECOND_SCROLL_CONSTRAINTS);
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `setScrollConstraints()` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
it("ignores scrollToContent() scrollLock when the prop is controlled", async () => {
const excalidrawAPIPromise = resolvablePromise<ExcalidrawImperativeAPI>();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await render(
<Excalidraw
scrollConstraints={FIRST_SCROLL_CONSTRAINTS}
onExcalidrawAPI={(api) => {
if (api) {
excalidrawAPIPromise.resolve(api);
}
}}
/>,
);
const excalidrawAPI = await excalidrawAPIPromise;
const rectangle = API.createElement({
x: 1000,
y: 1000,
width: 100,
height: 100,
});
API.setElements([rectangle]);
act(() => {
excalidrawAPI.scrollToContent(rectangle, {
animate: false,
fitToViewport: true,
scrollLock: {
lockZoom: true,
overscrollAllowance: 0,
},
});
});
expect(warn).toHaveBeenCalledWith(
"Excalidraw: `scrollToContent()` with `scrollLock` is ignored when the `scrollConstraints` prop is controlled. Update the prop value instead.",
);
expect(window.h.state.scrollConstraints).toEqual(FIRST_SCROLL_CONSTRAINTS);
});
});
+568 -6
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { KEYS, reseed } from "@excalidraw/common"; import { KEYS, ROUNDNESS, 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";
@@ -12,6 +14,7 @@ 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,
@@ -39,6 +42,19 @@ 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 />);
@@ -108,6 +124,497 @@ 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", () => {
@@ -326,7 +833,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(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
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();
@@ -359,7 +866,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(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
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();
@@ -392,7 +899,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(6); expect(renderStaticScene).toHaveBeenCalledTimes(7);
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();
@@ -438,7 +945,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(8); expect(renderStaticScene).toHaveBeenCalledTimes(9);
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();
@@ -483,7 +990,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(8); expect(renderStaticScene).toHaveBeenCalledTimes(9);
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();
@@ -558,3 +1065,58 @@ 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({});
});
});
@@ -0,0 +1,88 @@
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
);
};
+80 -4
View File
@@ -32,6 +32,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -268,6 +269,8 @@ 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;
@@ -306,6 +309,8 @@ 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 */
@@ -327,7 +332,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: NonDeletedExcalidrawElement | null; editingTextElement: ExcalidrawTextElement | 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
@@ -449,6 +454,7 @@ 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;
@@ -541,6 +547,8 @@ export type ExcalidrawInitialDataState = Merge<
ImportedDataState, ImportedDataState,
{ {
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>; libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
scrollX?: number;
scrollY?: number;
} }
>; >;
@@ -657,6 +665,7 @@ 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[]
@@ -876,9 +885,8 @@ 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 };
// Whether to block drag after lasso selection // explicit flag for specific scenarios such as:
// this is meant to be used to block dragging after lasso selection on PCs // - after lasso selection until the next pointer down
// 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
@@ -984,6 +992,8 @@ 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 = {
@@ -1050,3 +1060,69 @@ 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,7 +1,10 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { getOriginalContainerHeightFromCache } from "@excalidraw/element"; import {
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -42,6 +45,28 @@ 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;
@@ -210,6 +235,42 @@ 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",
@@ -232,6 +293,94 @@ 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", {
@@ -1205,6 +1354,40 @@ 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",
@@ -1511,7 +1694,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 25, y: 12.5,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+203 -2
View File
@@ -10,7 +10,9 @@ 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,
@@ -33,8 +35,11 @@ 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,
@@ -91,6 +96,103 @@ 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 = ({
@@ -103,6 +205,7 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -119,7 +222,19 @@ 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,
@@ -254,6 +369,7 @@ 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 =
@@ -269,7 +385,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
getTextElementAngle(updatedTextElement, container), angle,
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -283,6 +399,19 @@ 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
@@ -333,6 +462,71 @@ 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()
@@ -696,6 +890,13 @@ 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;
}
}); });
}; };
@@ -786,7 +987,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect) { if (autoSelect && !pendingInitialSelection) {
// 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();
+39 -5
View File
@@ -137,8 +137,17 @@ 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(c, l, t0, s0, 1e-2, 4); const solution = solveWithAnalyticalJacobian(
c,
l,
t0,
s0,
tolerance,
iterLimit,
);
if (!solution) { if (!solution) {
return null; return null;
@@ -158,18 +167,43 @@ 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[] { >(
let solution = calculate(initial_guesses[0], l, c); c: Curve<Point>,
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(initial_guesses[1], l, c); solution = calculate(
initial_guesses[1],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
solution = calculate(initial_guesses[2], l, c); solution = calculate(
initial_guesses[2],
l,
c,
opts?.tolerance,
opts?.iterLimit,
);
if (solution) { if (solution) {
return [solution]; return [solution];
} }
+7 -2
View File
@@ -123,10 +123,15 @@ 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>(
[x, y]: Point, point: Point,
[cx, cy]: Point, center: 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[0]); isPoint(segment[1]);
/** /**
* 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
+25 -1
View File
@@ -1,5 +1,9 @@
import { pointFrom } from "../src/point"; import { pointFrom } from "../src/point";
import { lineSegment, lineSegmentIntersectionPoints } from "../src/segment"; import {
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => { describe("line-segment intersections", () => {
it("should correctly detect intersection", () => { it("should correctly detect intersection", () => {
@@ -19,3 +23,23 @@ 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,6 +13,7 @@ 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,
@@ -84,6 +85,7 @@ exports[`exportToSvg > with default arguments 1`] = `
}, },
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
+7
View File
@@ -90,6 +90,13 @@ 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,5 +1,4 @@
{ {
"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.1.0": "@excalidraw/mermaid-to-excalidraw@2.2.2":
version "2.1.0" version "2.2.2"
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.1.0.tgz#a5b9cf87c3185558cda7f9687d87b9937f452358" resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz#ee6b597a0d95b9a76f7ae41ce0e3733a9b96e4a0"
integrity sha512-RMd+c2b7WzzUjhERMpKwp8PhF2/XlHDjr/zK+Gxfp8K9sVlafPYJ5OEa/GkN6edi2rBUXRfW+41WdO6L56b6Kw== integrity sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==
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"