Compare commits

...
Author SHA1 Message Date
Mark Tolmacs 521896cccf Fix lint 2024-09-23 12:47:04 +02:00
Mark Tolmacs fe318126bd Warn to save content
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-23 12:12:58 +02:00
8ca4cf3260 feat: flip arrowheads if only arrow(s) selected (#8525)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2024-09-19 15:46:36 +02:00
Márk TolmácsandGitHub f3f0ab7c83 fix: Elbow arrow fixedpoint flipping now properly flips on inverted resize and flip action (#8324)
* Flipping action now properly mirrors selections with elbow arrows
* Flipping action now re-centers the selection to the original center to avoid "walking" selections on repeated flipping
2024-09-19 08:47:23 +02:00
David LuzarandGitHub 44a1c8d857 fix: svg and png frame clipping cases (#8515) 2024-09-18 00:20:22 +02:00
Márk TolmácsandGitHub e0a22edfbd fix: Re-route elbow arrows when pasted (#8448)
Re-route elbow arrows when pasted
2024-09-17 12:20:40 +02:00
Márk TolmácsandGitHub c07f5a0c80 feat: Common elbow mid segments (#8440)
Common start or end segment length for elbow arrows regardless of arrowhead is present
2024-09-17 10:11:07 +02:00
David LuzarandGitHub 508f16dc04 refactor: rename example App.tsx -> ExampleApp.tsx (#8501) 2024-09-13 16:56:32 +02:00
c1b310c56b fix: Buffer dependency (#8474)
* fix Buffer dependency

* moved to encode.ts

* move base64 parsing out

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-12 15:48:47 +02:00
zsviczianandGitHub d4900e8f19 fix: Linear element complete button disabled (#8492) 2024-09-12 14:59:38 +02:00
zsviczianandGitHub caf2db934c fix: aspect ratio of distorted images are not preserved in SVG exports (#8061) 2024-09-12 14:11:08 +02:00
zsviczianandGitHub 60e3801691 fix: WYSIWYG editor padding is not normalized with zoom.value (#8481) 2024-09-12 13:42:39 +02:00
David LuzarandGitHub 80f3b75d42 chore: revert vite 5.4.2 -> 5.0.12 (#8499) 2024-09-12 12:56:45 +02:00
hocinoandGitHub dc812bee19 docs: replace dead link (#8494)
* docs update dead link on main-menu page

* doc: fix dead link
2024-09-12 13:20:18 +05:30
David LuzarandGitHub 01e83cc9a5 fix: default sidebar triggers & behavior (#8498) 2024-09-11 20:19:14 +02:00
David LuzarandGitHub 813f9b702e feat: merge search sidebar back to default sidebar (#8497) 2024-09-11 19:26:01 +02:00
David LuzarandGitHub fd39712ba6 fix: improve canvas search scroll behavior further (#8491) 2024-09-11 18:01:18 +02:00
zsviczianandGitHub b46ca0192b fix: addFiles clears the whole image cache when each file is added - regression from #8471 (#8490)
Update App.tsx
2024-09-11 07:57:41 +02:00
David LuzarandGitHub 72b7c937b1 feat: smarter zooming when scrolling to match & only match on search/switch (#8488) 2024-09-10 21:26:01 +02:00
David LuzarandGitHub d107215564 fix: select instead of focus search input (#8483) 2024-09-09 19:57:22 +02:00
6959a363f0 feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-09-09 17:12:07 +02:00
Abhishek MehandirattaandGitHub 5a11c70714 fix: image rendering issue when passed in initialData (#8471) 2024-09-08 23:56:00 +02:00
Aakansha DoshiandGitHub 6ff56c36e3 fix: add partial mocking (#8473)
* fix: add partial mocking

* lint

* Update packages/utils/export.test.ts
2024-09-06 16:41:37 +05:30
Aakansha DoshiandGitHub 51ea184938 build: upgrade vite to 5.4.x, vitest to 2.x and related vite packages (#8459)
* build: upgrade vite to 5.x, vitest to 2.x and related vite packages

* upgrade vitest-ui and coverage

* pass empty set to fix type error and update snap

* set ignoreEmptyLines to false

* update threshold

* update coverage threshold

* downgrade vite-plugin-pwa as its better to push separately with testing

* add package resolutions for strip-ansi, string-width and wrap-ansi

* disable pwa

* only add resolution for strip-ansi
2024-09-05 18:35:36 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
21fff26d31 build(deps): bump micromatch from 4.0.5 to 4.0.8 in /dev-docs (#8458)
Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8.
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: micromatch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-04 15:46:37 +05:30
81 changed files with 2984 additions and 708 deletions
+1
View File
@@ -8,3 +8,4 @@ public/workbox
packages/excalidraw/types packages/excalidraw/types
examples/**/public examples/**/public
dev-dist dev-dist
coverage
@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
getDimensions,<br/>&nbsp; getDimensions,<br/>&nbsp;
files,<br/>&nbsp; files,<br/>&nbsp;
exportPadding?: number;<br/> exportPadding?: number;<br/>
&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a> &#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
</pre> </pre>
| Name | Type | Default | Description | | Name | Type | Default | Description |
+41 -22
View File
@@ -1547,7 +1547,7 @@
"@docusaurus/theme-search-algolia" "2.2.0" "@docusaurus/theme-search-algolia" "2.2.0"
"@docusaurus/types" "2.2.0" "@docusaurus/types" "2.2.0"
"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": "@docusaurus/react-loadable@5.5.2":
version "5.5.2" version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0" balanced-match "^1.0.0"
concat-map "0.0.1" concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2: braces@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
dependencies:
fill-range "^7.1.1"
braces@~3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
fill-range@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies:
to-regex-range "^5.0.1"
finalhandler@1.2.0: finalhandler@1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32"
@@ -5207,11 +5221,11 @@ methods@~1.1.2:
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5" version "4.0.8"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
dependencies: dependencies:
braces "^3.0.2" braces "^3.0.3"
picomatch "^2.3.1" picomatch "^2.3.1"
mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": mime-db@1.52.0, "mime-db@>= 1.43.0 < 2":
@@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1:
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
text-table "^0.2.0" text-table "^0.2.0"
react-dom@^17.0.2: react-dom@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1" scheduler "^0.23.0"
scheduler "^0.20.2"
react-error-overlay@^6.0.11: react-error-overlay@^6.0.11:
version "6.0.11" version "6.0.11"
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
dependencies: dependencies:
"@babel/runtime" "^7.10.3" "@babel/runtime" "^7.10.3"
"react-loadable@npm:@docusaurus/react-loadable@5.5.2":
version "5.5.2"
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
dependencies:
"@types/react" "*"
prop-types "^15.6.2"
react-router-config@^5.1.1: react-router-config@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988"
@@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2:
use-composed-ref "^1.3.0" use-composed-ref "^1.3.0"
use-latest "^1.2.1" use-latest "^1.2.1"
react@^17.0.2: react@18.2.0:
version "17.0.2" version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
readable-stream@^2.0.1: readable-stream@^2.0.1:
version "2.3.7" version "2.3.7"
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
scheduler@^0.20.2: scheduler@^0.23.0:
version "0.20.2" version "0.23.2"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
dependencies: dependencies:
loose-envify "^1.1.0" loose-envify "^1.1.0"
object-assign "^4.1.1"
schema-utils@2.7.0: schema-utils@2.7.0:
version "2.7.0" version "2.7.0"
@@ -40,7 +40,7 @@ import type {
} from "@excalidraw/excalidraw/dist/excalidraw/element/types"; } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types"; import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
import "./App.scss"; import "./ExampleApp.scss";
type Comment = { type Comment = {
x: number; x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
excalidrawLib: typeof TExcalidraw; excalidrawLib: typeof TExcalidraw;
} }
export default function App({ export default function ExampleApp({
appTitle, appTitle,
useCustom, useCustom,
customArgs, customArgs,
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as excalidrawLib from "@excalidraw/excalidraw"; import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../components/App"; import App from "../../components/ExampleApp";
import "@excalidraw/excalidraw/index.css"; import "@excalidraw/excalidraw/index.css";
@@ -1,4 +1,4 @@
import App from "../components/App"; import App from "../components/ExampleApp";
import React, { StrictMode } from "react"; import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
+13 -1
View File
@@ -126,6 +126,8 @@ import DebugCanvas, {
loadSavedDebugState, loadSavedDebugState,
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import type { SaveWarningRef } from "./components/SaveWarning";
import { SaveWarning } from "./components/SaveWarning";
polyfill(); polyfill();
@@ -331,6 +333,8 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
const activityRef = useRef<SaveWarningRef | null>(null);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -615,6 +619,8 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements); collabAPI.syncElements(elements);
} }
activityRef.current?.activity();
// this check is redundant, but since this is a hot path, it's best // this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time // not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) { if (!LocalData.isSavePaused()) {
@@ -649,7 +655,12 @@ const ExcalidrawWrapper = () => {
// Render the debug scene if the debug canvas is available // Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) { if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio); debugRenderer(
debugCanvasRef.current,
appState,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
} }
}; };
@@ -851,6 +862,7 @@ const ExcalidrawWrapper = () => {
setTheme={(theme) => setAppTheme(theme)} setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<SaveWarning ref={activityRef} />
<AppWelcomeScreen <AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen} onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
/> />
)} )}
<MainMenu.DefaultItems.CommandPalette className="highlighted" /> <MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
+12 -2
View File
@@ -68,12 +68,17 @@ const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -138,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
}; };
export const debugRenderer = throttleRAF( export const debugRenderer = throttleRAF(
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => { (
_debugRenderer(canvas, appState, scale); canvas: HTMLCanvasElement,
appState: AppState,
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );
+40
View File
@@ -0,0 +1,40 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import { t } from "../../packages/excalidraw/i18n";
import { getShortcutKey } from "../../packages/excalidraw/utils";
export type SaveWarningRef = {
activity: () => Promise<void>;
};
export const SaveWarning = forwardRef<SaveWarningRef, {}>((props, ref) => {
const dialogRef = useRef<HTMLDivElement | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useImperativeHandle(ref, () => ({
/**
* Call this API method via the ref to hide warning message
* and start an idle timer again.
*/
activity: async () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
dialogRef.current?.classList.remove("animate");
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
dialogRef.current?.classList.add("animate");
}, 5000);
},
}));
return (
<div ref={dialogRef} className="alert-save">
<div className="dialog">
{t("alerts.saveYourContent", {
shortcut: getShortcutKey("CtrlOrCmd + S"),
})}
</div>
</div>
);
});
+14 -1
View File
@@ -20,6 +20,10 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "../../packages/excalidraw/constants";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library"; import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
appState: AppState, appState: AppState,
) => { ) => {
try { try {
const _appState = clearAppStateForLocalStorage(appState);
if (
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
_appState.openSidebar = null;
}
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)), JSON.stringify(clearElementsForLocalStorage(elements)),
); );
localStorage.setItem( localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) { } catch (error: any) {
+37
View File
@@ -18,6 +18,43 @@
margin-inline-start: auto; margin-inline-start: auto;
} }
.alert-save {
position: absolute;
z-index: 10;
left: 0;
right: 0;
bottom: 10vh;
margin-inline: auto;
width: fit-content;
opacity: 0;
transition: all 0s;
&.animate {
opacity: 1;
transition: all 0.2s ease-in;
}
.dialog {
margin-inline: 10px;
padding: 1rem;
padding-inline: 1.25rem;
resize: none;
white-space: pre-wrap;
box-sizing: border-box;
background-color: var(--color-warning);
border-radius: var(--border-radius-md);
border: 1px solid var(--dialog-border-color);
font-size: 0.875rem;
text-align: center;
line-height: 1.5;
color: var(--color-text-warning);
}
}
.encrypted-icon { .encrypted-icon {
border-radius: var(--space-factor); border-radius: var(--space-factor);
color: var(--color-primary); color: var(--color-primary);
+8 -7
View File
@@ -21,8 +21,8 @@
"@types/react-dom": "18.2.0", "@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0", "@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0", "@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0", "@vitest/coverage-v8": "2.0.5",
"@vitest/ui": "0.32.2", "@vitest/ui": "2.0.5",
"chai": "4.3.6", "chai": "4.3.6",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
@@ -37,12 +37,12 @@
"rewire": "6.0.0", "rewire": "6.0.0",
"typescript": "4.9.4", "typescript": "4.9.4",
"vite": "5.0.12", "vite": "5.0.12",
"vite-plugin-checker": "0.6.1", "vite-plugin-checker": "0.7.2",
"vite-plugin-ejs": "1.7.0", "vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4", "vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0", "vite-plugin-svgr": "4.2.0",
"vitest": "1.6.0", "vitest": "2.0.5",
"vitest-canvas-mock": "0.3.2" "vitest-canvas-mock": "0.3.3"
}, },
"engines": { "engines": {
"node": "18.0.0 - 20.x.x" "node": "18.0.0 - 20.x.x"
@@ -83,6 +83,7 @@
"clean-install": "yarn rm:node_modules && yarn install" "clean-install": "yarn rm:node_modules && yarn install"
}, },
"resolutions": { "resolutions": {
"@types/react": "18.2.0" "@types/react": "18.2.0",
"strip-ansi": "6.0.1"
} }
} }
+58 -51
View File
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene"; import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import type { AppState } from "../types"; import type { AppState, Offsets } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils"; import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { Tooltip } from "../components/Tooltip"; import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds"; import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { clamp } from "../../math"; import { clamp, roundToStep } from "../../math";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
const adjustedZoomValue = const adjustedZoomValue =
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1); smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
const zoomAdjustedToSteps = return Math.min(adjustedZoomValue, 1);
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
}; };
export const zoomToFitBounds = ({ export const zoomToFitBounds = ({
bounds, bounds,
appState, appState,
canvasOffsets,
fitToViewport = false, fitToViewport = false,
viewportZoomFactor = 1, viewportZoomFactor = 1,
minZoom = -Infinity,
maxZoom = Infinity,
}: { }: {
bounds: SceneBounds; bounds: SceneBounds;
canvasOffsets?: Offsets;
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
const [x1, y1, x2, y2] = bounds; const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2; const centerY = (y1 + y2) / 2;
let newZoomValue; const canvasOffsetLeft = canvasOffsets?.left ?? 0;
let scrollX; const canvasOffsetTop = canvasOffsets?.top ?? 0;
let scrollY; const canvasOffsetRight = canvasOffsets?.right ?? 0;
const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
const effectiveCanvasWidth =
appState.width - canvasOffsetLeft - canvasOffsetRight;
const effectiveCanvasHeight =
appState.height - canvasOffsetTop - canvasOffsetBottom;
let adjustedZoomValue;
if (fitToViewport) { if (fitToViewport) {
const commonBoundsWidth = x2 - x1; const commonBoundsWidth = x2 - x1;
const commonBoundsHeight = y2 - y1; const commonBoundsHeight = y2 - y1;
newZoomValue = adjustedZoomValue =
Math.min( Math.min(
appState.width / commonBoundsWidth, effectiveCanvasWidth / commonBoundsWidth,
appState.height / commonBoundsHeight, effectiveCanvasHeight / commonBoundsHeight,
) * clamp(viewportZoomFactor, 0.1, 1); ) * viewportZoomFactor;
newZoomValue = getNormalizedZoom(newZoomValue);
let appStateWidth = appState.width;
if (appState.openSidebar) {
const sidebarDOMElem = document.querySelector(
".sidebar",
) as HTMLElement | null;
const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
appStateWidth = !isRTL
? appState.width - sidebarWidth
: appState.width + sidebarWidth;
}
scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else { } else {
newZoomValue = zoomValueToFitBoundsOnViewport( adjustedZoomValue = zoomValueToFitBoundsOnViewport(
bounds, bounds,
{ {
width: appState.width, width: effectiveCanvasWidth,
height: appState.height, height: effectiveCanvasHeight,
}, },
viewportZoomFactor, viewportZoomFactor,
); );
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: { value: newZoomValue },
});
scrollX = centerScroll.scrollX;
scrollY = centerScroll.scrollY;
} }
const newZoomValue = getNormalizedZoom(
clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
);
const centerScroll = centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
offsets: canvasOffsets,
zoom: { value: newZoomValue },
});
return { return {
appState: { appState: {
...appState, ...appState,
scrollX, scrollX: centerScroll.scrollX,
scrollY, scrollY: centerScroll.scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
storeAction: StoreAction.NONE, storeAction: StoreAction.NONE,
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
}; };
export const zoomToFit = ({ export const zoomToFit = ({
canvasOffsets,
targetElements, targetElements,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}: { }: {
canvasOffsets?: Offsets;
targetElements: readonly ExcalidrawElement[]; targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>; appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */ /** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean; fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */ /** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number; viewportZoomFactor?: number;
minZoom?: number;
maxZoom?: number;
}) => { }) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({ return zoomToFitBounds({
canvasOffsets,
bounds: commonBounds, bounds: commonBounds,
appState, appState,
fitToViewport, fitToViewport,
viewportZoomFactor, viewportZoomFactor,
minZoom,
maxZoom,
}); });
}; };
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE shift-2 should have been assigned actionZoomToFitSelection. // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: true, fitToViewport: true,
viewportZoomFactor: 0.7, canvasOffsets: app.getEditorUIOffsets(),
}); });
}, },
// NOTE this action should use shift-2 per figma, alas // NOTE this action should use shift-2 per figma, alas
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
icon: zoomAreaIcon, icon: zoomAreaIcon,
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => perform: (elements, appState, _, app) =>
zoomToFit({ zoomToFit({
targetElements: elements, targetElements: elements,
appState: { appState: {
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
userToFollow: null, userToFollow: null,
}, },
fitToViewport: false, fitToViewport: false,
canvasOffsets: app.getEditorUIOffsets(),
}), }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
@@ -217,6 +217,7 @@ export const actionFinalize = register({
onClick={updateData} onClick={updateData}
visible={appState.multiElement != null} visible={appState.multiElement != null}
size={data?.size || "medium"} size={data?.size || "medium"}
style={{ pointerEvents: "all" }}
/> />
), ),
}); });
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { point } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
},
startArrowhead: null,
endArrowhead: "arrow",
points: [
point(0, 0),
point(0, -35),
point(-90.9, -35),
point(-90.9, 204.9),
point(65.1, 204.9),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});
+69 -2
View File
@@ -2,6 +2,8 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
@@ -18,7 +20,13 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks"; import {
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -109,7 +117,23 @@ const flipElements = (
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); if (
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements( resizeMultipleElements(
elementsMap, elementsMap,
@@ -131,5 +155,48 @@ const flipElements = (
[], [],
); );
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
);
// ---------------------------------------------------------------------------
return selectedElements; return selectedElements;
}; };
@@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
: {}), : {}),
}, },
); );
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
} }
return newElement; return newElement;
@@ -0,0 +1,55 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { AppState } from "../types";
import { searchIcon } from "../components/icons";
import { StoreAction } from "../store";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
export const actionToggleSearchMenu = register({
name: "searchMenu",
icon: searchIcon,
keywords: ["search", "find"],
label: "search.title",
viewMode: true,
trackEvent: {
category: "search_menu",
action: "toggle",
predicate: (appState) => appState.gridModeEnabled,
},
perform(elements, appState, _, app) {
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB
) {
const searchInput =
app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
if (searchInput?.matches(":focus")) {
return {
appState: { ...appState, openSidebar: null },
storeAction: StoreAction.NONE,
};
}
searchInput?.focus();
searchInput?.select();
return false;
}
return {
appState: {
...appState,
openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
openDialog: null,
},
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridModeEnabled,
predicate: (element, appState, props) => {
return props.gridModeEnabled === undefined;
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
});
+2
View File
@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink"; export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
+3 -1
View File
@@ -51,7 +51,8 @@ export type ShortcutName =
> >
| "saveScene" | "saveScene"
| "imageExport" | "imageExport"
| "commandPalette"; | "commandPalette"
| "searchMenu";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")], toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")], toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+4 -2
View File
@@ -137,7 +137,8 @@ export type ActionName =
| "wrapTextInContainer" | "wrapTextInContainer"
| "commandPalette" | "commandPalette"
| "autoResize" | "autoResize"
| "elementStats"; | "elementStats"
| "searchMenu";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -191,7 +192,8 @@ export interface Action {
| "history" | "history"
| "menu" | "menu"
| "collab" | "collab"
| "hyperlink"; | "hyperlink"
| "search_menu";
action?: string; action?: string;
predicate?: ( predicate?: (
appState: Readonly<AppState>, appState: Readonly<AppState>,
+2
View File
@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
userToFollow: null, userToFollow: null,
followedBy: new Set(), followedBy: new Set(),
searchMatches: [],
}; };
}; };
@@ -236,6 +237,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 },
searchMatches: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <
+122 -41
View File
@@ -185,6 +185,7 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@@ -259,6 +260,7 @@ import type {
ElementsPendingErasure, ElementsPendingErasure,
GenerateDiagramToCode, GenerateDiagramToCode,
NullableGridSize, NullableGridSize,
Offsets,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@@ -286,6 +288,7 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
toBrandedType,
} from "../utils"; } from "../utils";
import { import {
createSrcDoc, createSrcDoc,
@@ -434,12 +437,13 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow } from "../element/routing"; import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
getLinkDirectionFromKey, getLinkDirectionFromKey,
} from "../element/flowchart"; } from "../element/flowchart";
import { searchItemInFocusAtom } from "./SearchMenu";
import type { LocalPoint, Radians } from "../../math"; import type { LocalPoint, Radians } from "../../math";
import { point, pointDistance, vector } from "../../math"; import { point, pointDistance, vector } from "../../math";
@@ -548,6 +552,7 @@ class App extends React.Component<AppProps, AppState> {
public scene: Scene; public scene: Scene;
public fonts: Fonts; public fonts: Fonts;
public renderer: Renderer; public renderer: Renderer;
public visibleElements: readonly NonDeletedExcalidrawElement[];
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: AppClassProperties["library"]; public library: AppClassProperties["library"];
@@ -555,7 +560,7 @@ class App extends React.Component<AppProps, AppState> {
public id: string; public id: string;
private store: Store; private store: Store;
private history: History; private history: History;
private excalidrawContainerValue: { public excalidrawContainerValue: {
container: HTMLDivElement | null; container: HTMLDivElement | null;
id: string; id: string;
}; };
@@ -682,6 +687,7 @@ class App extends React.Component<AppProps, AppState> {
this.canvas = document.createElement("canvas"); this.canvas = document.createElement("canvas");
this.rc = rough.canvas(this.canvas); this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene); this.renderer = new Renderer(this.scene);
this.visibleElements = [];
this.store = new Store(); this.store = new Store();
this.history = new History(); this.history = new History();
@@ -1480,6 +1486,7 @@ class App extends React.Component<AppProps, AppState> {
newElementId: this.state.newElement?.id, newElementId: this.state.newElement?.id,
pendingImageElementId: this.state.pendingImageElementId, pendingImageElementId: this.state.pendingImageElementId,
}); });
this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap(); const allElementsMap = this.scene.getNonDeletedElementsMap();
@@ -2295,6 +2302,9 @@ class App extends React.Component<AppProps, AppState> {
storeAction: StoreAction.UPDATE, storeAction: StoreAction.UPDATE,
}); });
// clear the shape and image cache so that any images in initialData
// can be loaded fresh
this.clearImageShapeCache();
// FontFaceSet loadingdone event we listen on may not always // FontFaceSet loadingdone event we listen on may not always
// fire (looking at you Safari), so on init we manually load all // fire (looking at you Safari), so on init we manually load all
// fonts and rerender scene text elements once done. This also // fonts and rerender scene text elements once done. This also
@@ -2360,6 +2370,16 @@ class App extends React.Component<AppProps, AppState> {
return false; return false;
}; };
private clearImageShapeCache(filesMap?: BinaryFiles) {
const files = filesMap ?? this.files;
this.scene.getNonDeletedElements().forEach((element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
}
public async componentDidMount() { public async componentDidMount() {
this.unmounted = false; this.unmounted = false;
this.excalidrawContainerValue.container = this.excalidrawContainerValue.container =
@@ -3091,7 +3111,45 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
const elements = restoreElements(opts.elements, null, undefined); let elements = opts.elements.map((el, _, elements) => {
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
@@ -3215,6 +3273,7 @@ class App extends React.Component<AppProps, AppState> {
if (opts.fitToContent) { if (opts.fitToContent) {
this.scrollToContent(newElements, { this.scrollToContent(newElements, {
fitToContent: true, fitToContent: true,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
}; };
@@ -3527,7 +3586,7 @@ class App extends React.Component<AppProps, AppState> {
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: opts?: (
| { | {
fitToContent?: boolean; fitToContent?: boolean;
fitToViewport?: never; fitToViewport?: never;
@@ -3544,7 +3603,12 @@ class App extends React.Component<AppProps, AppState> {
viewportZoomFactor?: number; viewportZoomFactor?: number;
animate?: boolean; animate?: boolean;
duration?: number; duration?: number;
}, }
) & {
minZoom?: number;
maxZoom?: number;
canvasOffsets?: Offsets;
},
) => { ) => {
this.cancelInProgressAnimation?.(); this.cancelInProgressAnimation?.();
@@ -3557,10 +3621,13 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.fitToContent || opts?.fitToViewport) { if (opts?.fitToContent || opts?.fitToViewport) {
const { appState } = zoomToFit({ const { appState } = zoomToFit({
canvasOffsets: opts.canvasOffsets,
targetElements, targetElements,
appState: this.state, appState: this.state,
fitToViewport: !!opts?.fitToViewport, fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor, viewportZoomFactor: opts?.viewportZoomFactor,
minZoom: opts?.minZoom,
maxZoom: opts?.maxZoom,
}); });
zoom = appState.zoom; zoom = appState.zoom;
scrollX = appState.scrollX; scrollX = appState.scrollX;
@@ -3674,15 +3741,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...Object.fromEntries(filesMap) }; this.files = { ...this.files, ...Object.fromEntries(filesMap) };
this.scene.getNonDeletedElements().forEach((element) => { this.clearImageShapeCache(Object.fromEntries(filesMap));
if (
isInitializedImageElement(element) &&
filesMap.has(element.fileId)
) {
this.imageCache.delete(element.fileId);
ShapeCache.delete(element);
}
});
this.scene.triggerUpdate(); this.scene.triggerUpdate();
this.addNewImagesToImageCache(); this.addNewImagesToImageCache();
@@ -3796,40 +3855,42 @@ class App extends React.Component<AppProps, AppState> {
}, },
); );
private getEditorUIOffsets = (): { public getEditorUIOffsets = (): Offsets => {
top: number;
right: number;
bottom: number;
left: number;
} => {
const toolbarBottom = const toolbarBottom =
this.excalidrawContainerRef?.current this.excalidrawContainerRef?.current
?.querySelector(".App-toolbar") ?.querySelector(".App-toolbar")
?.getBoundingClientRect()?.bottom ?? 0; ?.getBoundingClientRect()?.bottom ?? 0;
const sidebarWidth = Math.max( const sidebarRect = this.excalidrawContainerRef?.current
this.excalidrawContainerRef?.current ?.querySelector(".sidebar")
?.querySelector(".default-sidebar") ?.getBoundingClientRect();
?.getBoundingClientRect()?.width ?? 0, const propertiesPanelRect = this.excalidrawContainerRef?.current
); ?.querySelector(".App-menu__left")
const propertiesPanelWidth = Math.max( ?.getBoundingClientRect();
this.excalidrawContainerRef?.current
?.querySelector(".App-menu__left") const PADDING = 16;
?.getBoundingClientRect()?.width ?? 0,
0,
);
return getLanguage().rtl return getLanguage().rtl
? { ? {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: propertiesPanelWidth, right:
bottom: 0, Math.max(
left: sidebarWidth, this.state.width -
(propertiesPanelRect?.left ?? this.state.width),
0,
) + PADDING,
bottom: PADDING,
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
} }
: { : {
top: toolbarBottom, top: toolbarBottom + PADDING,
right: sidebarWidth, right: Math.max(
bottom: 0, this.state.width -
left: propertiesPanelWidth, (sidebarRect?.left ?? this.state.width) +
PADDING,
0,
),
bottom: PADDING,
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
}; };
}; };
@@ -3914,7 +3975,7 @@ class App extends React.Component<AppProps, AppState> {
animate: true, animate: true,
duration: 300, duration: 300,
fitToContent: true, fitToContent: true,
viewportZoomFactor: 0.8, canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
@@ -3970,6 +4031,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(nextNode, { this.scrollToContent(nextNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }
@@ -4402,6 +4464,7 @@ class App extends React.Component<AppProps, AppState> {
this.scrollToContent(firstNode, { this.scrollToContent(firstNode, {
animate: true, animate: true,
duration: 300, duration: 300,
canvasOffsets: this.getEditorUIOffsets(),
}); });
} }
} }
@@ -5969,6 +6032,16 @@ class App extends React.Component<AppProps, AppState> {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
this.setState((state) => ({
searchMatches: state.searchMatches.map((searchMatch) => ({
...searchMatch,
focus: false,
})),
}));
jotaiStore.set(searchItemInFocusAtom, null);
}
// since contextMenu options are potentially evaluated on each render, // since contextMenu options are potentially evaluated on each render,
// and an contextMenu action may depend on selection state, we must // and an contextMenu action may depend on selection state, we must
// close the contextMenu before we update the selection on pointerDown // close the contextMenu before we update the selection on pointerDown
@@ -6397,8 +6470,16 @@ class App extends React.Component<AppProps, AppState> {
} }
isPanning = true; isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) { if (!this.state.editingTextElement) {
// preventing defualt while text editing messes with cursor/focus // necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// as such, the above is broken when panning canvas while in wysiwyg
event.preventDefault(); event.preventDefault();
} }
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes"; import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback"; import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions"; import {
actionClearCanvas,
actionLink,
actionToggleSearchMenu,
} from "../../actions";
import { jotaiStore } from "../../jotai"; import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import type { CommandPaletteItem } from "./types"; import type { CommandPaletteItem } from "./types";
@@ -382,6 +386,15 @@ function CommandPaletteInner({
} }
}, },
}, },
{
label: t("search.title"),
category: DEFAULT_CATEGORIES.app,
icon: searchIcon,
viewMode: true,
perform: () => {
actionManager.executeAction(actionToggleSearchMenu);
},
},
{ {
label: t("labels.changeStroke"), label: t("labels.changeStroke"),
keywords: ["color", "outline"], keywords: ["color", "outline"],
@@ -1,8 +1,11 @@
import clsx from "clsx"; import clsx from "clsx";
import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants"; import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { useTunnels } from "../context/tunnels"; import { useTunnels } from "../context/tunnels";
import { useUIAppState } from "../context/ui-appState"; import { useUIAppState } from "../context/ui-appState";
import { t } from "../i18n";
import type { MarkOptional, Merge } from "../utility-types"; import type { MarkOptional, Merge } from "../utility-types";
import { composeEventHandlers } from "../utils"; import { composeEventHandlers } from "../utils";
import { useExcalidrawSetAppState } from "./App"; import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
import { LibraryMenu } from "./LibraryMenu"; import { LibraryMenu } from "./LibraryMenu";
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common"; import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
import { Sidebar } from "./Sidebar/Sidebar"; import { Sidebar } from "./Sidebar/Sidebar";
import "../components/dropdownMenu/DropdownMenu.scss";
import { SearchMenu } from "./SearchMenu";
import { LibraryIcon, searchIcon } from "./icons";
const DefaultSidebarTrigger = withInternalFallback( const DefaultSidebarTrigger = withInternalFallback(
"DefaultSidebarTrigger", "DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
); );
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger"; DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
const DefaultTabTriggers = ({ const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
return ( return (
<DefaultSidebarTabTriggersTunnel.In> <DefaultSidebarTabTriggersTunnel.In>
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers> {children}
</DefaultSidebarTabTriggersTunnel.In> </DefaultSidebarTabTriggersTunnel.In>
); );
}; };
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
const { DefaultSidebarTabTriggersTunnel } = useTunnels(); const { DefaultSidebarTabTriggersTunnel } = useTunnels();
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
return ( return (
<Sidebar <Sidebar
{...rest} {...rest}
name="default" name="default"
key="default" key="default"
className={clsx("default-sidebar", className)} className={clsx("default-sidebar", className)}
docked={docked ?? appState.defaultSidebarDockedPreference} docked={
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
}
onDock={ onDock={
// `onDock=false` disables docking. // `onDock=false` disables docking.
// if `docked` passed, but no onDock passed, disable manual docking. // if `docked` passed, but no onDock passed, disable manual docking.
onDock === false || (!onDock && docked != null) isForceDocked || onDock === false || (!onDock && docked != null)
? undefined ? undefined
: // compose to allow the host app to listen on default behavior : // compose to allow the host app to listen on default behavior
composeEventHandlers(onDock, (docked) => { composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
> >
<Sidebar.Tabs> <Sidebar.Tabs>
<Sidebar.Header> <Sidebar.Header>
{rest.__fallback && ( <Sidebar.TabTriggers>
<div <Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
style={{ {searchIcon}
color: "var(--color-primary)", </Sidebar.TabTrigger>
fontSize: "1.2em", <Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
fontWeight: "bold", {LibraryIcon}
textOverflow: "ellipsis", </Sidebar.TabTrigger>
overflow: "hidden", <DefaultSidebarTabTriggersTunnel.Out />
whiteSpace: "nowrap", </Sidebar.TabTriggers>
paddingRight: "1em",
}}
>
{t("toolBar.library")}
</div>
)}
<DefaultSidebarTabTriggersTunnel.Out />
</Sidebar.Header> </Sidebar.Header>
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}> <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
<LibraryMenu /> <LibraryMenu />
</Sidebar.Tab> </Sidebar.Tab>
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
<SearchMenu />
</Sidebar.Tab>
{children} {children}
</Sidebar.Tabs> </Sidebar.Tabs>
</Sidebar> </Sidebar>
@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.fullTitle")} label={t("stats.fullTitle")}
shortcuts={[getShortcutKey("Alt+/")]} shortcuts={[getShortcutKey("Alt+/")]}
/> />
<Shortcut
label={t("search.title")}
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
/>
<Shortcut <Shortcut
label={t("commandPalette.title")} label={t("commandPalette.title")}
shortcuts={ shortcuts={
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
import "./HintViewer.scss"; import "./HintViewer.scss";
import { isNodeInFlowchart } from "../element/flowchart"; import { isNodeInFlowchart } from "../element/flowchart";
import { isGridModeEnabled } from "../snapping"; import { isGridModeEnabled } from "../snapping";
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
interface HintViewerProps { interface HintViewerProps {
appState: UIAppState; appState: UIAppState;
@@ -30,6 +31,14 @@ const getHints = ({
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null; const multiMode = appState.multiElement !== null;
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.length
) {
return t("hints.dismissSearch");
}
if (appState.openSidebar && !device.editor.canFitSidebar) { if (appState.openSidebar && !device.editor.canFitSidebar) {
return null; return null;
} }
+4 -3
View File
@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
import { UIAppStateContext } from "../context/ui-appState"; import { UIAppStateContext } from "../context/ui-appState";
import { DefaultSidebar } from "./DefaultSidebar"; import { DefaultSidebar } from "./DefaultSidebar";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import "./LayerUI.scss";
import "./Toolbar.scss";
import { mutateElement } from "../element/mutateElement"; import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats"; import { Stats } from "./Stats";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import "./LayerUI.scss";
import "./Toolbar.scss";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
appState: UIAppState; appState: UIAppState;
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
{UIOptions.canvasActions.saveAsImage && ( {UIOptions.canvasActions.saveAsImage && (
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
)} )}
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas /> <MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator /> <MainMenu.Separator />
@@ -0,0 +1,110 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__search {
flex: 1 0 auto;
display: flex;
flex-direction: column;
padding: 8px 0 0 0;
}
.layer-ui__search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0.75rem;
.ExcTextField {
flex: 1 0 auto;
}
.ExcTextField__input {
background-color: #f5f5f9;
@at-root .excalidraw.theme--dark#{&} {
background-color: #31303b;
}
border-radius: var(--border-radius-md);
border: 0;
input::placeholder {
font-size: 0.9rem;
}
}
}
.layer-ui__search-count {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 8px 0 8px;
margin: 0 0.75rem 0.25rem 0.75rem;
font-size: 0.8em;
.result-nav {
display: flex;
.result-nav-btn {
width: 36px;
height: 36px;
--button-border: transparent;
&:active {
background-color: var(--color-surface-high);
}
&:first {
margin-right: 4px;
}
}
}
}
.layer-ui__search-result-container {
overflow-y: auto;
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
width: 1rem;
height: 1rem;
margin-right: 0.75rem;
}
.preview-text {
flex: 1;
max-height: 48px;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
&:hover {
background-color: var(--color-surface-high);
}
&:active {
border-color: var(--color-primary);
}
&.active {
background-color: var(--color-surface-high);
}
}
}
@@ -0,0 +1,718 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import { TextField } from "./TextField";
import { Button } from "./Button";
import { useApp, useExcalidrawSetAppState } from "./App";
import { debounce } from "lodash";
import type { AppClassProperties } from "../types";
import { isTextElement, newTextElement } from "../element";
import type { ExcalidrawTextElement } from "../element/types";
import { measureText } from "../element/textElement";
import { addEventListener, getFontString } from "../utils";
import { KEYS } from "../keys";
import clsx from "clsx";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { isElementCompletelyInViewport } from "../element/sizeHelpers";
import { randomInteger } from "../random";
import { CLASSES, EVENT } from "../constants";
import { useStable } from "../hooks/useStable";
import "./SearchMenu.scss";
import { round } from "../../math";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
searchQuery: SearchQuery;
index: number;
preview: {
indexInSearchQuery: number;
previewText: string;
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
type SearchMatches = {
nonce: number | null;
items: SearchMatchItem[];
};
type SearchQuery = string & { _brand: "SearchQuery" };
export const SearchMenu = () => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const searchInputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
const searchQuery = inputValue.trim() as SearchQuery;
const [isSearching, setIsSearching] = useState(false);
const [searchMatches, setSearchMatches] = useState<SearchMatches>({
nonce: null,
items: [],
});
const searchedQueryRef = useRef<SearchQuery | null>(null);
const lastSceneNonceRef = useRef<number | undefined>(undefined);
const [focusIndex, setFocusIndex] = useAtom(
searchItemInFocusAtom,
jotaiScope,
);
const elementsMap = app.scene.getNonDeletedElementsMap();
useEffect(() => {
if (isSearching) {
return;
}
if (
searchQuery !== searchedQueryRef.current ||
app.scene.getSceneNonce() !== lastSceneNonceRef.current
) {
searchedQueryRef.current = null;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
});
}
}, [
isSearching,
searchQuery,
elementsMap,
app,
setAppState,
setFocusIndex,
lastSceneNonceRef,
]);
const goToNextItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return (focusIndex + 1) % searchMatches.items.length;
});
}
};
const goToPreviousItem = () => {
if (searchMatches.items.length > 0) {
setFocusIndex((focusIndex) => {
if (focusIndex === null) {
return 0;
}
return focusIndex - 1 < 0
? searchMatches.items.length - 1
: focusIndex - 1;
});
}
};
useEffect(() => {
setAppState((state) => {
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
};
});
}, [focusIndex, setAppState]);
useEffect(() => {
if (searchMatches.items.length > 0 && focusIndex !== null) {
const match = searchMatches.items[focusIndex];
if (match) {
const zoomValue = app.state.zoom.value;
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
});
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const isTextTiny =
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
if (
!isElementCompletelyInViewport(
[matchAsElement],
app.canvas.width / window.devicePixelRatio,
app.canvas.height / window.devicePixelRatio,
{
offsetLeft: app.state.offsetLeft,
offsetTop: app.state.offsetTop,
scrollX: app.state.scrollX,
scrollY: app.state.scrollY,
zoom: app.state.zoom,
},
app.scene.getNonDeletedElementsMap(),
app.getEditorUIOffsets(),
) ||
isTextTiny
) {
let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
if (isTextTiny) {
if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
zoomOptions = { fitToContent: true };
} else {
zoomOptions = {
fitToViewport: true,
// calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
};
}
} else {
zoomOptions = { fitToContent: true };
}
app.scrollToContent(matchAsElement, {
animate: true,
duration: 300,
...zoomOptions,
canvasOffsets: app.getEditorUIOffsets(),
});
}
}
}
}, [focusIndex, searchMatches, app]);
useEffect(() => {
return () => {
setFocusIndex(null);
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
});
setIsSearching(false);
};
}, [setAppState, setFocusIndex]);
const stableState = useStable({
goToNextItem,
goToPreviousItem,
searchMatches,
});
useEffect(() => {
const eventHandler = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
!app.state.openDialog &&
!app.state.openPopup
) {
event.preventDefault();
event.stopPropagation();
setAppState({
openSidebar: null,
});
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
event.preventDefault();
event.stopPropagation();
if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) {
setAppState({
openDialog: null,
});
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
if (
event.target instanceof HTMLElement &&
event.target.closest(".layer-ui__search")
) {
if (stableState.searchMatches.items.length) {
if (event.key === KEYS.ENTER) {
event.stopPropagation();
stableState.goToNextItem();
}
if (event.key === KEYS.ARROW_UP) {
event.stopPropagation();
stableState.goToPreviousItem();
} else if (event.key === KEYS.ARROW_DOWN) {
event.stopPropagation();
stableState.goToNextItem();
}
}
}
};
// `capture` needed to prevent firing on initial open from App.tsx,
// as well as to handle events before App ones
return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
capture: true,
});
}, [setAppState, stableState, app]);
const matchCount = `${searchMatches.items.length} ${
searchMatches.items.length === 1
? t("search.singleResult")
: t("search.multipleResults")
}`;
return (
<div className="layer-ui__search">
<div className="layer-ui__search-header">
<TextField
className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
value={inputValue}
ref={searchInputRef}
placeholder={t("search.placeholder")}
icon={searchIcon}
onChange={(value) => {
setInputValue(value);
setIsSearching(true);
const searchQuery = value.trim() as SearchQuery;
handleSearch(searchQuery, app, (matchItems, index) => {
setSearchMatches({
nonce: randomInteger(),
items: matchItems,
});
setFocusIndex(index);
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
});
setIsSearching(false);
});
}}
selectOnRender
/>
</div>
<div className="layer-ui__search-count">
{searchMatches.items.length > 0 && (
<>
{focusIndex !== null && focusIndex > -1 ? (
<div>
{focusIndex + 1} / {matchCount}
</div>
) : (
<div>{matchCount}</div>
)}
<div className="result-nav">
<Button
onSelect={() => {
goToNextItem();
}}
className="result-nav-btn"
>
{collapseDownIcon}
</Button>
<Button
onSelect={() => {
goToPreviousItem();
}}
className="result-nav-btn"
>
{upIcon}
</Button>
</div>
</>
)}
{searchMatches.items.length === 0 &&
searchQuery &&
searchedQueryRef.current && (
<div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
)}
</div>
<MatchList
matches={searchMatches}
onItemClick={setFocusIndex}
focusIndex={focusIndex}
searchQuery={searchQuery}
/>
</div>
);
};
const ListItem = (props: {
preview: SearchMatchItem["preview"];
searchQuery: SearchQuery;
highlighted: boolean;
onClick?: () => void;
}) => {
const preview = [
props.preview.moreBefore ? "..." : "",
props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
props.preview.previewText.slice(
props.preview.indexInSearchQuery,
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.previewText.slice(
props.preview.indexInSearchQuery + props.searchQuery.length,
),
props.preview.moreAfter ? "..." : "",
];
return (
<div
tabIndex={-1}
className={clsx("layer-ui__result-item", {
active: props.highlighted,
})}
onClick={props.onClick}
ref={(ref) => {
if (props.highlighted) {
ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
}
}}
>
<div className="preview-text">
{preview.flatMap((text, idx) => (
<Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
))}
</div>
</div>
);
};
interface MatchListProps {
matches: SearchMatches;
onItemClick: (index: number) => void;
focusIndex: number | null;
searchQuery: SearchQuery;
}
const MatchListBase = (props: MatchListProps) => {
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
</div>
);
};
const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
return (
prevProps.matches.nonce === nextProps.matches.nonce &&
prevProps.focusIndex === nextProps.focusIndex
);
};
const MatchList = memo(MatchListBase, areEqual);
const getMatchPreview = (
text: string,
index: number,
searchQuery: SearchQuery,
) => {
const WORDS_BEFORE = 2;
const WORDS_AFTER = 5;
const substrBeforeQuery = text.slice(0, index);
const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
// text = "small", query = "mall", not complete before
// text = "small", query = "smal", complete before
const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
const startWordIndex =
wordsBeforeQuery.length -
WORDS_BEFORE -
1 -
(isQueryCompleteBefore ? 0 : 1);
let wordsBeforeAsString =
wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
(isQueryCompleteBefore ? " " : "");
const MAX_ALLOWED_CHARS = 20;
wordsBeforeAsString =
wordsBeforeAsString.length > MAX_ALLOWED_CHARS
? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
: wordsBeforeAsString;
const substrAfterQuery = text.slice(index + searchQuery.length);
const wordsAfter = substrAfterQuery.split(/\s+/);
// text = "small", query = "mall", complete after
// text = "small", query = "smal", not complete after
const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
const numberOfWordsToTake = isQueryCompleteAfter
? WORDS_AFTER + 1
: WORDS_AFTER;
const wordsAfterAsString =
(isQueryCompleteAfter ? "" : " ") +
wordsAfter.slice(0, numberOfWordsToTake).join(" ");
return {
indexInSearchQuery: wordsBeforeAsString.length,
previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
moreBefore: startWordIndex > 0,
moreAfter: wordsAfter.length > numberOfWordsToTake,
};
};
const normalizeWrappedText = (
wrappedText: string,
originalText: string,
): string => {
const wrappedLines = wrappedText.split("\n");
const normalizedLines: string[] = [];
let originalIndex = 0;
for (let i = 0; i < wrappedLines.length; i++) {
let currentLine = wrappedLines[i];
const nextLine = wrappedLines[i + 1];
if (nextLine) {
const nextLineIndexInOriginal = originalText.indexOf(
nextLine,
originalIndex,
);
if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
while (j > 0) {
currentLine += " ";
j--;
}
}
}
normalizedLines.push(currentLine);
originalIndex = originalIndex + currentLine.length;
}
return normalizedLines.join("\n");
};
const getMatchedLines = (
textElement: ExcalidrawTextElement,
searchQuery: SearchQuery,
index: number,
) => {
const normalizedText = normalizeWrappedText(
textElement.text,
textElement.originalText,
);
const lines = normalizedText.split("\n");
const lineIndexRanges = [];
let currentIndex = 0;
let lineNumber = 0;
for (const line of lines) {
const startIndex = currentIndex;
const endIndex = startIndex + line.length - 1;
lineIndexRanges.push({
line,
startIndex,
endIndex,
lineNumber,
});
// Move to the next line's start index
currentIndex = endIndex + 1;
lineNumber++;
}
let startIndex = index;
let remainingQuery = textElement.originalText.slice(
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
break;
}
if (
startIndex >= lineIndexRange.startIndex &&
startIndex <= lineIndexRange.endIndex
) {
const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
const textToStart = lineIndexRange.line.slice(
0,
startIndex - lineIndexRange.startIndex,
);
const matchedWord = remainingQuery.slice(0, matchCapacity);
remainingQuery = remainingQuery.slice(matchCapacity);
const offset = measureText(
textToStart,
getFontString(textElement),
textElement.lineHeight,
true,
);
// measureText returns a non-zero width for the empty string
// which is not what we're after here, hence the check and the correction
if (textToStart === "") {
offset.width = 0;
}
if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
const lineLength = measureText(
lineIndexRange.line,
getFontString(textElement),
textElement.lineHeight,
true,
);
const spaceToStart =
textElement.textAlign === "center"
? (textElement.width - lineLength.width) / 2
: textElement.width - lineLength.width;
offset.width += spaceToStart;
}
const { width, height } = measureText(
matchedWord,
getFontString(textElement),
textElement.lineHeight,
);
const offsetX = offset.width;
const offsetY = lineIndexRange.lineNumber * offset.height;
matchedLines.push({
offsetX,
offsetY,
width,
height,
});
startIndex += matchCapacity;
}
}
return matchedLines;
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
const handleSearch = debounce(
(
searchQuery: SearchQuery,
app: AppClassProperties,
cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
) => {
if (!searchQuery || searchQuery === "") {
cb([], null);
return;
}
const elements = app.scene.getNonDeletedElements();
const texts = elements.filter((el) =>
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const matchItems: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
for (const textEl of texts) {
let match = null;
const text = textEl.originalText;
while ((match = regex.exec(text)) !== null) {
const preview = getMatchPreview(text, match.index, searchQuery);
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const visibleIds = new Set(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
) ?? null;
cb(matchItems, focusIndex);
},
SEARCH_DEBOUNCE,
);
+21 -7
View File
@@ -3,16 +3,29 @@
.excalidraw { .excalidraw {
--ExcTextField--color: var(--color-on-surface); --ExcTextField--color: var(--color-on-surface);
--ExcTextField--label-color: var(--color-on-surface); --ExcTextField--label-color: var(--color-on-surface);
--ExcTextField--background: transparent; --ExcTextField--background: var(--color-surface-low);
--ExcTextField--readonly--background: var(--color-surface-high); --ExcTextField--readonly--background: var(--color-surface-high);
--ExcTextField--readonly--color: var(--color-on-surface); --ExcTextField--readonly--color: var(--color-on-surface);
--ExcTextField--border: var(--color-border-outline); --ExcTextField--border: var(--color-gray-20);
--ExcTextField--readonly--border: var(--color-border-outline-variant); --ExcTextField--readonly--border: var(--color-border-outline-variant);
--ExcTextField--border-hover: var(--color-brand-hover); --ExcTextField--border-hover: var(--color-brand-hover);
--ExcTextField--border-active: var(--color-brand-active); --ExcTextField--border-active: var(--color-brand-active);
--ExcTextField--placeholder: var(--color-border-outline-variant); --ExcTextField--placeholder: var(--color-border-outline-variant);
.ExcTextField { .ExcTextField {
position: relative;
svg {
position: absolute;
top: 50%; // 50% is not exactly in the center of the input
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
&--fullWidth { &--fullWidth {
width: 100%; width: 100%;
flex-grow: 1; flex-grow: 1;
@@ -37,7 +50,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 0 1rem;
height: 3rem; height: 3rem;
@@ -45,6 +57,8 @@
border: 1px solid var(--ExcTextField--border); border: 1px solid var(--ExcTextField--border);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0 0.75rem;
&:not(&--readonly) { &:not(&--readonly) {
&:hover { &:hover {
border-color: var(--ExcTextField--border-hover); border-color: var(--ExcTextField--border-hover);
@@ -80,10 +94,6 @@
width: 100%; width: 100%;
&::placeholder {
color: var(--ExcTextField--placeholder);
}
&:not(:focus) { &:not(:focus) {
&:hover { &:hover {
background-color: initial; background-color: initial;
@@ -105,5 +115,9 @@
} }
} }
} }
&--hasIcon .ExcTextField__input {
padding-left: 2.5rem;
}
} }
} }
+10 -2
View File
@@ -21,7 +21,9 @@ type TextFieldProps = {
fullWidth?: boolean; fullWidth?: boolean;
selectOnRender?: boolean; selectOnRender?: boolean;
icon?: React.ReactNode;
label?: string; label?: string;
className?: string;
placeholder?: string; placeholder?: string;
isRedacted?: boolean; isRedacted?: boolean;
} & ({ value: string } | { defaultValue: string }); } & ({ value: string } | { defaultValue: string });
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isRedacted = false, isRedacted = false,
icon,
className,
...rest ...rest
}, },
ref, ref,
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
useLayoutEffect(() => { useLayoutEffect(() => {
if (selectOnRender) { if (selectOnRender) {
// focusing first is needed because vitest/jsdom
innerRef.current?.focus();
innerRef.current?.select(); innerRef.current?.select();
} }
}, [selectOnRender]); }, [selectOnRender]);
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", className, {
"ExcTextField--fullWidth": fullWidth, "ExcTextField--fullWidth": fullWidth,
"ExcTextField--hasIcon": !!icon,
})} })}
onClick={() => { onClick={() => {
innerRef.current?.focus(); innerRef.current?.focus();
}} }}
> >
<div className="ExcTextField__label">{label}</div> {icon}
{label && <div className="ExcTextField__label">{label}</div>}
<div <div
className={clsx("ExcTextField__input", { className={clsx("ExcTextField__input", {
"ExcTextField__input--readonly": readonly, "ExcTextField__input--readonly": readonly,
@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
snapLines: appState.snapLines, snapLines: appState.snapLines,
zenModeEnabled: appState.zenModeEnabled, zenModeEnabled: appState.zenModeEnabled,
editingTextElement: appState.editingTextElement, editingTextElement: appState.editingTextElement,
searchMatches: appState.searchMatches,
}); });
const areEqual = ( const areEqual = (
+8
View File
@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
</g>, </g>,
tablerIconProps, tablerIconProps,
); );
export const upIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 15l6 -6l6 6" />
</g>,
tablerIconProps,
);
@@ -15,6 +15,7 @@ import {
LoadIcon, LoadIcon,
MoonIcon, MoonIcon,
save, save,
searchIcon,
SunIcon, SunIcon,
TrashIcon, TrashIcon,
usersIcon, usersIcon,
@@ -27,6 +28,7 @@ import {
actionLoadScene, actionLoadScene,
actionSaveToActiveFile, actionSaveToActiveFile,
actionShortcuts, actionShortcuts,
actionToggleSearchMenu,
actionToggleTheme, actionToggleTheme,
} from "../../actions"; } from "../../actions";
import clsx from "clsx"; import clsx from "clsx";
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
import { THEME } from "../../constants"; import { THEME } from "../../constants";
import type { Theme } from "../../element/types"; import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics"; import { trackEvent } from "../../analytics";
import "./DefaultItems.scss"; import "./DefaultItems.scss";
export const LoadScene = () => { export const LoadScene = () => {
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
}; };
CommandPalette.displayName = "CommandPalette"; CommandPalette.displayName = "CommandPalette";
export const SearchMenu = (opts?: { className?: string }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
return (
<DropdownMenuItem
icon={searchIcon}
data-testid="search-menu-button"
onSelect={() => {
actionManager.executeAction(actionToggleSearchMenu);
}}
shortcut={getShortcutFromShortcutName("searchMenu")}
aria-label={t("search.title")}
className={opts?.className}
>
{t("search.title")}
</DropdownMenuItem>
);
};
SearchMenu.displayName = "SearchMenu";
export const Help = () => { export const Help = () => {
const { t } = useI18n(); const { t } = useI18n();
+2
View File
@@ -113,6 +113,7 @@ export const ENV = {
export const CLASSES = { export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left", SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions", ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
}; };
/** /**
@@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
}; };
export const LIBRARY_SIDEBAR_TAB = "library"; export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = { export const DEFAULT_SIDEBAR = {
name: "default", name: "default",
+3 -3
View File
@@ -144,9 +144,9 @@
--border-radius-md: 0.375rem; --border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem; --border-radius-lg: 0.5rem;
--color-surface-high: hsl(244, 100%, 97%); --color-surface-high: #f1f0ff;
--color-surface-mid: hsl(240 25% 96%); --color-surface-mid: #f2f2f7;
--color-surface-low: hsl(240 25% 94%); --color-surface-low: #ececf4;
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0; --color-brand-hover: #5753d0;
+9
View File
@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
: byteStringToString(window.atob(base64)); : byteStringToString(window.atob(base64));
}; };
export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
if (typeof Buffer !== "undefined") {
// Node.js environment
return Buffer.from(base64, "base64").buffer;
}
// Browser environment
return byteStringToArrayBuffer(atob(base64));
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// text encoding // text encoding
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+9 -5
View File
@@ -5,6 +5,7 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FontFamilyValues, FontFamilyValues,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding, PointBinding,
@@ -21,6 +22,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@@ -101,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = ( const repairBinding = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
binding: PointBinding | null, binding: PointBinding | FixedPointBinding | null,
): PointBinding | null => { ): PointBinding | FixedPointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
@@ -110,9 +112,11 @@ const repairBinding = (
return { return {
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
fixedPoint: isElbowArrow(element) ...(isElbowArrow(element) && isFixedPointBinding(binding)
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0]) ? {
: null, fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
}
: {}),
}; };
}; };
+3 -2
View File
@@ -39,6 +39,7 @@ import {
isBindingElement, isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement, isRectangularElement,
@@ -797,7 +798,7 @@ export const bindPointToSnapToElementOutline = (
isVertical isVertical
? Math.abs(p[1] - i[1]) < 0.1 ? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1, : Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? point; )[0] ?? p;
} }
return p; return p;
@@ -1013,7 +1014,7 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement)) { if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
+1 -8
View File
@@ -35,7 +35,6 @@ export const dragSelectedElements = (
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) && isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding) (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) { ) {
@@ -43,13 +42,7 @@ export const dragSelectedElements = (
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter(
(el) => (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
); );
// we do not want a frame and its elements to be selected at the same time // we do not want a frame and its elements to be selected at the same time
@@ -102,6 +102,7 @@ export class LinearElementEditor {
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) { constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
@@ -131,6 +132,7 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1477,7 +1479,9 @@ export class LinearElementEditor {
nextPoints, nextPoints,
vector(offsetX, offsetY), vector(offsetX, offsetY),
bindings, bindings,
options, {
isDragging: options?.isDragging,
},
); );
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
@@ -223,7 +223,6 @@ export const newTextElement = (
verticalAlign?: VerticalAlign; verticalAlign?: VerticalAlign;
containerId?: ExcalidrawTextContainer["id"] | null; containerId?: ExcalidrawTextContainer["id"] | null;
lineHeight?: ExcalidrawTextElement["lineHeight"]; lineHeight?: ExcalidrawTextElement["lineHeight"];
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
autoResize?: ExcalidrawTextElement["autoResize"]; autoResize?: ExcalidrawTextElement["autoResize"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawTextElement> => { ): NonDeleted<ExcalidrawTextElement> => {
+4 -14
View File
@@ -9,6 +9,7 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -909,6 +910,8 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawArrowElement["startBinding"];
endBinding?: ExcalidrawArrowElement["endBinding"];
}; };
}[] = []; }[] = [];
@@ -993,19 +996,6 @@ export const resizeMultipleElements = (
mutateElement(element, update, false); mutateElement(element, update, false);
if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
);
}
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
oldSize: { width: oldWidth, height: oldHeight }, oldSize: { width: oldWidth, height: oldHeight },
@@ -1059,7 +1049,7 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
if (isArrowElement(element) && isElbowArrow(element)) { if (isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points); mutateElbowArrow(element, elementsMap, points);
} else { } else {
+13 -12
View File
@@ -94,7 +94,16 @@ describe("elbow arrow routing", () => {
describe("elbow arrow ui", () => { describe("elbow arrow ui", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
}); });
it("can follow bound shapes", async () => { it("can follow bound shapes", async () => {
@@ -130,8 +139,8 @@ describe("elbow arrow ui", () => {
expect(arrow.elbowed).toBe(true); expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[35, 0], [45, 0],
[35, 200], [45, 200],
[90, 200], [90, 200],
]); ]);
}); });
@@ -163,14 +172,6 @@ describe("elbow arrow ui", () => {
h.state, h.state,
)[0] as ExcalidrawArrowElement; )[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
@@ -182,8 +183,8 @@ describe("elbow arrow ui", () => {
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90], [35, 90],
[25, 90], [35, 90], // Note that coordinates are rounded above!
[25, 165], [35, 165],
[103, 165], [103, 165],
]); ]);
}); });
+62 -32
View File
@@ -36,11 +36,11 @@ import {
HEADING_UP, HEADING_UP,
vectorToHeading, vectorToHeading,
} from "./heading"; } from "./heading";
import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPointBinding,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -72,16 +72,48 @@ export const mutateElbowArrow = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offset?: Vector, offset?: Vector,
otherUpdates?: { otherUpdates?: Omit<
startBinding?: FixedPointBinding | null; ElementUpdate<ExcalidrawElbowArrowElement>,
endBinding?: FixedPointBinding | null; "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
>,
options?: {
isDragging?: boolean;
informMutation?: boolean;
}, },
) => {
const update = updateElbowArrow(
arrow,
elementsMap,
nextPoints,
offset,
options,
);
if (update) {
mutateElement(
arrow,
{
...otherUpdates,
...update,
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
}
};
export const updateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[],
offset?: Vector,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
}, },
) => { ): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
const origStartGlobalPoint: GlobalPoint = pointTranslate( const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>( pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0], nextPoints[0],
@@ -235,6 +267,8 @@ export const mutateElbowArrow = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -295,18 +329,10 @@ export const mutateElbowArrow = (
startDongle && points.unshift(startGlobalPoint); startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint); endDongle && points.push(endGlobalPoint);
mutateElement( return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
arrow,
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
} }
return null;
}; };
const offsetFromHeading = ( const offsetFromHeading = (
@@ -475,7 +501,11 @@ const generateDynamicAABBs = (
startDifference?: [number, number, number, number], startDifference?: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference?: [number, number, number, number],
disableSideHack?: boolean, disableSideHack?: boolean,
startElementBounds?: Bounds | null,
endElementBounds?: Bounds | null,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b;
const [startUp, startRight, startDown, startLeft] = startDifference ?? [ const [startUp, startRight, startDown, startLeft] = startDifference ?? [
0, 0, 0, 0, 0, 0, 0, 0,
]; ];
@@ -484,29 +514,29 @@ const generateDynamicAABBs = (
const first = [ const first = [
a[0] > b[2] a[0] > b[2]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft) ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
: (a[0] + b[2]) / 2 : (startEl[0] + endEl[2]) / 2
: a[0] > b[0] : a[0] > b[0]
? a[0] - startLeft ? a[0] - startLeft
: common[0] - startLeft, : common[0] - startLeft,
a[1] > b[3] a[1] > b[3]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.min((a[1] + b[3]) / 2, a[1] - startUp) ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
: (a[1] + b[3]) / 2 : (startEl[1] + endEl[3]) / 2
: a[1] > b[1] : a[1] > b[1]
? a[1] - startUp ? a[1] - startUp
: common[1] - startUp, : common[1] - startUp,
a[2] < b[0] a[2] < b[0]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.max((a[2] + b[0]) / 2, a[2] + startRight) ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
: (a[2] + b[0]) / 2 : (startEl[2] + endEl[0]) / 2
: a[2] < b[2] : a[2] < b[2]
? a[2] + startRight ? a[2] + startRight
: common[2] + startRight, : common[2] + startRight,
a[3] < b[1] a[3] < b[1]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.max((a[3] + b[1]) / 2, a[3] + startDown) ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
: (a[3] + b[1]) / 2 : (startEl[3] + endEl[1]) / 2
: a[3] < b[3] : a[3] < b[3]
? a[3] + startDown ? a[3] + startDown
: common[3] + startDown, : common[3] + startDown,
@@ -514,29 +544,29 @@ const generateDynamicAABBs = (
const second = [ const second = [
b[0] > a[2] b[0] > a[2]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft) ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
: (b[0] + a[2]) / 2 : (endEl[0] + startEl[2]) / 2
: b[0] > a[0] : b[0] > a[0]
? b[0] - endLeft ? b[0] - endLeft
: common[0] - endLeft, : common[0] - endLeft,
b[1] > a[3] b[1] > a[3]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.min((b[1] + a[3]) / 2, b[1] - endUp) ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
: (b[1] + a[3]) / 2 : (endEl[1] + startEl[3]) / 2
: b[1] > a[1] : b[1] > a[1]
? b[1] - endUp ? b[1] - endUp
: common[1] - endUp, : common[1] - endUp,
b[2] < a[0] b[2] < a[0]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.max((b[2] + a[0]) / 2, b[2] + endRight) ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
: (b[2] + a[0]) / 2 : (endEl[2] + startEl[0]) / 2
: b[2] < a[2] : b[2] < a[2]
? b[2] + endRight ? b[2] + endRight
: common[2] + endRight, : common[2] + endRight,
b[3] < a[1] b[3] < a[1]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.max((b[3] + a[1]) / 2, b[3] + endDown) ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
: (b[3] + a[1]) / 2 : (endEl[3] + startEl[1]) / 2
: b[3] < a[3] : b[3] < a[3]
? b[3] + endDown ? b[3] + endDown
: common[3] + endDown, : common[3] + endDown,
+2 -7
View File
@@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants"; import { SHIFT_LOCKING_ANGLE } from "../constants";
import type { AppState, Zoom } from "../types"; import type { AppState, Offsets, Zoom } from "../types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
scrollY: number; scrollY: number;
}, },
elementsMap: ElementsMap, elementsMap: ElementsMap,
padding?: Partial<{ padding?: Offsets,
top: number;
right: number;
bottom: number;
left: number;
}>,
) => { ) => {
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
const topLeftSceneCoords = viewportCoordsToSceneCoords( const topLeftSceneCoords = viewportCoordsToSceneCoords(
+4 -3
View File
@@ -284,16 +284,17 @@ export const measureText = (
text: string, text: string,
font: FontString, font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"], lineHeight: ExcalidrawTextElement["lineHeight"],
forceAdvanceWidth?: true,
) => { ) => {
text = text const _text = text
.split("\n") .split("\n")
// replace empty lines with single space because leading/trailing empty // replace empty lines with single space because leading/trailing empty
// lines would be stripped from computation // lines would be stripped from computation
.map((x) => x || " ") .map((x) => x || " ")
.join("\n"); .join("\n");
const fontSize = parseFloat(font); const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight); const height = getTextHeight(_text, fontSize, lineHeight);
const width = getTextWidth(text, font); const width = getTextWidth(_text, font, forceAdvanceWidth);
return { width, height }; return { width, height };
}; };
+1 -1
View File
@@ -247,7 +247,7 @@ export const textWysiwyg = ({
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari) // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
const padding = !isSafari const padding = !isSafari
? Math.ceil(updatedTextElement.fontSize / 2) ? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
: 0; : 0;
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
+5 -2
View File
@@ -320,9 +320,12 @@ export const getDefaultRoundnessTypeForElement = (
}; };
export const isFixedPointBinding = ( export const isFixedPointBinding = (
binding: PointBinding, binding: PointBinding | FixedPointBinding,
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return binding.fixedPoint != null; return (
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
}; };
// TODO: Move this to @excalidraw/math // TODO: Move this to @excalidraw/math
+12 -7
View File
@@ -193,6 +193,7 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement | ExcalidrawGenericElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawArrowElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
@@ -268,15 +269,19 @@ export type PointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number; focus: number;
gap: number; gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
}; };
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>; export type FixedPointBinding = Merge<
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
+6 -5
View File
@@ -1,4 +1,8 @@
import { stringToBase64, toByteString } from "../data/encode"; import {
base64ToArrayBuffer,
stringToBase64,
toByteString,
} from "../data/encode";
import { LOCAL_FONT_PROTOCOL } from "./metadata"; import { LOCAL_FONT_PROTOCOL } from "./metadata";
import loadWoff2 from "./wasm/woff2.loader"; import loadWoff2 from "./wasm/woff2.loader";
import loadHbSubset from "./wasm/hb-subset.loader"; import loadHbSubset from "./wasm/hb-subset.loader";
@@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
// it's dataurl (server), the font is inlined as base64, no need to fetch // it's dataurl (server), the font is inlined as base64, no need to fetch
if (url.protocol === "data:") { if (url.protocol === "data:") {
const arrayBuffer = Buffer.from( const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
url.toString().split(",")[1],
"base64",
).buffer;
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints( const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
arrayBuffer, arrayBuffer,
+10 -1
View File
@@ -162,6 +162,13 @@
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here." "hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
}, },
"search": {
"title": "Find on canvas",
"noMatch": "No matches found...",
"singleResult": "result",
"multipleResults": "results",
"placeholder": "Find text on canvas..."
},
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",
"exportJSON": "Export to file", "exportJSON": "Export to file",
@@ -223,7 +230,8 @@
"resetLibrary": "This will clear your library. Are you sure?", "resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.", "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!" "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!",
"saveYourContent": "Don't forget to save your content ({{shortcut}})!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Unsupported file type.", "unsupportedFileType": "Unsupported file type.",
@@ -297,6 +305,7 @@
"shapes": "Shapes" "shapes": "Shapes"
}, },
"hints": { "hints": {
"dismissSearch": "Escape to dismiss search",
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool", "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
"linearElement": "Click to start multiple points, drag for single line", "linearElement": "Click to start multiple points, drag for single line",
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.", "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
@@ -30,8 +30,12 @@ import {
shouldShowBoundingBox, shouldShowBoundingBox,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils"; import { arrayToMap, throttleRAF } from "../utils";
import type { InteractiveCanvasAppState } from "../types"; import {
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; DEFAULT_TRANSFORM_HANDLE_SPACING,
FRAME_STYLE,
THEME,
} from "../constants";
import { type InteractiveCanvasAppState } from "../types";
import { renderSnaps } from "../renderer/renderSnaps"; import { renderSnaps } from "../renderer/renderSnaps";
@@ -48,7 +52,6 @@ import {
} from "./helpers"; } from "./helpers";
import oc from "open-color"; import oc from "open-color";
import { import {
isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
@@ -803,7 +806,6 @@ const _renderInteractiveScene = ({
// Elbow arrow elements cannot be selected when bound on either end // Elbow arrow elements cannot be selected when bound on either end
( (
isSingleLinearElementSelected && isSingleLinearElementSelected &&
isArrowElement(element) &&
isElbowArrow(element) && isElbowArrow(element) &&
(element.startBinding || element.endBinding) (element.startBinding || element.endBinding)
) )
@@ -952,9 +954,48 @@ const _renderInteractiveScene = ({
context.restore(); context.restore();
} }
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
const element = elementsMap.get(id);
if (element && isTextElement(element)) {
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
true,
);
context.save();
if (appState.theme === THEME.LIGHT) {
if (focus) {
context.fillStyle = "rgba(255, 124, 0, 0.4)";
} else {
context.fillStyle = "rgba(255, 226, 0, 0.4)";
}
} else if (focus) {
context.fillStyle = "rgba(229, 82, 0, 0.4)";
} else {
context.fillStyle = "rgba(99, 52, 0, 0.4)";
}
context.translate(appState.scrollX, appState.scrollY);
context.translate(cx, cy);
context.rotate(element.angle);
matchedLines.forEach((matchedLine) => {
context.fillRect(
elementX1 + matchedLine.offsetX - cx,
elementY1 + matchedLine.offsetY - cy,
matchedLine.width,
matchedLine.height,
);
});
context.restore();
}
});
renderSnaps(context, appState); renderSnaps(context, appState);
// Reset zoom
context.restore(); context.restore();
renderRemoteCursors({ renderRemoteCursors({
@@ -421,6 +421,7 @@ const renderElementToSvg = (
image.setAttribute("width", "100%"); image.setAttribute("width", "100%");
image.setAttribute("height", "100%"); image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL); image.setAttribute("href", fileData.dataURL);
image.setAttribute("preserveAspectRatio", "none");
symbol.appendChild(image); symbol.appendChild(image);
+10
View File
@@ -185,6 +185,11 @@ export const exportToCanvas = async (
exportingFrame ?? null, exportingFrame ?? null,
appState.frameRendering ?? null, appState.frameRendering ?? null,
); );
// for canvas export, don't clip if exporting a specific frame as it would
// clip the corners of the content
if (exportingFrame) {
frameRendering.clip = false;
}
const elementsForRender = prepareElementsForRender({ const elementsForRender = prepareElementsForRender({
elements, elements,
@@ -351,6 +356,11 @@ export const exportToSvg = async (
}) rotate(${frame.angle} ${cx} ${cy})" }) rotate(${frame.angle} ${cx} ${cy})"
width="${frame.width}" width="${frame.width}"
height="${frame.height}" height="${frame.height}"
${
exportingFrame
? ""
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
}
> >
</rect> </rect>
</clipPath>`; </clipPath>`;
+17 -3
View File
@@ -1,4 +1,4 @@
import type { AppState, PointerCoords, Zoom } from "../types"; import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
import type { ExcalidrawElement } from "../element/types"; import type { ExcalidrawElement } from "../element/types";
import { import {
getCommonBounds, getCommonBounds,
@@ -31,14 +31,28 @@ export const centerScrollOn = ({
scenePoint, scenePoint,
viewportDimensions, viewportDimensions,
zoom, zoom,
offsets,
}: { }: {
scenePoint: PointerCoords; scenePoint: PointerCoords;
viewportDimensions: { height: number; width: number }; viewportDimensions: { height: number; width: number };
zoom: Zoom; zoom: Zoom;
offsets?: Offsets;
}) => { }) => {
let scrollX =
(viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
scenePoint.x;
scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
let scrollY =
(viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
scenePoint.y;
scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
return { return {
scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x, scrollX,
scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y, scrollY,
}; };
}; };
@@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0_copy": true, "id0_copy": true,
}, },
@@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
Ctrl+Shift+E Ctrl+Shift+E
</div> </div>
</button> </button>
<button
aria-label="Find on canvas"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="search-menu-button"
title="Find on canvas"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
/>
<path
d="M21 21l-6 -6"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Find on canvas
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+F
</div>
</button>
<button <button
aria-label="Help" aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base" class="dropdown-menu-item dropdown-menu-item-base"
File diff suppressed because one or more lines are too long
@@ -80,6 +80,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id166": true, "id166": true,
}, },
@@ -681,6 +682,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id161": true, "id161": true,
}, },
@@ -1187,6 +1189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -1554,6 +1557,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -1922,6 +1926,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -2185,6 +2190,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id180": true, "id180": true,
}, },
@@ -2627,6 +2633,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -2925,6 +2932,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -3208,6 +3216,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -3501,6 +3510,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -3786,6 +3796,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4020,6 +4031,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4278,6 +4290,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4550,6 +4563,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4780,6 +4794,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5010,6 +5025,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5238,6 +5254,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5466,6 +5483,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5723,6 +5741,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -6053,6 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -6477,6 +6497,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id100": true, "id100": true,
"id101": true, "id101": true,
@@ -6855,6 +6876,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id105": true, "id105": true,
"id106": true, "id106": true,
@@ -7170,6 +7192,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id113": true, "id113": true,
}, },
@@ -7467,6 +7490,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -7695,6 +7719,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -8049,6 +8074,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -8406,6 +8432,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id127": true, "id127": true,
"id128": true, "id128": true,
@@ -8806,6 +8833,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -9092,6 +9120,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id124": true, "id124": true,
}, },
@@ -9356,6 +9385,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id81": true, "id81": true,
}, },
@@ -9619,6 +9649,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id83": true, "id83": true,
}, },
@@ -9852,6 +9883,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -10149,6 +10181,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id92": true, "id92": true,
}, },
@@ -10488,6 +10521,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -10725,6 +10759,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -11174,6 +11209,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id84": true, "id84": true,
}, },
@@ -11427,6 +11463,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -11665,6 +11702,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id14": true, "id14": true,
}, },
@@ -11905,6 +11943,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -12308,6 +12347,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"resizingElement": null, "resizingElement": null,
"scrollX": -50, "scrollX": -50,
"scrollY": -50, "scrollY": -50,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -12551,6 +12591,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id78": true, "id78": true,
}, },
@@ -12791,6 +12832,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id19": true, "id19": true,
}, },
@@ -13033,6 +13075,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -13277,6 +13320,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id8": true, "id8": true,
}, },
@@ -13611,6 +13655,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -13779,6 +13824,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
"id2": true, "id2": true,
@@ -14069,6 +14115,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -14334,6 +14381,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id11": true, "id11": true,
}, },
@@ -14609,6 +14657,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -14768,6 +14817,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id55": true, "id55": true,
}, },
@@ -15463,6 +15513,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id49": true, "id49": true,
}, },
@@ -16082,6 +16133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id61": true, "id61": true,
}, },
@@ -16699,6 +16751,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id62": true, "id62": true,
}, },
@@ -17412,6 +17465,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id69": true, "id69": true,
"id71": true, "id71": true,
@@ -18161,6 +18215,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id39": true, "id39": true,
"id41": true, "id41": true,
@@ -18634,6 +18689,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id35_copy_copy": true, "id35_copy_copy": true,
"id36_copy_copy": true, "id36_copy_copy": true,
@@ -19155,6 +19211,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -19610,6 +19667,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"resizingElement": null, "resizingElement": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id27": true, "id27": true,
}, },
@@ -84,6 +84,7 @@ exports[`given element A and group of elements B and given both are selected whe
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id2": true, "id2": true,
@@ -495,6 +496,7 @@ exports[`given element A and group of elements B and given both are selected whe
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -893,6 +895,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id7": true, "id7": true,
}, },
@@ -1434,6 +1437,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -1636,6 +1640,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id2": true, "id2": true,
@@ -2007,6 +2012,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -2241,6 +2247,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -2419,6 +2426,7 @@ exports[`regression tests > can drag element that covers another element, while
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -2733,6 +2741,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -2977,6 +2986,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -3216,6 +3226,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -3442,6 +3453,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -3694,6 +3706,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -3999,6 +4012,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -4412,6 +4426,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4691,6 +4706,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -4939,6 +4955,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5145,6 +5162,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -5338,6 +5356,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id2": true, "id2": true,
}, },
@@ -5719,6 +5738,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -6002,6 +6022,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -6809,6 +6830,7 @@ exports[`regression tests > given a group of selected elements with an element t
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -7134,6 +7156,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -7406,6 +7429,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -7636,6 +7660,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -7867,6 +7892,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -8043,6 +8069,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -8219,6 +8246,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -8395,12 +8423,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -8613,12 +8643,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -8830,6 +8862,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -9020,12 +9053,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -9238,6 +9273,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -9414,12 +9450,14 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -9631,6 +9669,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -9807,6 +9846,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -9997,6 +10037,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -10177,6 +10218,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -10685,6 +10727,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -10956,6 +10999,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"scrollX": "-6.25000", "scrollX": "-6.25000",
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -11080,6 +11124,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -11276,6 +11321,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -11584,6 +11630,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -11993,6 +12040,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
"id1": true, "id1": true,
@@ -12600,6 +12648,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"scrollX": 60, "scrollX": 60,
"scrollY": 60, "scrollY": 60,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -12724,6 +12773,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -13305,6 +13355,7 @@ exports[`regression tests > switches from group of selected elements to another
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -13638,6 +13689,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id0": true, "id0": true,
}, },
@@ -13897,6 +13949,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"scrollX": 20, "scrollX": 20,
"scrollY": "-18.53553", "scrollY": "-18.53553",
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -14019,6 +14072,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": { "selectedElementIds": {
"id1": true, "id1": true,
}, },
@@ -14394,6 +14448,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
@@ -14519,6 +14574,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
+8 -9
View File
@@ -23,7 +23,6 @@ import { Excalidraw } from "../index";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { vi } from "vitest"; import { vi } from "vitest";
import * as blob from "../data/blob";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement"; import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard"; import { createPasteEvent } from "../clipboard";
@@ -33,15 +32,15 @@ import { point, type Radians } from "../../math";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
// This needs to fixed in vitest mock, as when importActual used with mock
// the tests hangs - https://github.com/vitest-dev/vitest/issues/546.
// But fortunately spying and mocking the return value of spy works :p
const resizeImageFileSpy = vi.spyOn(blob, "resizeImageFile"); vi.mock("../data/blob", async (actual) => {
const generateIdFromFileSpy = vi.spyOn(blob, "generateIdFromFile"); const orig: Object = await actual();
return {
resizeImageFileSpy.mockImplementation(async (imageFile: File) => imageFile); ...orig,
generateIdFromFileSpy.mockImplementation(async () => "fileId" as FileId); resizeImageFile: (imageFile: File) => imageFile,
generateIdFromFile: () => "fileId" as FileId,
};
});
beforeEach(async () => { beforeEach(async () => {
// Unmount ReactDOM from root // Unmount ReactDOM from root
+20 -5
View File
@@ -9,6 +9,8 @@ import type {
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -76,11 +78,12 @@ export class API {
}); });
}; };
static updateElement = ( // eslint-disable-next-line prettier/prettier
...[element, updates]: Parameters<typeof mutateElement> static updateElement = <T extends ExcalidrawElement>(
...args: Parameters<typeof mutateElement<T>>
) => { ) => {
act(() => { act(() => {
mutateElement(element, updates); mutateElement<T>(...args);
}); });
}; };
@@ -126,6 +129,10 @@ export class API {
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
}; };
static getElement = <T extends ExcalidrawElement>(element: T): T => {
return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
}
static createElement = < static createElement = <
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle", T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
>({ >({
@@ -178,10 +185,16 @@ export class API {
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
status?: T extends "image" ? ExcalidrawImageElement["status"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
startBinding?: T extends "arrow" startBinding?: T extends "arrow"
? ExcalidrawLinearElement["startBinding"] ? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"]
: never; : never;
endBinding?: T extends "arrow" endBinding?: T extends "arrow"
? ExcalidrawLinearElement["endBinding"] ? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"]
: never;
startArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
: never;
endArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
: never; : never;
elbowed?: boolean; elbowed?: boolean;
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
@@ -339,6 +352,8 @@ export class API {
if (element.type === "arrow") { if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null; element.startBinding = rest.startBinding ?? null;
element.endBinding = rest.endBinding ?? null; element.endBinding = rest.endBinding ?? null;
element.startArrowhead = rest.startArrowhead ?? null;
element.endArrowhead = rest.endArrowhead ?? null;
} }
if (id) { if (id) {
element.id = id; element.id = id;
+13 -7
View File
@@ -69,8 +69,11 @@ export class Keyboard {
} }
}; };
static keyDown = (key: string) => { static keyDown = (
fireEvent.keyDown(document, { key: string,
target: HTMLElement | Document | Window = document,
) => {
fireEvent.keyDown(target, {
key, key,
ctrlKey, ctrlKey,
shiftKey, shiftKey,
@@ -78,8 +81,11 @@ export class Keyboard {
}); });
}; };
static keyUp = (key: string) => { static keyUp = (
fireEvent.keyUp(document, { key: string,
target: HTMLElement | Document | Window = document,
) => {
fireEvent.keyUp(target, {
key, key,
ctrlKey, ctrlKey,
shiftKey, shiftKey,
@@ -87,9 +93,9 @@ export class Keyboard {
}); });
}; };
static keyPress = (key: string) => { static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
Keyboard.keyDown(key); Keyboard.keyDown(key, target);
Keyboard.keyUp(key); Keyboard.keyUp(key, target);
}; };
static codeDown = (code: string) => { static codeDown = (code: string) => {
+5 -4
View File
@@ -31,6 +31,7 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
} from "../element/types"; } from "../element/types";
@@ -2049,13 +2050,13 @@ describe("history", () => {
focus: -0.001587301587301948, focus: -0.001587301587301948,
gap: 5, gap: 5,
fixedPoint: [1.0318471337579618, 0.49920634920634904], fixedPoint: [1.0318471337579618, 0.49920634920634904],
}, } as FixedPointBinding,
endBinding: { endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5", elementId: "u2JGnnmoJ0VATV4vCNJE5",
focus: -0.0016129032258049847, focus: -0.0016129032258049847,
gap: 3.537079145500037, gap: 3.537079145500037,
fixedPoint: [0.4991935483870975, -0.03875193720914723], fixedPoint: [0.4991935483870975, -0.03875193720914723],
}, } as FixedPointBinding,
}, },
], ],
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
@@ -4455,7 +4456,7 @@ describe("history", () => {
elements: [ elements: [
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { boundElements: [] }), newElementWith(h.elements[1], { boundElements: [] }),
newElementWith(h.elements[2] as ExcalidrawLinearElement, { newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, {
endBinding: { endBinding: {
elementId: remoteContainer.id, elementId: remoteContainer.id,
gap: 1, gap: 1,
@@ -4655,7 +4656,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [ elements: [
newElementWith(h.elements[0] as ExcalidrawLinearElement, { newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, {
startBinding: { startBinding: {
elementId: rect1.id, elementId: rect1.id,
gap: 1, gap: 1,
@@ -43,7 +43,7 @@ describe("event callbacks", () => {
// files // files
{}, {},
); );
expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe( expect(onChange.mock?.lastCall?.[1].viewBackgroundColor).not.toBe(
origBackgroundColor, origBackgroundColor,
); );
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ export const getTextEditor = async (selector: string, waitForEditor = true) => {
}; };
export const updateTextEditor = ( export const updateTextEditor = (
editor: HTMLTextAreaElement, editor: HTMLTextAreaElement | HTMLInputElement,
value: string, value: string,
) => { ) => {
fireEvent.change(editor, { target: { value } }); fireEvent.change(editor, { target: { value } });
+57 -2
View File
@@ -4,6 +4,7 @@ import { render } from "./test-utils";
import { reseed } from "../random"; import { reseed } from "../random";
import { UI, Keyboard, Pointer } from "./helpers/ui"; import { UI, Keyboard, Pointer } from "./helpers/ui";
import type { import type {
ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
} from "../element/types"; } from "../element/types";
@@ -333,6 +334,62 @@ describe("arrow element", () => {
expect(label.angle).toBeCloseTo(0); expect(label.angle).toBeCloseTo(0);
expect(label.fontSize).toEqual(20); expect(label.fontSize).toEqual(20);
}); });
it("flips the fixed point binding on negative resize for single bindable", () => {
const rectangle = UI.createElement("rectangle", {
x: -100,
y: -75,
width: 95,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-5, 0);
mouse.click();
mouse.moveTo(120, 200);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
it("flips the fixed point binding on negative resize for group selection", () => {
const rectangle = UI.createElement("rectangle", {
x: -100,
y: -75,
width: 95,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-5, 0);
mouse.click();
mouse.moveTo(120, 200);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
}); });
describe("text element", () => { describe("text element", () => {
@@ -828,7 +885,6 @@ describe("multiple selection", () => {
expect(leftBoundArrow.endBinding?.elementId).toBe( expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId, leftArrowBinding.elementId,
); );
expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.x).toBeCloseTo(210);
@@ -843,7 +899,6 @@ describe("multiple selection", () => {
expect(rightBoundArrow.endBinding?.elementId).toBe( expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId, rightArrowBinding.elementId,
); );
expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus); expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
}); });
File diff suppressed because one or more lines are too long
+148
View File
@@ -0,0 +1,148 @@
import React from "react";
import { act, render, waitFor } from "./test-utils";
import { Excalidraw } from "../index";
import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
import { Keyboard } from "./helpers/ui";
import { KEYS } from "../keys";
import { updateTextEditor } from "./queries/dom";
import { API } from "./helpers/api";
import type { ExcalidrawTextElement } from "../element/types";
const { h } = window;
const querySearchInput = async () => {
const input =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
)!;
await waitFor(() => expect(input).not.toBeNull());
return input;
};
describe("search", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally />);
API.setAppState({
openSidebar: null,
});
});
it("should toggle search on cmd+f", async () => {
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
});
it("should refocus search input with cmd+f when search sidebar is still open", async () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
const searchInput =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
act(() => {
searchInput?.blur();
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(searchInput?.matches(":focus")).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(searchInput?.matches(":focus")).toBe(true);
});
it("should match text and cycle through matches on Enter", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({ type: "text", text: "test one" }),
API.createElement({ type: "text", text: "test two" }),
]);
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(2);
expect(h.app.state.searchMatches[0].focus).toBe(true);
});
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches[0].focus).toBe(false);
expect(h.app.state.searchMatches[1].focus).toBe(true);
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches[0].focus).toBe(true);
expect(h.app.state.searchMatches[1].focus).toBe(false);
});
it("should match text split across multiple lines", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({
type: "text",
text: "",
}),
]);
API.updateElement(h.elements[0] as ExcalidrawTextElement, {
text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns",
originalText: "test text split into multiple lines",
});
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(1);
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(4);
});
updateTextEditor(searchInput, "ext spli");
await waitFor(() => {
expect(h.app.state.searchMatches.length).toBe(1);
expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(6);
});
});
});
+24
View File
@@ -198,6 +198,8 @@ export type InteractiveCanvasAppState = Readonly<
snapLines: AppState["snapLines"]; snapLines: AppState["snapLines"];
zenModeEnabled: AppState["zenModeEnabled"]; zenModeEnabled: AppState["zenModeEnabled"];
editingTextElement: AppState["editingTextElement"]; editingTextElement: AppState["editingTextElement"];
// Search matches
searchMatches: AppState["searchMatches"];
} }
>; >;
@@ -384,8 +386,20 @@ 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>;
searchMatches: readonly SearchMatch[];
} }
type SearchMatch = {
id: string;
focus: boolean;
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
};
export type UIAppState = Omit< export type UIAppState = Omit<
AppState, AppState,
| "suggestedBindings" | "suggestedBindings"
@@ -642,6 +656,9 @@ export type AppClassProperties = {
getEffectiveGridSize: App["getEffectiveGridSize"]; getEffectiveGridSize: App["getEffectiveGridSize"];
setPlugins: App["setPlugins"]; setPlugins: App["setPlugins"];
plugins: App["plugins"]; plugins: App["plugins"];
getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@@ -834,3 +851,10 @@ export type GenerateDiagramToCode = (props: {
frame: ExcalidrawMagicFrameElement; frame: ExcalidrawMagicFrameElement;
children: readonly ExcalidrawElement[]; children: readonly ExcalidrawElement[];
}) => MaybePromise<{ html: string }>; }) => MaybePromise<{ html: string }>;
export type Offsets = Partial<{
top: number;
right: number;
bottom: number;
left: number;
}>;
+3 -3
View File
@@ -110,8 +110,8 @@ export const debugDrawBoundingBox = (
export const debugDrawBounds = ( export const debugDrawBounds = (
box: Bounds | Bounds[], box: Bounds | Bounds[],
opts?: { opts?: {
color: string; color?: string;
permanent: boolean; permanent?: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox) => (isBounds(box) ? [box] : box).forEach((bbox) =>
@@ -136,7 +136,7 @@ export const debugDrawBounds = (
], ],
{ {
color: opts?.color ?? "green", color: opts?.color ?? "green",
permanent: opts?.permanent, permanent: !!opts?.permanent,
}, },
), ),
); );
+18 -5
View File
@@ -1,14 +1,27 @@
export const PRECISION = 10e-5; export const PRECISION = 10e-5;
export function clamp(value: number, min: number, max: number) { export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} };
export function round(value: number, precision: number) { export const round = (
value: number,
precision: number,
func: "round" | "floor" | "ceil" = "round",
) => {
const multiplier = Math.pow(10, precision); const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier; return Math[func]((value + Number.EPSILON) * multiplier) / multiplier;
} };
export const roundToStep = (
value: number,
step: number,
func: "round" | "floor" | "ceil" = "round",
): number => {
const factor = 1 / step;
return Math[func](value * factor) / factor;
};
export const average = (a: number, b: number) => (a + b) / 2; export const average = (a: number, b: number) => (a + b) / 2;
@@ -81,6 +81,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {}, "selectedElementIds": {},
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
-1
View File
@@ -32,7 +32,6 @@ describe("exportToCanvas", async () => {
describe("exportToBlob", async () => { describe("exportToBlob", async () => {
describe("mime type", () => { describe("mime type", () => {
// afterEach(vi.restoreAllMocks);
it("should change image/jpg to image/jpeg", async () => { it("should change image/jpg to image/jpeg", async () => {
const blob = await utils.exportToBlob({ const blob = await utils.exportToBlob({
...diagramFactory(), ...diagramFactory(),
+1 -1
View File
@@ -76,7 +76,7 @@ vi.mock(
const url = this.urls[0]; const url = this.urls[0];
if (url.protocol !== "file:") { if (url.protocol !== "file:") {
return super.getContent(); return super.getContent(new Set());
} }
// read local assets directly, without running a server // read local assets directly, without running a server
+12 -4
View File
@@ -2,19 +2,27 @@ import { defineConfig } from "vitest/config";
import { woff2BrowserPlugin } from "./scripts/woff2/woff2-vite-plugins"; import { woff2BrowserPlugin } from "./scripts/woff2/woff2-vite-plugins";
export default defineConfig({ export default defineConfig({
// @ts-ignore //@ts-ignore
plugins: [woff2BrowserPlugin()], plugins: [woff2BrowserPlugin()],
test: { test: {
// Since hooks are running in stack in v2, which means all hooks run serially whereas
// we need to run them in parallel
sequence: {
hooks: "parallel",
},
setupFiles: ["./setupTests.ts"], setupFiles: ["./setupTests.ts"],
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
coverage: { coverage: {
reporter: ["text", "json-summary", "json", "html", "lcovonly"], reporter: ["text", "json-summary", "json", "html", "lcovonly"],
// Since v2, it ignores empty lines by default and we need to disable it as it affects the coverage
// Additionally the thresholds also needs to be updated slightly as a result of this change
ignoreEmptyLines: false,
thresholds: { thresholds: {
lines: 70, lines: 66,
branches: 70, branches: 70,
functions: 68, functions: 63,
statements: 70, statements: 66,
}, },
}, },
}, },
+607 -355
View File
File diff suppressed because it is too large Load Diff