Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
521896cccf | ||
|
|
fe318126bd | ||
|
|
8ca4cf3260 | ||
|
|
f3f0ab7c83 | ||
|
|
44a1c8d857 | ||
|
|
e0a22edfbd | ||
|
|
c07f5a0c80 | ||
|
|
508f16dc04 | ||
|
|
c1b310c56b | ||
|
|
d4900e8f19 | ||
|
|
caf2db934c | ||
|
|
60e3801691 | ||
|
|
80f3b75d42 | ||
|
|
dc812bee19 | ||
|
|
01e83cc9a5 | ||
|
|
813f9b702e | ||
|
|
fd39712ba6 | ||
|
|
b46ca0192b | ||
|
|
72b7c937b1 | ||
|
|
d107215564 | ||
|
|
6959a363f0 | ||
|
|
5a11c70714 | ||
|
|
6ff56c36e3 | ||
|
|
51ea184938 | ||
|
|
21fff26d31 | ||
|
|
f4dd23fc31 | ||
|
|
e3d1dee9d0 | ||
|
|
e3f31df747 | ||
|
|
60e75406e0 | ||
|
|
b396e07b90 | ||
|
|
2d1d84a47b | ||
|
|
ee30225062 | ||
|
|
16cae4fc07 | ||
|
|
576bc0dbe5 | ||
|
|
00af35c692 | ||
|
|
ea7c702cfc |
@@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a
|
|||||||
# put these in your .env.local, or make sure you don't commit!
|
# put these in your .env.local, or make sure you don't commit!
|
||||||
# must be lowercase `true` when turned on
|
# must be lowercase `true` when turned on
|
||||||
#
|
#
|
||||||
# whether to enable Service Workers in development
|
|
||||||
VITE_APP_DEV_ENABLE_SW=
|
|
||||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||||
# debugging Service Workers.
|
# debugging Service Workers.
|
||||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ public/workbox
|
|||||||
packages/excalidraw/types
|
packages/excalidraw/types
|
||||||
examples/**/public
|
examples/**/public
|
||||||
dev-dist
|
dev-dist
|
||||||
|
coverage
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/mainMenu/DefaultItems.tsx) of the default items.
|
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/main-menu/DefaultItems.tsx) of the default items.
|
||||||
|
|
||||||
### MainMenu.Group
|
### MainMenu.Group
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
|||||||
getDimensions,<br/>
|
getDimensions,<br/>
|
||||||
files,<br/>
|
files,<br/>
|
||||||
exportPadding?: number;<br/>
|
exportPadding?: number;<br/>
|
||||||
}: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
|
}: <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
@@ -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"
|
||||||
|
|||||||
+2
-2
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+50
-1
@@ -120,7 +120,14 @@ import {
|
|||||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
|
import DebugCanvas, {
|
||||||
|
debugRenderer,
|
||||||
|
isVisualDebuggerEnabled,
|
||||||
|
loadSavedDebugState,
|
||||||
|
} 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();
|
||||||
|
|
||||||
@@ -326,6 +333,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
|
const activityRef = useRef<SaveWarningRef | null>(null);
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -337,6 +346,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
trackEvent("load", "frame", getFrame());
|
trackEvent("load", "frame", getFrame());
|
||||||
// Delayed so that the app has a time to load the latest SW
|
// Delayed so that the app has a time to load the latest SW
|
||||||
@@ -362,6 +373,23 @@ const ExcalidrawWrapper = () => {
|
|||||||
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
migrationAdapter: LibraryLocalStorageMigrationAdapter,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [, forceRefresh] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
const debugState = loadSavedDebugState();
|
||||||
|
|
||||||
|
if (debugState.enabled && !window.visualDebug) {
|
||||||
|
window.visualDebug = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete window.visualDebug;
|
||||||
|
}
|
||||||
|
forceRefresh((prev) => !prev);
|
||||||
|
}
|
||||||
|
}, [excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||||
return;
|
return;
|
||||||
@@ -591,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()) {
|
||||||
@@ -622,6 +652,16 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the debug scene if the debug canvas is available
|
||||||
|
if (debugCanvasRef.current && excalidrawAPI) {
|
||||||
|
debugRenderer(
|
||||||
|
debugCanvasRef.current,
|
||||||
|
appState,
|
||||||
|
window.devicePixelRatio,
|
||||||
|
() => forceRefresh((prev) => !prev),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||||
@@ -820,7 +860,9 @@ const ExcalidrawWrapper = () => {
|
|||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
theme={appTheme}
|
theme={appTheme}
|
||||||
setTheme={(theme) => setAppTheme(theme)}
|
setTheme={(theme) => setAppTheme(theme)}
|
||||||
|
refresh={() => forceRefresh((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
<SaveWarning ref={activityRef} />
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
@@ -845,7 +887,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
</OverwriteConfirmDialog.Action>
|
</OverwriteConfirmDialog.Action>
|
||||||
)}
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter />
|
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
||||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
@@ -1077,6 +1119,13 @@ const ExcalidrawWrapper = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
||||||
|
<DebugCanvas
|
||||||
|
appState={excalidrawAPI.getAppState()}
|
||||||
|
scale={window.devicePixelRatio}
|
||||||
|
ref={debugCanvasRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Excalidraw>
|
</Excalidraw>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
|
|||||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||||
|
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
|
||||||
VERSION_DATA_STATE: "version-dataState",
|
VERSION_DATA_STATE: "version-dataState",
|
||||||
VERSION_FILES: "version-files",
|
VERSION_FILES: "version-files",
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
|
|||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppFooter = React.memo(() => {
|
export const AppFooter = React.memo(
|
||||||
return (
|
({ onChange }: { onChange: () => void }) => {
|
||||||
<Footer>
|
return (
|
||||||
<div
|
<Footer>
|
||||||
style={{
|
<div
|
||||||
display: "flex",
|
style={{
|
||||||
gap: ".5rem",
|
display: "flex",
|
||||||
alignItems: "center",
|
gap: ".5rem",
|
||||||
}}
|
alignItems: "center",
|
||||||
>
|
}}
|
||||||
{isExcalidrawPlusSignedUser ? (
|
>
|
||||||
<ExcalidrawPlusAppLink />
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
) : (
|
{isExcalidrawPlusSignedUser ? (
|
||||||
<EncryptedIcon />
|
<ExcalidrawPlusAppLink />
|
||||||
)}
|
) : (
|
||||||
</div>
|
<EncryptedIcon />
|
||||||
</Footer>
|
)}
|
||||||
);
|
</div>
|
||||||
});
|
</Footer>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
loginIcon,
|
loginIcon,
|
||||||
ExcalLogo,
|
ExcalLogo,
|
||||||
|
eyeIcon,
|
||||||
} from "../../packages/excalidraw/components/icons";
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "../app-language/LanguageList";
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
|
import { saveDebugState } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
theme: Theme | "system";
|
theme: Theme | "system";
|
||||||
setTheme: (theme: Theme | "system") => void;
|
setTheme: (theme: Theme | "system") => void;
|
||||||
|
refresh: () => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
@@ -28,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 />
|
||||||
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
|
|||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<MainMenu.Item
|
||||||
|
icon={eyeIcon}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.visualDebug) {
|
||||||
|
delete window.visualDebug;
|
||||||
|
saveDebugState({ enabled: false });
|
||||||
|
} else {
|
||||||
|
window.visualDebug = { data: [] };
|
||||||
|
saveDebugState({ enabled: true });
|
||||||
|
}
|
||||||
|
props?.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Visual Debug
|
||||||
|
</MainMenu.Item>
|
||||||
|
)}
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme
|
<MainMenu.DefaultItems.ToggleTheme
|
||||||
allowSystemTheme
|
allowSystemTheme
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||||
|
import { type AppState } from "../../packages/excalidraw/types";
|
||||||
|
import { throttleRAF } from "../../packages/excalidraw/utils";
|
||||||
|
import {
|
||||||
|
bootstrapCanvas,
|
||||||
|
getNormalizedCanvasDimensions,
|
||||||
|
} from "../../packages/excalidraw/renderer/helpers";
|
||||||
|
import type { DebugElement } from "../../packages/excalidraw/visualdebug";
|
||||||
|
import {
|
||||||
|
ArrowheadArrowIcon,
|
||||||
|
CloseIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
import {
|
||||||
|
isLineSegment,
|
||||||
|
type GlobalPoint,
|
||||||
|
type LineSegment,
|
||||||
|
} from "../../packages/math";
|
||||||
|
|
||||||
|
const renderLine = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
zoom: number,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
color: string,
|
||||||
|
) => {
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = color;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
|
||||||
|
context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
|
||||||
|
context.stroke();
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||||
|
context.strokeStyle = "#888";
|
||||||
|
context.save();
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(-10 * zoom, -10 * zoom);
|
||||||
|
context.lineTo(10 * zoom, 10 * zoom);
|
||||||
|
context.moveTo(10 * zoom, -10 * zoom);
|
||||||
|
context.lineTo(-10 * zoom, 10 * zoom);
|
||||||
|
context.stroke();
|
||||||
|
context.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = (
|
||||||
|
frame: DebugElement[],
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
frame.forEach((el: DebugElement) => {
|
||||||
|
switch (true) {
|
||||||
|
case isLineSegment(el.data):
|
||||||
|
renderLine(
|
||||||
|
context,
|
||||||
|
appState.zoom.value,
|
||||||
|
el.data as LineSegment<GlobalPoint>,
|
||||||
|
el.color,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const _debugRenderer = (
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
appState: AppState,
|
||||||
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
|
) => {
|
||||||
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
|
canvas,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = bootstrapCanvas({
|
||||||
|
canvas,
|
||||||
|
scale,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
viewBackgroundColor: "transparent",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply zoom
|
||||||
|
context.save();
|
||||||
|
context.translate(
|
||||||
|
appState.scrollX * appState.zoom.value,
|
||||||
|
appState.scrollY * appState.zoom.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderOrigin(context, appState.zoom.value);
|
||||||
|
|
||||||
|
if (
|
||||||
|
window.visualDebug?.currentFrame &&
|
||||||
|
window.visualDebug?.data &&
|
||||||
|
window.visualDebug.data.length > 0
|
||||||
|
) {
|
||||||
|
// Render only one frame
|
||||||
|
const [idx] = debugFrameData();
|
||||||
|
|
||||||
|
render(window.visualDebug.data[idx], context, appState);
|
||||||
|
} else {
|
||||||
|
// Render all debug frames
|
||||||
|
window.visualDebug?.data.forEach((frame) => {
|
||||||
|
render(frame, context, appState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.visualDebug) {
|
||||||
|
window.visualDebug!.data =
|
||||||
|
window.visualDebug?.data.map((frame) =>
|
||||||
|
frame.filter((el) => el.permanent),
|
||||||
|
) ?? [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debugFrameData = (): [number, number] => {
|
||||||
|
const currentFrame = window.visualDebug?.currentFrame ?? 0;
|
||||||
|
const frameCount = window.visualDebug?.data.length ?? 0;
|
||||||
|
|
||||||
|
if (frameCount > 0) {
|
||||||
|
return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [0, 0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveDebugState = (debug: { enabled: boolean }) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||||
|
JSON.stringify(debug),
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debugRenderer = throttleRAF(
|
||||||
|
(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
appState: AppState,
|
||||||
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
|
) => {
|
||||||
|
_debugRenderer(canvas, appState, scale, refresh);
|
||||||
|
},
|
||||||
|
{ trailing: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const loadSavedDebugState = () => {
|
||||||
|
let debug;
|
||||||
|
try {
|
||||||
|
const savedDebugState = localStorage.getItem(
|
||||||
|
STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
|
||||||
|
);
|
||||||
|
if (savedDebugState) {
|
||||||
|
debug = JSON.parse(savedDebugState) as { enabled: boolean };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return debug ?? { enabled: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isVisualDebuggerEnabled = () =>
|
||||||
|
Array.isArray(window.visualDebug?.data);
|
||||||
|
|
||||||
|
export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||||
|
const moveForward = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!window.visualDebug?.currentFrame ||
|
||||||
|
isNaN(window.visualDebug?.currentFrame ?? -1)
|
||||||
|
) {
|
||||||
|
window.visualDebug!.currentFrame = 0;
|
||||||
|
}
|
||||||
|
window.visualDebug!.currentFrame += 1;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const moveBackward = useCallback(() => {
|
||||||
|
if (
|
||||||
|
!window.visualDebug?.currentFrame ||
|
||||||
|
isNaN(window.visualDebug?.currentFrame ?? -1) ||
|
||||||
|
window.visualDebug?.currentFrame < 1
|
||||||
|
) {
|
||||||
|
window.visualDebug!.currentFrame = 1;
|
||||||
|
}
|
||||||
|
window.visualDebug!.currentFrame -= 1;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
window.visualDebug!.currentFrame = undefined;
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
const trashFrames = useCallback(() => {
|
||||||
|
if (window.visualDebug) {
|
||||||
|
window.visualDebug.currentFrame = undefined;
|
||||||
|
window.visualDebug.data = [];
|
||||||
|
}
|
||||||
|
onChange();
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={trashFrames}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
{TrashIcon}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={moveBackward}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
<ArrowheadArrowIcon flip />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-forward"
|
||||||
|
aria-label="Move forward"
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
{CloseIcon}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ToolIcon_type_button"
|
||||||
|
data-testid="debug-backward"
|
||||||
|
aria-label="Move backward"
|
||||||
|
type="button"
|
||||||
|
onClick={moveForward}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ToolIcon__icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-disabled="false"
|
||||||
|
>
|
||||||
|
<ArrowheadArrowIcon />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DebugCanvasProps {
|
||||||
|
appState: AppState;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DebugCanvas = forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||||
|
({ appState, scale }, ref) => {
|
||||||
|
const { width, height } = appState;
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||||
|
ref,
|
||||||
|
() => canvasRef.current,
|
||||||
|
[canvasRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
position: "absolute",
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
width={width * scale}
|
||||||
|
height={height * scale}
|
||||||
|
ref={canvasRef}
|
||||||
|
>
|
||||||
|
Debug Canvas
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DebugCanvas;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -128,29 +128,6 @@
|
|||||||
<script>
|
<script>
|
||||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- in DEV we need to preload from the local server and without the hash -->
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
<!-- For Nunito only preload the latin range, which should be good enough for now -->
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
workbox: {
|
workbox: {
|
||||||
// Don't push fonts and locales to app precache
|
// Don't push fonts, locales and wasm to app precache
|
||||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||||
@@ -108,6 +108,17 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
urlPattern: new RegExp(".wasm-.+.js"),
|
||||||
|
handler: "CacheFirst",
|
||||||
|
options: {
|
||||||
|
cacheName: "wasm",
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
+9
-7
@@ -6,6 +6,7 @@
|
|||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/excalidraw",
|
"packages/excalidraw",
|
||||||
"packages/utils",
|
"packages/utils",
|
||||||
|
"packages/math",
|
||||||
"examples/excalidraw",
|
"examples/excalidraw",
|
||||||
"examples/excalidraw/*"
|
"examples/excalidraw/*"
|
||||||
],
|
],
|
||||||
@@ -20,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",
|
||||||
@@ -36,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"
|
||||||
@@ -82,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
|
|||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||||
appState,
|
try {
|
||||||
app.scene.getNonDeletedElementsMap(),
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
);
|
appState,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
if (!ret) {
|
return {
|
||||||
|
elements,
|
||||||
|
appState: newAppState,
|
||||||
|
storeAction: StoreAction.CAPTURE,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
appState: ret.appState,
|
|
||||||
storeAction: StoreAction.CAPTURE,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement } from "../element/mutateElement";
|
import { mutateElement } from "../element/mutateElement";
|
||||||
import { isPathALoop } from "../math";
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
|||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
|
import { point } from "../../math";
|
||||||
|
import { isPathALoop } from "../shapes";
|
||||||
|
|
||||||
export const actionFinalize = register({
|
export const actionFinalize = register({
|
||||||
name: "finalize",
|
name: "finalize",
|
||||||
@@ -112,10 +113,10 @@ export const actionFinalize = register({
|
|||||||
const linePoints = multiPointElement.points;
|
const linePoints = multiPointElement.points;
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
mutateElement(multiPointElement, {
|
mutateElement(multiPointElement, {
|
||||||
points: linePoints.map((point, index) =>
|
points: linePoints.map((p, index) =>
|
||||||
index === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? ([firstPoint[0], firstPoint[1]] as const)
|
? point(firstPoint[0], firstPoint[1])
|
||||||
: point,
|
: p,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -216,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { AppClassProperties, AppState, Point, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
import type { StoreActionType } from "../store";
|
import type { StoreActionType } from "../store";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||||
@@ -115,6 +115,8 @@ import {
|
|||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import type { LocalPoint } from "../../math";
|
||||||
|
import { point, vector } from "../../math";
|
||||||
|
|
||||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||||
|
|
||||||
@@ -850,7 +852,7 @@ export const actionChangeFontFamily = register({
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
ExcalidrawElement | null
|
ExcalidrawElement | null
|
||||||
>();
|
>();
|
||||||
let uniqueGlyphs = new Set<string>();
|
let uniqueChars = new Set<string>();
|
||||||
let skipFontFaceCheck = false;
|
let skipFontFaceCheck = false;
|
||||||
|
|
||||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||||
@@ -898,8 +900,8 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!skipFontFaceCheck) {
|
if (!skipFontFaceCheck) {
|
||||||
uniqueGlyphs = new Set([
|
uniqueChars = new Set([
|
||||||
...uniqueGlyphs,
|
...uniqueChars,
|
||||||
...Array.from(newElement.originalText),
|
...Array.from(newElement.originalText),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -919,12 +921,9 @@ export const actionChangeFontFamily = register({
|
|||||||
const fontString = `10px ${getFontFamilyString({
|
const fontString = `10px ${getFontFamilyString({
|
||||||
fontFamily: nextFontFamily,
|
fontFamily: nextFontFamily,
|
||||||
})}`;
|
})}`;
|
||||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
const chars = Array.from(uniqueChars.values()).join();
|
||||||
|
|
||||||
if (
|
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||||
skipFontFaceCheck ||
|
|
||||||
window.document.fonts.check(fontString, glyphs)
|
|
||||||
) {
|
|
||||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||||
for (const [element, container] of elementContainerMapping) {
|
for (const [element, container] of elementContainerMapping) {
|
||||||
// trigger synchronous redraw
|
// trigger synchronous redraw
|
||||||
@@ -936,8 +935,8 @@ export const actionChangeFontFamily = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||||
for (const [element, container] of elementContainerMapping) {
|
for (const [element, container] of elementContainerMapping) {
|
||||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||||
const latestElement = app.scene.getElement(element.id);
|
const latestElement = app.scene.getElement(element.id);
|
||||||
@@ -1651,10 +1650,10 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
[finalStartPoint, finalEndPoint].map(
|
[finalStartPoint, finalEndPoint].map(
|
||||||
(point) =>
|
(p): LocalPoint =>
|
||||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
point(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
),
|
),
|
||||||
[0, 0],
|
vector(0, 0),
|
||||||
{
|
{
|
||||||
...(startElement && newElement.startBinding
|
...(startElement && newElement.startBinding
|
||||||
? {
|
? {
|
||||||
@@ -1686,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,
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// place here categories that you want to track. We want to track just a
|
// place here categories that you want to track. We want to track just a
|
||||||
// small subset of categories at a given time.
|
// small subset of categories at a given time.
|
||||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette", "export"]);
|
||||||
|
|
||||||
export const trackEvent = (
|
export const trackEvent = (
|
||||||
category: string,
|
category: string,
|
||||||
|
|||||||
@@ -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 = <
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Radians } from "../math";
|
||||||
|
import { point } from "../math";
|
||||||
import {
|
import {
|
||||||
COLOR_PALETTE,
|
COLOR_PALETTE,
|
||||||
DEFAULT_CHART_COLOR_INDEX,
|
DEFAULT_CHART_COLOR_INDEX,
|
||||||
@@ -203,7 +205,7 @@ const chartXLabels = (
|
|||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
y: y + BAR_GAP / 2,
|
y: y + BAR_GAP / 2,
|
||||||
width: BAR_WIDTH,
|
width: BAR_WIDTH,
|
||||||
angle: 5.87,
|
angle: 5.87 as Radians,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
@@ -258,10 +260,7 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
points: [
|
points: [point(0, 0), point(chartWidth, 0)],
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const yLine = newLinearElement({
|
const yLine = newLinearElement({
|
||||||
@@ -272,10 +271,7 @@ const chartLines = (
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
height: chartHeight,
|
height: chartHeight,
|
||||||
points: [
|
points: [point(0, 0), point(0, -chartHeight)],
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxLine = newLinearElement({
|
const maxLine = newLinearElement({
|
||||||
@@ -288,10 +284,7 @@ const chartLines = (
|
|||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
width: chartWidth,
|
width: chartWidth,
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [
|
points: [point(0, 0), point(chartWidth, 0)],
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return [xLine, yLine, maxLine];
|
return [xLine, yLine, maxLine];
|
||||||
@@ -448,10 +441,7 @@ const chartTypeLine = (
|
|||||||
height: cy,
|
height: cy,
|
||||||
strokeStyle: "dotted",
|
strokeStyle: "dotted",
|
||||||
opacity: GRID_OPACITY,
|
opacity: GRID_OPACITY,
|
||||||
points: [
|
points: [point(0, 0), point(0, cy)],
|
||||||
[0, 0],
|
|
||||||
[0, cy],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from "./icons";
|
} from "./icons";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { useTunnels } from "../context/tunnels";
|
import { useTunnels } from "../context/tunnels";
|
||||||
|
import { CLASSES } from "../constants";
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
@@ -426,7 +427,7 @@ export const ZoomActions = ({
|
|||||||
renderAction: ActionManager["renderAction"];
|
renderAction: ActionManager["renderAction"];
|
||||||
zoom: Zoom;
|
zoom: Zoom;
|
||||||
}) => (
|
}) => (
|
||||||
<Stack.Col gap={1} className="zoom-actions">
|
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
||||||
<Stack.Row align="center">
|
<Stack.Row align="center">
|
||||||
{renderAction("zoomOut")}
|
{renderAction("zoomOut")}
|
||||||
{renderAction("resetZoom")}
|
{renderAction("resetZoom")}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -210,12 +211,6 @@ import {
|
|||||||
isElementCompletelyInViewport,
|
isElementCompletelyInViewport,
|
||||||
isElementInViewport,
|
isElementInViewport,
|
||||||
} from "../element/sizeHelpers";
|
} from "../element/sizeHelpers";
|
||||||
import {
|
|
||||||
distance2d,
|
|
||||||
getCornerRadius,
|
|
||||||
getGridPoint,
|
|
||||||
isPathALoop,
|
|
||||||
} from "../math";
|
|
||||||
import {
|
import {
|
||||||
calculateScrollCenter,
|
calculateScrollCenter,
|
||||||
getElementsWithinSelection,
|
getElementsWithinSelection,
|
||||||
@@ -230,7 +225,13 @@ import type {
|
|||||||
ScrollBars,
|
ScrollBars,
|
||||||
} from "../scene/types";
|
} from "../scene/types";
|
||||||
import { getStateForZoom } from "../scene/zoom";
|
import { getStateForZoom } from "../scene/zoom";
|
||||||
import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
|
import {
|
||||||
|
findShapeByKey,
|
||||||
|
getBoundTextShape,
|
||||||
|
getCornerRadius,
|
||||||
|
getElementShape,
|
||||||
|
isPathALoop,
|
||||||
|
} from "../shapes";
|
||||||
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
||||||
import { isPointInShape } from "../../utils/collision";
|
import { isPointInShape } from "../../utils/collision";
|
||||||
import type {
|
import type {
|
||||||
@@ -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,
|
||||||
@@ -386,6 +389,7 @@ import {
|
|||||||
getReferenceSnapPoints,
|
getReferenceSnapPoints,
|
||||||
SnapCache,
|
SnapCache,
|
||||||
isGridModeEnabled,
|
isGridModeEnabled,
|
||||||
|
getGridPoint,
|
||||||
} from "../snapping";
|
} from "../snapping";
|
||||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
@@ -433,12 +437,15 @@ 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 { point, pointDistance, vector } from "../../math";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@@ -545,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"];
|
||||||
@@ -552,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;
|
||||||
};
|
};
|
||||||
@@ -679,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();
|
||||||
@@ -1477,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();
|
||||||
|
|
||||||
@@ -2292,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
|
||||||
@@ -2357,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 =
|
||||||
@@ -2515,7 +2538,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
addEventListener(
|
addEventListener(
|
||||||
this.excalidrawContainerRef.current,
|
this.excalidrawContainerRef.current,
|
||||||
EVENT.WHEEL,
|
EVENT.WHEEL,
|
||||||
this.onWheel,
|
this.handleWheel,
|
||||||
{ passive: false },
|
{ passive: false },
|
||||||
),
|
),
|
||||||
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
|
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
|
||||||
@@ -2578,6 +2601,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
addEventListener(window, EVENT.RESIZE, this.onResize, false),
|
addEventListener(window, EVENT.RESIZE, this.onResize, false),
|
||||||
addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
|
addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
|
||||||
addEventListener(window, EVENT.BLUR, this.onBlur, false),
|
addEventListener(window, EVENT.BLUR, this.onBlur, false),
|
||||||
|
addEventListener(
|
||||||
|
this.excalidrawContainerRef.current,
|
||||||
|
EVENT.WHEEL,
|
||||||
|
this.handleWheel,
|
||||||
|
),
|
||||||
addEventListener(
|
addEventListener(
|
||||||
this.excalidrawContainerRef.current,
|
this.excalidrawContainerRef.current,
|
||||||
EVENT.DRAG_OVER,
|
EVENT.DRAG_OVER,
|
||||||
@@ -3083,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;
|
||||||
@@ -3207,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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3519,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;
|
||||||
@@ -3536,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?.();
|
||||||
|
|
||||||
@@ -3549,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;
|
||||||
@@ -3666,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();
|
||||||
@@ -3788,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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3906,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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3962,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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4318,13 +4388,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
private onWheel = withBatchedUpdates((event: WheelEvent) => {
|
|
||||||
// prevent browser pinch zoom on DOM elements
|
|
||||||
if (!(event.target instanceof HTMLCanvasElement) && event.ctrlKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
|
||||||
if (event.key === KEYS.SPACE) {
|
if (event.key === KEYS.SPACE) {
|
||||||
if (this.state.viewModeEnabled) {
|
if (this.state.viewModeEnabled) {
|
||||||
@@ -4401,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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4846,7 +4910,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.getElementHitThreshold(),
|
this.getElementHitThreshold(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return isPointInShape([x, y], selectionShape);
|
return isPointInShape(point(x, y), selectionShape);
|
||||||
}
|
}
|
||||||
|
|
||||||
// take bound text element into consideration for hit collision as well
|
// take bound text element into consideration for hit collision as well
|
||||||
@@ -5037,7 +5101,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||||
groupIds: container?.groupIds ?? [],
|
groupIds: container?.groupIds ?? [],
|
||||||
lineHeight,
|
lineHeight,
|
||||||
angle: container?.angle ?? 0,
|
angle: container?.angle ?? (0 as Radians),
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5205,7 +5269,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
element,
|
element,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state,
|
this.state,
|
||||||
[scenePointer.x, scenePointer.y],
|
point(scenePointer.x, scenePointer.y),
|
||||||
this.device.editor.isMobile,
|
this.device.editor.isMobile,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -5216,11 +5280,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLCanvasElement>,
|
||||||
isTouchScreen: boolean,
|
isTouchScreen: boolean,
|
||||||
) => {
|
) => {
|
||||||
const draggedDistance = distance2d(
|
const draggedDistance = pointDistance(
|
||||||
this.lastPointerDownEvent!.clientX,
|
point(
|
||||||
this.lastPointerDownEvent!.clientY,
|
this.lastPointerDownEvent!.clientX,
|
||||||
this.lastPointerUpEvent!.clientX,
|
this.lastPointerDownEvent!.clientY,
|
||||||
this.lastPointerUpEvent!.clientY,
|
),
|
||||||
|
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!this.hitLinkElement ||
|
!this.hitLinkElement ||
|
||||||
@@ -5239,7 +5304,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.hitLinkElement,
|
this.hitLinkElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
this.state,
|
this.state,
|
||||||
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||||
this.device.editor.isMobile,
|
this.device.editor.isMobile,
|
||||||
);
|
);
|
||||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||||
@@ -5250,7 +5315,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.hitLinkElement,
|
this.hitLinkElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
this.state,
|
this.state,
|
||||||
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||||
this.device.editor.isMobile,
|
this.device.editor.isMobile,
|
||||||
);
|
);
|
||||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||||
@@ -5499,17 +5564,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// if we haven't yet created a temp point and we're beyond commit-zone
|
// if we haven't yet created a temp point and we're beyond commit-zone
|
||||||
// threshold, add a point
|
// threshold, add a point
|
||||||
if (
|
if (
|
||||||
distance2d(
|
pointDistance(
|
||||||
scenePointerX - rx,
|
point(scenePointerX - rx, scenePointerY - ry),
|
||||||
scenePointerY - ry,
|
lastPoint,
|
||||||
lastPoint[0],
|
|
||||||
lastPoint[1],
|
|
||||||
) >= LINE_CONFIRM_THRESHOLD
|
) >= LINE_CONFIRM_THRESHOLD
|
||||||
) {
|
) {
|
||||||
mutateElement(
|
mutateElement(
|
||||||
multiElement,
|
multiElement,
|
||||||
{
|
{
|
||||||
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
|
points: [
|
||||||
|
...points,
|
||||||
|
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -5521,11 +5587,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} else if (
|
} else if (
|
||||||
points.length > 2 &&
|
points.length > 2 &&
|
||||||
lastCommittedPoint &&
|
lastCommittedPoint &&
|
||||||
distance2d(
|
pointDistance(
|
||||||
scenePointerX - rx,
|
point(scenePointerX - rx, scenePointerY - ry),
|
||||||
scenePointerY - ry,
|
lastCommittedPoint,
|
||||||
lastCommittedPoint[0],
|
|
||||||
lastCommittedPoint[1],
|
|
||||||
) < LINE_CONFIRM_THRESHOLD
|
) < LINE_CONFIRM_THRESHOLD
|
||||||
) {
|
) {
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||||
@@ -5572,10 +5636,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
[
|
[
|
||||||
...points.slice(0, -1),
|
...points.slice(0, -1),
|
||||||
[
|
point<LocalPoint>(
|
||||||
lastCommittedX + dxFromLastCommitted,
|
lastCommittedX + dxFromLastCommitted,
|
||||||
lastCommittedY + dyFromLastCommitted,
|
lastCommittedY + dyFromLastCommitted,
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -5591,10 +5655,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
{
|
{
|
||||||
points: [
|
points: [
|
||||||
...points.slice(0, -1),
|
...points.slice(0, -1),
|
||||||
[
|
point<LocalPoint>(
|
||||||
lastCommittedX + dxFromLastCommitted,
|
lastCommittedX + dxFromLastCommitted,
|
||||||
lastCommittedY + dyFromLastCommitted,
|
lastCommittedY + dyFromLastCommitted,
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
@@ -5819,17 +5883,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const distance = distance2d(
|
const distance = pointDistance(
|
||||||
pointerDownState.lastCoords.x,
|
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||||
pointerDownState.lastCoords.y,
|
point(scenePointer.x, scenePointer.y),
|
||||||
scenePointer.x,
|
|
||||||
scenePointer.y,
|
|
||||||
);
|
);
|
||||||
const threshold = this.getElementHitThreshold();
|
const threshold = this.getElementHitThreshold();
|
||||||
const point = { ...pointerDownState.lastCoords };
|
const p = { ...pointerDownState.lastCoords };
|
||||||
let samplingInterval = 0;
|
let samplingInterval = 0;
|
||||||
while (samplingInterval <= distance) {
|
while (samplingInterval <= distance) {
|
||||||
const hitElements = this.getElementsAtPosition(point.x, point.y);
|
const hitElements = this.getElementsAtPosition(p.x, p.y);
|
||||||
processElements(hitElements);
|
processElements(hitElements);
|
||||||
|
|
||||||
// Exit since we reached current point
|
// Exit since we reached current point
|
||||||
@@ -5841,12 +5903,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
samplingInterval = Math.min(samplingInterval + threshold, distance);
|
samplingInterval = Math.min(samplingInterval + threshold, distance);
|
||||||
|
|
||||||
const distanceRatio = samplingInterval / distance;
|
const distanceRatio = samplingInterval / distance;
|
||||||
const nextX =
|
const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
|
||||||
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
|
const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
|
||||||
const nextY =
|
p.x = nextX;
|
||||||
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
|
p.y = nextY;
|
||||||
point.x = nextX;
|
|
||||||
point.y = nextY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pointerDownState.lastCoords.x = scenePointer.x;
|
pointerDownState.lastCoords.x = scenePointer.x;
|
||||||
@@ -5972,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
|
||||||
@@ -6327,7 +6397,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.hitLinkElement,
|
this.hitLinkElement,
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
this.state,
|
this.state,
|
||||||
[scenePointer.x, scenePointer.y],
|
point(scenePointer.x, scenePointer.y),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.handleEmbeddableCenterClick(this.hitLinkElement);
|
this.handleEmbeddableCenterClick(this.hitLinkElement);
|
||||||
@@ -6384,8 +6454,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Returns whether the event is a panning
|
// Returns whether the event is a panning
|
||||||
private handleCanvasPanUsingWheelOrSpaceDrag = (
|
public handleCanvasPanUsingWheelOrSpaceDrag = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement> | MouseEvent,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
@@ -6394,13 +6464,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||||
isHandToolActive(this.state) ||
|
isHandToolActive(this.state) ||
|
||||||
this.state.viewModeEnabled)
|
this.state.viewModeEnabled)
|
||||||
) ||
|
)
|
||||||
this.state.editingTextElement
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
isPanning = true;
|
isPanning = true;
|
||||||
event.preventDefault();
|
|
||||||
|
// 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) {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
let nextPastePrevented = false;
|
let nextPastePrevented = false;
|
||||||
const isLinux =
|
const isLinux =
|
||||||
@@ -7007,7 +7088,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
simulatePressure,
|
simulatePressure,
|
||||||
locked: false,
|
locked: false,
|
||||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||||
points: [[0, 0]],
|
points: [point<LocalPoint>(0, 0)],
|
||||||
pressures: simulatePressure ? [] : [event.pressure],
|
pressures: simulatePressure ? [] : [event.pressure],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7215,11 +7296,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (
|
if (
|
||||||
multiElement.points.length > 1 &&
|
multiElement.points.length > 1 &&
|
||||||
lastCommittedPoint &&
|
lastCommittedPoint &&
|
||||||
distance2d(
|
pointDistance(
|
||||||
pointerDownState.origin.x - rx,
|
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
|
||||||
pointerDownState.origin.y - ry,
|
lastCommittedPoint,
|
||||||
lastCommittedPoint[0],
|
|
||||||
lastCommittedPoint[1],
|
|
||||||
) < LINE_CONFIRM_THRESHOLD
|
) < LINE_CONFIRM_THRESHOLD
|
||||||
) {
|
) {
|
||||||
this.actionManager.executeAction(actionFinalize);
|
this.actionManager.executeAction(actionFinalize);
|
||||||
@@ -7320,7 +7399,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [...element.points, [0, 0]],
|
points: [...element.points, point<LocalPoint>(0, 0)],
|
||||||
});
|
});
|
||||||
const boundElement = getHoveredElementForBinding(
|
const boundElement = getHoveredElementForBinding(
|
||||||
pointerDownState.origin,
|
pointerDownState.origin,
|
||||||
@@ -7572,11 +7651,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.state.activeTool.type === "line")
|
this.state.activeTool.type === "line")
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
distance2d(
|
pointDistance(
|
||||||
pointerCoords.x,
|
point(pointerCoords.x, pointerCoords.y),
|
||||||
pointerCoords.y,
|
point(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||||
pointerDownState.origin.x,
|
|
||||||
pointerDownState.origin.y,
|
|
||||||
) < DRAGGING_THRESHOLD
|
) < DRAGGING_THRESHOLD
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -7925,7 +8002,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElement(
|
mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
points: [...points, [dx, dy]],
|
points: [...points, point<LocalPoint>(dx, dy)],
|
||||||
pressures,
|
pressures,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
@@ -7954,7 +8031,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElement(
|
mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
points: [...points, [dx, dy]],
|
points: [...points, point<LocalPoint>(dx, dy)],
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -7962,8 +8039,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElbowArrow(
|
mutateElbowArrow(
|
||||||
newElement,
|
newElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
[...points.slice(0, -1), [dx, dy]],
|
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||||
[0, 0],
|
vector(0, 0),
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
@@ -7974,7 +8051,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElement(
|
mutateElement(
|
||||||
newElement,
|
newElement,
|
||||||
{
|
{
|
||||||
points: [...points.slice(0, -1), [dx, dy]],
|
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -8283,9 +8360,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
: [...newElement.pressures, childEvent.pressure];
|
: [...newElement.pressures, childEvent.pressure];
|
||||||
|
|
||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
points: [...points, [dx, dy]],
|
points: [...points, point<LocalPoint>(dx, dy)],
|
||||||
pressures,
|
pressures,
|
||||||
lastCommittedPoint: [dx, dy],
|
lastCommittedPoint: point<LocalPoint>(dx, dy),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.actionManager.executeAction(actionFinalize);
|
this.actionManager.executeAction(actionFinalize);
|
||||||
@@ -8332,7 +8409,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
mutateElement(newElement, {
|
mutateElement(newElement, {
|
||||||
points: [
|
points: [
|
||||||
...newElement.points,
|
...newElement.points,
|
||||||
[pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
|
point<LocalPoint>(
|
||||||
|
pointerCoords.x - newElement.x,
|
||||||
|
pointerCoords.y - newElement.y,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -8642,11 +8722,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
||||||
this.eraserTrail.endPath();
|
this.eraserTrail.endPath();
|
||||||
|
|
||||||
const draggedDistance = distance2d(
|
const draggedDistance = pointDistance(
|
||||||
pointerStart.clientX,
|
point(pointerStart.clientX, pointerStart.clientY),
|
||||||
pointerStart.clientY,
|
point(pointerEnd.clientX, pointerEnd.clientY),
|
||||||
pointerEnd.clientX,
|
|
||||||
pointerEnd.clientY,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (draggedDistance === 0) {
|
if (draggedDistance === 0) {
|
||||||
@@ -9472,7 +9550,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// NOTE wheel, touchstart, touchend events must be registered outside
|
// NOTE wheel, touchstart, touchend events must be registered outside
|
||||||
// of react because react binds them them passively (so we can't prevent
|
// of react because react binds them them passively (so we can't prevent
|
||||||
// default on them)
|
// default on them)
|
||||||
this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel);
|
|
||||||
this.interactiveCanvas.addEventListener(
|
this.interactiveCanvas.addEventListener(
|
||||||
EVENT.TOUCH_START,
|
EVENT.TOUCH_START,
|
||||||
this.onTouchStart,
|
this.onTouchStart,
|
||||||
@@ -9480,10 +9557,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
|
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
} else {
|
} else {
|
||||||
this.interactiveCanvas?.removeEventListener(
|
|
||||||
EVENT.WHEEL,
|
|
||||||
this.handleWheel,
|
|
||||||
);
|
|
||||||
this.interactiveCanvas?.removeEventListener(
|
this.interactiveCanvas?.removeEventListener(
|
||||||
EVENT.TOUCH_START,
|
EVENT.TOUCH_START,
|
||||||
this.onTouchStart,
|
this.onTouchStart,
|
||||||
@@ -10078,7 +10151,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
(
|
(
|
||||||
event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
|
event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
|
||||||
) => {
|
) => {
|
||||||
|
// if not scrolling on canvas/wysiwyg, ignore
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
event.target instanceof HTMLCanvasElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement ||
|
||||||
|
event.target instanceof HTMLIFrameElement
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// prevent zooming the browser (but allow scrolling DOM)
|
||||||
|
if (event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (isPanning) {
|
if (isPanning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
|
|||||||
return (
|
return (
|
||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "208px" }}
|
style={{ maxWidth: "13rem" }}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
focusPickerContent();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
|
@keyframes successStatusAnimation {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExcButton {
|
.ExcButton {
|
||||||
--text-color: transparent;
|
--text-color: transparent;
|
||||||
@@ -22,9 +36,10 @@
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
width: 1rem;
|
width: 1.2rem;
|
||||||
height: 1rem;
|
height: 1.2rem;
|
||||||
font-size: 1rem;
|
|
||||||
|
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.ExcButton--status-loading,
|
&.ExcButton--status-loading,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,20 @@ const ImageExportModal = ({
|
|||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const { onCopy, copyStatus } = useCopyStatus();
|
const { onCopy, copyStatus, resetCopyStatus } = useCopyStatus();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// if user changes setting right after export to clipboard, reset the status
|
||||||
|
// so they don't have to wait for the timeout to click the button again
|
||||||
|
resetCopyStatus();
|
||||||
|
}, [
|
||||||
|
projectName,
|
||||||
|
exportWithBackground,
|
||||||
|
exportDarkMode,
|
||||||
|
exportScale,
|
||||||
|
embedScene,
|
||||||
|
resetCopyStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
const { exportedElements, exportingFrame } = prepareElementsForExport(
|
||||||
elementsSnapshot,
|
elementsSnapshot,
|
||||||
@@ -108,6 +121,7 @@ const ImageExportModal = ({
|
|||||||
if (!maxWidth) {
|
if (!maxWidth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
exportToCanvas({
|
exportToCanvas({
|
||||||
elements: exportedElements,
|
elements: exportedElements,
|
||||||
appState: {
|
appState: {
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
|
|||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
},
|
},
|
||||||
files: null,
|
files: null,
|
||||||
|
skipInliningFonts: true,
|
||||||
});
|
});
|
||||||
node.innerHTML = svg.outerHTML;
|
node.innerHTML = svg.outerHTML;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
|
|||||||
import { getBoundTextElement } from "../../element/textElement";
|
import { getBoundTextElement } from "../../element/textElement";
|
||||||
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { degreeToRadian, radianToDegree } from "../../math";
|
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
import type { Degrees } from "../../../math";
|
||||||
|
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
||||||
|
|
||||||
interface AngleProps {
|
interface AngleProps {
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextAngle = degreeToRadian(nextValue);
|
const nextAngle = degreesToRadians(nextValue as Degrees);
|
||||||
mutateElement(latestElement, {
|
mutateElement(latestElement, {
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
});
|
});
|
||||||
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const originalAngleInDegrees =
|
const originalAngleInDegrees =
|
||||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
if (shouldChangeByStepSize) {
|
if (shouldChangeByStepSize) {
|
||||||
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
|||||||
nextAngleInDegrees =
|
nextAngleInDegrees =
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
||||||
|
|
||||||
mutateElement(latestElement, {
|
mutateElement(latestElement, {
|
||||||
angle: nextAngle,
|
angle: nextAngle,
|
||||||
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
|||||||
<DragInput
|
<DragInput
|
||||||
label="A"
|
label="A"
|
||||||
icon={angleIcon}
|
icon={angleIcon}
|
||||||
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
|
||||||
elements={[element]}
|
elements={[element]}
|
||||||
dragInputCallback={handleDegreeChange}
|
dragInputCallback={handleDegreeChange}
|
||||||
editable={isPropertyEditable(element, "angle")}
|
editable={isPropertyEditable(element, "angle")}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
|
|||||||
import { isArrowElement } from "../../element/typeChecks";
|
import { isArrowElement } from "../../element/typeChecks";
|
||||||
import type { ExcalidrawElement } from "../../element/types";
|
import type { ExcalidrawElement } from "../../element/types";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
import { degreeToRadian, radianToDegree } from "../../math";
|
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import { angleIcon } from "../icons";
|
import { angleIcon } from "../icons";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
import type { Degrees } from "../../../math";
|
||||||
|
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
||||||
|
|
||||||
interface MultiAngleProps {
|
interface MultiAngleProps {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextAngle = degreeToRadian(nextValue);
|
const nextAngle = degreesToRadians(nextValue as Degrees);
|
||||||
|
|
||||||
for (const element of editableLatestIndividualElements) {
|
for (const element of editableLatestIndividualElements) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
}
|
}
|
||||||
const originalElement = editableOriginalIndividualElements[i];
|
const originalElement = editableOriginalIndividualElements[i];
|
||||||
const originalAngleInDegrees =
|
const originalAngleInDegrees =
|
||||||
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
|
||||||
const changeInDegrees = Math.round(accumulatedChange);
|
const changeInDegrees = Math.round(accumulatedChange);
|
||||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||||
if (shouldChangeByStepSize) {
|
if (shouldChangeByStepSize) {
|
||||||
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
|
|||||||
nextAngleInDegrees =
|
nextAngleInDegrees =
|
||||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||||
|
|
||||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
latestElement,
|
latestElement,
|
||||||
@@ -109,7 +110,7 @@ const MultiAngle = ({
|
|||||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||||
);
|
);
|
||||||
const angles = editableLatestIndividualElements.map(
|
const angles = editableLatestIndividualElements.map(
|
||||||
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
|
(el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
|
||||||
);
|
);
|
||||||
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import type {
|
|||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState, Point } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||||
|
import { point, type GlobalPoint } from "../../../math";
|
||||||
|
|
||||||
interface MultiDimensionProps {
|
interface MultiDimensionProps {
|
||||||
property: "width" | "height";
|
property: "width" | "height";
|
||||||
@@ -104,7 +105,7 @@ const resizeGroup = (
|
|||||||
nextHeight: number,
|
nextHeight: number,
|
||||||
initialHeight: number,
|
initialHeight: number,
|
||||||
aspectRatio: number,
|
aspectRatio: number,
|
||||||
anchor: Point,
|
anchor: GlobalPoint,
|
||||||
property: MultiDimensionProps["property"],
|
property: MultiDimensionProps["property"],
|
||||||
latestElements: ExcalidrawElement[],
|
latestElements: ExcalidrawElement[],
|
||||||
originalElements: ExcalidrawElement[],
|
originalElements: ExcalidrawElement[],
|
||||||
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
[x1, y1],
|
point(x1, y1),
|
||||||
property,
|
property,
|
||||||
latestElements,
|
latestElements,
|
||||||
originalElements,
|
originalElements,
|
||||||
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextHeight,
|
nextHeight,
|
||||||
initialHeight,
|
initialHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
[x1, y1],
|
point(x1, y1),
|
||||||
property,
|
property,
|
||||||
latestElements,
|
latestElements,
|
||||||
originalElements,
|
originalElements,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
@@ -14,6 +13,7 @@ import { useMemo } from "react";
|
|||||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
import { point, pointRotateRads } from "../../../math";
|
||||||
|
|
||||||
interface MultiPositionProps {
|
interface MultiPositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -43,11 +43,9 @@ const moveElements = (
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
origElement.x,
|
point(origElement.x, origElement.y),
|
||||||
origElement.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,11 +96,9 @@ const moveGroupTo = (
|
|||||||
latestElement.y + latestElement.height / 2,
|
latestElement.y + latestElement.height / 2,
|
||||||
];
|
];
|
||||||
|
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
latestElement.x,
|
point(latestElement.x, latestElement.y),
|
||||||
latestElement.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
latestElement.angle,
|
latestElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
origElement.x,
|
point(origElement.x, origElement.y),
|
||||||
origElement.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -246,7 +240,11 @@ const MultiPosition = ({
|
|||||||
const [el] = elementsInUnit;
|
const [el] = elementsInUnit;
|
||||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||||
|
|
||||||
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
|
point(el.x, el.y),
|
||||||
|
point(cx, cy),
|
||||||
|
el.angle,
|
||||||
|
);
|
||||||
|
|
||||||
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
|
||||||
import StatsDragInput from "./DragInput";
|
import StatsDragInput from "./DragInput";
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type { DragInputCallbackType } from "./DragInput";
|
||||||
import { getStepSizedValue, moveElement } from "./utils";
|
import { getStepSizedValue, moveElement } from "./utils";
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
import { point, pointRotateRads } from "../../../math";
|
||||||
|
|
||||||
interface PositionProps {
|
interface PositionProps {
|
||||||
property: "x" | "y";
|
property: "x" | "y";
|
||||||
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
|||||||
origElement.x + origElement.width / 2,
|
origElement.x + origElement.width / 2,
|
||||||
origElement.y + origElement.height / 2,
|
origElement.y + origElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
origElement.x,
|
point(origElement.x, origElement.y),
|
||||||
origElement.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
origElement.angle,
|
origElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,11 +92,9 @@ const Position = ({
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
}: PositionProps) => {
|
}: PositionProps) => {
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
element.x,
|
point(element.x, element.y),
|
||||||
element.y,
|
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const value =
|
const value =
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ import type {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { degreeToRadian, rotate } from "../../math";
|
|
||||||
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||||
import { getCommonBounds, isTextElement } from "../../element";
|
import { getCommonBounds, isTextElement } from "../../element";
|
||||||
import { API } from "../../tests/helpers/api";
|
import { API } from "../../tests/helpers/api";
|
||||||
import { actionGroup } from "../../actions";
|
import { actionGroup } from "../../actions";
|
||||||
import { isInGroup } from "../../groups";
|
import { isInGroup } from "../../groups";
|
||||||
|
import type { Degrees } from "../../../math";
|
||||||
|
import { degreesToRadians, point, pointRotateRads } from "../../../math";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
@@ -46,7 +47,9 @@ const testInputProperty = (
|
|||||||
expect(input.value).toBe(initialValue.toString());
|
expect(input.value).toBe(initialValue.toString());
|
||||||
UI.updateInput(input, String(nextValue));
|
UI.updateInput(input, String(nextValue));
|
||||||
if (property === "angle") {
|
if (property === "angle") {
|
||||||
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
expect(element[property]).toBe(
|
||||||
|
degreesToRadians(Number(nextValue) as Degrees),
|
||||||
|
);
|
||||||
} else if (property === "fontSize" && isTextElement(element)) {
|
} else if (property === "fontSize" && isTextElement(element)) {
|
||||||
expect(element[property]).toBe(Number(nextValue));
|
expect(element[property]).toBe(Number(nextValue));
|
||||||
} else if (property !== "fontSize") {
|
} else if (property !== "fontSize") {
|
||||||
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||||
|
|
||||||
let [newTopLeftX, newTopLeftY] = rotate(
|
let [newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
|
|||||||
|
|
||||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||||
|
|
||||||
[newTopLeftX, newTopLeftY] = rotate(
|
[newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||||
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||||
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
let [currentTopLeftX, currentTopLeftY] = rotate(
|
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||||
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
];
|
];
|
||||||
[currentTopLeftX, currentTopLeftY] = rotate(
|
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||||
rectangle.x,
|
point(rectangle.x, rectangle.y),
|
||||||
rectangle.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
rectangle.angle,
|
rectangle.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
|
|||||||
|
|
||||||
UI.updateInput(angle, "40");
|
UI.updateInput(angle, "40");
|
||||||
|
|
||||||
const angleInRadian = degreeToRadian(40);
|
const angleInRadian = degreesToRadians(40 as Degrees);
|
||||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
||||||
expect(frame.angle).toBe(0);
|
expect(frame.angle).toBe(0);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Radians } from "../../../math";
|
||||||
|
import { point, pointRotateRads } from "../../../math";
|
||||||
import {
|
import {
|
||||||
bindOrUnbindLinearElements,
|
bindOrUnbindLinearElements,
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
@@ -30,7 +32,6 @@ import {
|
|||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
isInGroup,
|
isInGroup,
|
||||||
} from "../../groups";
|
} from "../../groups";
|
||||||
import { rotate } from "../../math";
|
|
||||||
import type Scene from "../../scene/Scene";
|
import type Scene from "../../scene/Scene";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
import { getFontString } from "../../utils";
|
import { getFontString } from "../../utils";
|
||||||
@@ -229,23 +230,19 @@ export const moveElement = (
|
|||||||
originalElement.x + originalElement.width / 2,
|
originalElement.x + originalElement.width / 2,
|
||||||
originalElement.y + originalElement.height / 2,
|
originalElement.y + originalElement.height / 2,
|
||||||
];
|
];
|
||||||
const [topLeftX, topLeftY] = rotate(
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
originalElement.x,
|
point(originalElement.x, originalElement.y),
|
||||||
originalElement.y,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
originalElement.angle,
|
originalElement.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
const changeInX = newTopLeftX - topLeftX;
|
const changeInX = newTopLeftX - topLeftX;
|
||||||
const changeInY = newTopLeftY - topLeftY;
|
const changeInY = newTopLeftY - topLeftY;
|
||||||
|
|
||||||
const [x, y] = rotate(
|
const [x, y] = pointRotateRads(
|
||||||
newTopLeftX,
|
point(newTopLeftX, newTopLeftY),
|
||||||
newTopLeftY,
|
point(cx + changeInX, cy + changeInY),
|
||||||
cx + changeInX,
|
-originalElement.angle as Radians,
|
||||||
cy + changeInY,
|
|
||||||
-originalElement.angle,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
mutateElement(
|
mutateElement(
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
|
|||||||
import { ArrowRightIcon } from "../icons";
|
import { ArrowRightIcon } from "../icons";
|
||||||
|
|
||||||
import "./TTDDialog.scss";
|
import "./TTDDialog.scss";
|
||||||
import { isFiniteNumber } from "../../utils";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||||
|
import { isFiniteNumber } from "../../../math";
|
||||||
|
|
||||||
const MIN_PROMPT_LENGTH = 3;
|
const MIN_PROMPT_LENGTH = 3;
|
||||||
const MAX_PROMPT_LENGTH = 1000;
|
const MAX_PROMPT_LENGTH = 1000;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
|
import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
|
||||||
import {
|
import {
|
||||||
sceneCoordsToViewportCoords,
|
sceneCoordsToViewportCoords,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
|
|||||||
import { useAppProps, useExcalidrawAppState } from "../App";
|
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||||
import { getLinkHandleFromCoords } from "./helpers";
|
import { getLinkHandleFromCoords } from "./helpers";
|
||||||
|
import { point, type GlobalPoint } from "../../../math";
|
||||||
|
|
||||||
const CONTAINER_WIDTH = 320;
|
const CONTAINER_WIDTH = 320;
|
||||||
const SPACE_BOTTOM = 85;
|
const SPACE_BOTTOM = 85;
|
||||||
@@ -176,10 +177,12 @@ export const Hyperlink = ({
|
|||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
|
const shouldHide = shouldHideLinkPopup(
|
||||||
event.clientX,
|
element,
|
||||||
event.clientY,
|
elementsMap,
|
||||||
]) as boolean;
|
appState,
|
||||||
|
point(event.clientX, event.clientY),
|
||||||
|
) as boolean;
|
||||||
if (shouldHide) {
|
if (shouldHide) {
|
||||||
timeoutId = window.setTimeout(() => {
|
timeoutId = window.setTimeout(() => {
|
||||||
setAppState({ showHyperlinkPopup: false });
|
setAppState({ showHyperlinkPopup: false });
|
||||||
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[clientX, clientY]: Point,
|
[clientX, clientY]: GlobalPoint,
|
||||||
): Boolean => {
|
): Boolean => {
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
{ clientX, clientY },
|
{ clientX, clientY },
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GlobalPoint, Radians } from "../../../math";
|
||||||
|
import { point, pointRotateRads } from "../../../math";
|
||||||
import { MIME_TYPES } from "../../constants";
|
import { MIME_TYPES } from "../../constants";
|
||||||
import type { Bounds } from "../../element/bounds";
|
import type { Bounds } from "../../element/bounds";
|
||||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||||
@@ -6,9 +8,8 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { rotate } from "../../math";
|
|
||||||
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
|
||||||
import type { AppState, Point, UIAppState } from "../../types";
|
import type { AppState, UIAppState } from "../../types";
|
||||||
|
|
||||||
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
export const EXTERNAL_LINK_IMG = document.createElement("img");
|
||||||
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
||||||
@@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
|||||||
|
|
||||||
export const getLinkHandleFromCoords = (
|
export const getLinkHandleFromCoords = (
|
||||||
[x1, y1, x2, y2]: Bounds,
|
[x1, y1, x2, y2]: Bounds,
|
||||||
angle: number,
|
angle: Radians,
|
||||||
appState: Pick<UIAppState, "zoom">,
|
appState: Pick<UIAppState, "zoom">,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
const size = DEFAULT_LINK_SIZE;
|
const size = DEFAULT_LINK_SIZE;
|
||||||
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
|
|||||||
const x = x2 + dashedLineMargin - centeringOffset;
|
const x = x2 + dashedLineMargin - centeringOffset;
|
||||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||||
|
|
||||||
const [rotatedX, rotatedY] = rotate(
|
const [rotatedX, rotatedY] = pointRotateRads(
|
||||||
x + linkWidth / 2,
|
point(x + linkWidth / 2, y + linkHeight / 2),
|
||||||
y + linkHeight / 2,
|
point(centerX, centerY),
|
||||||
centerX,
|
|
||||||
centerY,
|
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[x, y]: Point,
|
[x, y]: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const threshold = 4 / appState.zoom.value;
|
const threshold = 4 / appState.zoom.value;
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
|
|||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
[x, y]: Point,
|
[x, y]: GlobalPoint,
|
||||||
isMobile: boolean,
|
isMobile: boolean,
|
||||||
) => {
|
) => {
|
||||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||||
@@ -86,5 +85,5 @@ export const isPointHittingLink = (
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
|
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ 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",
|
||||||
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,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",
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
|
|||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
width: 200px;
|
width: 12.5rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -40,11 +42,7 @@ import {
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
import { bumpVersion } from "../element/mutateElement";
|
import { bumpVersion } from "../element/mutateElement";
|
||||||
import {
|
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||||
getUpdatedTimestamp,
|
|
||||||
isFiniteNumber,
|
|
||||||
updateActiveTool,
|
|
||||||
} from "../utils";
|
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import type { MarkOptional, Mutable } from "../utility-types";
|
import type { MarkOptional, Mutable } from "../utility-types";
|
||||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||||
@@ -58,6 +56,8 @@ import {
|
|||||||
getNormalizedGridStep,
|
getNormalizedGridStep,
|
||||||
getNormalizedZoom,
|
getNormalizedZoom,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
|
import { isFiniteNumber, point } from "../../math";
|
||||||
|
|
||||||
type RestoredAppState = Omit<
|
type RestoredAppState = Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -103,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;
|
||||||
}
|
}
|
||||||
@@ -112,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]),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ const restoreElementWithProperties = <
|
|||||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||||
opacity:
|
opacity:
|
||||||
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
|
||||||
angle: element.angle || 0,
|
angle: element.angle || (0 as Radians),
|
||||||
x: extra.x ?? element.x ?? 0,
|
x: extra.x ?? element.x ?? 0,
|
||||||
y: extra.y ?? element.y ?? 0,
|
y: extra.y ?? element.y ?? 0,
|
||||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||||
@@ -266,10 +268,7 @@ const restoreElement = (
|
|||||||
let y = element.y;
|
let y = element.y;
|
||||||
let points = // migrate old arrow model to new one
|
let points = // migrate old arrow model to new one
|
||||||
!Array.isArray(element.points) || element.points.length < 2
|
!Array.isArray(element.points) || element.points.length < 2
|
||||||
? [
|
? [point(0, 0), point(element.width, element.height)]
|
||||||
[0, 0],
|
|
||||||
[element.width, element.height],
|
|
||||||
]
|
|
||||||
: element.points;
|
: element.points;
|
||||||
|
|
||||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||||
@@ -293,14 +292,11 @@ const restoreElement = (
|
|||||||
});
|
});
|
||||||
case "arrow": {
|
case "arrow": {
|
||||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||||
let x = element.x;
|
let x: number | undefined = element.x;
|
||||||
let y = element.y;
|
let y: number | undefined = element.y;
|
||||||
let points = // migrate old arrow model to new one
|
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||||
!Array.isArray(element.points) || element.points.length < 2
|
!Array.isArray(element.points) || element.points.length < 2
|
||||||
? [
|
? [point(0, 0), point(element.width, element.height)]
|
||||||
[0, 0],
|
|
||||||
[element.width, element.height],
|
|
||||||
]
|
|
||||||
: element.points;
|
: element.points;
|
||||||
|
|
||||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { vi } from "vitest";
|
|||||||
import type { ExcalidrawElementSkeleton } from "./transform";
|
import type { ExcalidrawElementSkeleton } from "./transform";
|
||||||
import { convertToExcalidrawElements } from "./transform";
|
import { convertToExcalidrawElements } from "./transform";
|
||||||
import type { ExcalidrawArrowElement } from "../element/types";
|
import type { ExcalidrawArrowElement } from "../element/types";
|
||||||
|
import { point } from "../../math";
|
||||||
|
|
||||||
const opts = { regenerateIds: false };
|
const opts = { regenerateIds: false };
|
||||||
|
|
||||||
@@ -911,10 +912,7 @@ describe("Test Transform", () => {
|
|||||||
x: 111.262,
|
x: 111.262,
|
||||||
y: 57,
|
y: 57,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
points: [
|
points: [point(0, 0), point(272.985, 0)],
|
||||||
[0, 0],
|
|
||||||
[272.985, 0],
|
|
||||||
],
|
|
||||||
label: {
|
label: {
|
||||||
text: "How are you?",
|
text: "How are you?",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -937,7 +935,7 @@ describe("Test Transform", () => {
|
|||||||
x: 77.017,
|
x: 77.017,
|
||||||
y: 79,
|
y: 79,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
points: [[0, 0]],
|
points: [point(0, 0)],
|
||||||
label: {
|
label: {
|
||||||
text: "Friendship",
|
text: "Friendship",
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { randomId } from "../random";
|
|||||||
import { syncInvalidIndices } from "../fractionalIndex";
|
import { syncInvalidIndices } from "../fractionalIndex";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
import { isArrowElement } from "../element/typeChecks";
|
import { isArrowElement } from "../element/typeChecks";
|
||||||
|
import { point, type LocalPoint } from "../../math";
|
||||||
|
|
||||||
export type ValidLinearElement = {
|
export type ValidLinearElement = {
|
||||||
type: "arrow" | "line";
|
type: "arrow" | "line";
|
||||||
@@ -417,7 +418,7 @@ const bindLinearElementToElement = (
|
|||||||
const endPointIndex = linearElement.points.length - 1;
|
const endPointIndex = linearElement.points.length - 1;
|
||||||
const delta = 0.5;
|
const delta = 0.5;
|
||||||
|
|
||||||
const newPoints = cloneJSON(linearElement.points) as [number, number][];
|
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
|
||||||
|
|
||||||
// left to right so shift the arrow towards right
|
// left to right so shift the arrow towards right
|
||||||
if (
|
if (
|
||||||
@@ -535,10 +536,7 @@ export const convertToExcalidrawElements = (
|
|||||||
excalidrawElement = newLinearElement({
|
excalidrawElement = newLinearElement({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
points: [
|
points: [point(0, 0), point(width, height)],
|
||||||
[0, 0],
|
|
||||||
[width, height],
|
|
||||||
],
|
|
||||||
...element,
|
...element,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,10 +549,7 @@ export const convertToExcalidrawElements = (
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
endArrowhead: "arrow",
|
endArrowhead: "arrow",
|
||||||
points: [
|
points: [point(0, 0), point(width, height)],
|
||||||
[0, 0],
|
|
||||||
[width, height],
|
|
||||||
],
|
|
||||||
...element,
|
...element,
|
||||||
type: "arrow",
|
type: "arrow",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as GA from "../ga";
|
import * as GA from "../../math/ga/ga";
|
||||||
import * as GAPoint from "../gapoints";
|
import * as GAPoint from "../../math/ga/gapoints";
|
||||||
import * as GADirection from "../gadirections";
|
import * as GADirection from "../../math/ga/gadirections";
|
||||||
import * as GALine from "../galines";
|
import * as GALine from "../../math/ga/galines";
|
||||||
import * as GATransform from "../gatransforms";
|
import * as GATransform from "../../math/ga/gatransforms";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
ExcalidrawRectangleElement,
|
ExcalidrawRectangleElement,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawFrameLikeElement,
|
ExcalidrawFrameLikeElement,
|
||||||
ExcalidrawIframeLikeElement,
|
ExcalidrawIframeLikeElement,
|
||||||
@@ -26,11 +25,12 @@ import type {
|
|||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
FixedPoint,
|
FixedPoint,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
||||||
import type { AppState, Point } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { isPointOnShape } from "../../utils/collision";
|
import { isPointOnShape } from "../../utils/collision";
|
||||||
import { getElementAtPosition } from "../scene";
|
import { getElementAtPosition } from "../scene";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
isBindingElement,
|
isBindingElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
|
isFixedPointBinding,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isRectangularElement,
|
isRectangularElement,
|
||||||
@@ -51,17 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
|||||||
import { arrayToMap, tupleToCoors } from "../utils";
|
import { arrayToMap, tupleToCoors } from "../utils";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||||
import { getElementShape } from "../shapes";
|
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||||
import {
|
|
||||||
aabbForElement,
|
|
||||||
clamp,
|
|
||||||
distanceSq2d,
|
|
||||||
getCenterForBounds,
|
|
||||||
getCenterForElement,
|
|
||||||
pointInsideBounds,
|
|
||||||
pointToVector,
|
|
||||||
rotatePoint,
|
|
||||||
} from "../math";
|
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
HEADING_DOWN,
|
HEADING_DOWN,
|
||||||
@@ -72,7 +63,18 @@ import {
|
|||||||
vectorToHeading,
|
vectorToHeading,
|
||||||
type Heading,
|
type Heading,
|
||||||
} from "./heading";
|
} from "./heading";
|
||||||
import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
|
import {
|
||||||
|
lineSegment,
|
||||||
|
point,
|
||||||
|
pointRotateRads,
|
||||||
|
type GlobalPoint,
|
||||||
|
vectorFromPoint,
|
||||||
|
pointFromPair,
|
||||||
|
pointDistanceSq,
|
||||||
|
clamp,
|
||||||
|
} from "../../math";
|
||||||
|
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
|
||||||
|
|
||||||
export type SuggestedBinding =
|
export type SuggestedBinding =
|
||||||
| NonDeleted<ExcalidrawBindableElement>
|
| NonDeleted<ExcalidrawBindableElement>
|
||||||
@@ -649,7 +651,7 @@ export const updateBoundElements = (
|
|||||||
update,
|
update,
|
||||||
): update is NonNullable<{
|
): update is NonNullable<{
|
||||||
index: number;
|
index: number;
|
||||||
point: Point;
|
point: LocalPoint;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
}> => update !== null,
|
}> => update !== null,
|
||||||
);
|
);
|
||||||
@@ -695,14 +697,14 @@ const getSimultaneouslyUpdatedElementIds = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getHeadingForElbowArrowSnap = (
|
export const getHeadingForElbowArrowSnap = (
|
||||||
point: Readonly<Point>,
|
p: Readonly<GlobalPoint>,
|
||||||
otherPoint: Readonly<Point>,
|
otherPoint: Readonly<GlobalPoint>,
|
||||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||||
aabb: Bounds | undefined | null,
|
aabb: Bounds | undefined | null,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
origPoint: Point,
|
origPoint: GlobalPoint,
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
|
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||||
|
|
||||||
if (!bindableElement || !aabb) {
|
if (!bindableElement || !aabb) {
|
||||||
return otherPointHeading;
|
return otherPointHeading;
|
||||||
@@ -716,17 +718,23 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
return vectorToHeading(
|
return vectorToHeading(
|
||||||
pointToVector(point, getCenterForElement(bindableElement)),
|
vectorFromPoint(
|
||||||
|
p,
|
||||||
|
point<GlobalPoint>(
|
||||||
|
bindableElement.x + bindableElement.width / 2,
|
||||||
|
bindableElement.y + bindableElement.height / 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
|
const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
|
||||||
|
|
||||||
return pointHeading;
|
return pointHeading;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDistanceForBinding = (
|
const getDistanceForBinding = (
|
||||||
point: Readonly<Point>,
|
point: Readonly<GlobalPoint>,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
@@ -745,89 +753,87 @@ const getDistanceForBinding = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const bindPointToSnapToElementOutline = (
|
export const bindPointToSnapToElementOutline = (
|
||||||
point: Readonly<Point>,
|
p: Readonly<GlobalPoint>,
|
||||||
otherPoint: Readonly<Point>,
|
otherPoint: Readonly<GlobalPoint>,
|
||||||
bindableElement: ExcalidrawBindableElement | undefined,
|
bindableElement: ExcalidrawBindableElement | undefined,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
const aabb = bindableElement && aabbForElement(bindableElement);
|
const aabb = bindableElement && aabbForElement(bindableElement);
|
||||||
|
|
||||||
if (bindableElement && aabb) {
|
if (bindableElement && aabb) {
|
||||||
// TODO: Dirty hacks until tangents are properly calculated
|
// TODO: Dirty hacks until tangents are properly calculated
|
||||||
const heading = headingForPointFromElement(bindableElement, aabb, point);
|
const heading = headingForPointFromElement(bindableElement, aabb, p);
|
||||||
const intersections = [
|
const intersections = [
|
||||||
...intersectElementWithLine(
|
...(intersectElementWithLine(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
[point[0], point[1] - 2 * bindableElement.height],
|
point(p[0], p[1] - 2 * bindableElement.height),
|
||||||
[point[0], point[1] + 2 * bindableElement.height],
|
point(p[0], p[1] + 2 * bindableElement.height),
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
) ?? []),
|
||||||
...intersectElementWithLine(
|
...(intersectElementWithLine(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
[point[0] - 2 * bindableElement.width, point[1]],
|
point(p[0] - 2 * bindableElement.width, p[1]),
|
||||||
[point[0] + 2 * bindableElement.width, point[1]],
|
point(p[0] + 2 * bindableElement.width, p[1]),
|
||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
) ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const isVertical =
|
const isVertical =
|
||||||
compareHeading(heading, HEADING_LEFT) ||
|
compareHeading(heading, HEADING_LEFT) ||
|
||||||
compareHeading(heading, HEADING_RIGHT);
|
compareHeading(heading, HEADING_RIGHT);
|
||||||
const dist = Math.abs(
|
const dist = Math.abs(
|
||||||
distanceToBindableElement(bindableElement, point, elementsMap),
|
distanceToBindableElement(bindableElement, p, elementsMap),
|
||||||
);
|
);
|
||||||
const isInner = isVertical
|
const isInner = isVertical
|
||||||
? dist < bindableElement.width * -0.1
|
? dist < bindableElement.width * -0.1
|
||||||
: dist < bindableElement.height * -0.1;
|
: dist < bindableElement.height * -0.1;
|
||||||
|
|
||||||
intersections.sort(
|
intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
|
||||||
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
|
|
||||||
);
|
|
||||||
|
|
||||||
return isInner
|
return isInner
|
||||||
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
|
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
|
||||||
: intersections.filter((i) =>
|
: intersections.filter((i) =>
|
||||||
isVertical
|
isVertical
|
||||||
? Math.abs(point[1] - i[1]) < 0.1
|
? Math.abs(p[1] - i[1]) < 0.1
|
||||||
: Math.abs(point[0] - i[0]) < 0.1,
|
: Math.abs(p[0] - i[0]) < 0.1,
|
||||||
)[0] ?? point;
|
)[0] ?? p;
|
||||||
}
|
}
|
||||||
|
|
||||||
return point;
|
return p;
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingToMidBindPoint = (
|
const headingToMidBindPoint = (
|
||||||
point: Point,
|
p: GlobalPoint,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
aabb: Bounds,
|
aabb: Bounds,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
const center = getCenterForBounds(aabb);
|
const center = getCenterForBounds(aabb);
|
||||||
const heading = vectorToHeading(pointToVector(point, center));
|
const heading = vectorToHeading(vectorFromPoint(p, center));
|
||||||
|
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case compareHeading(heading, HEADING_UP):
|
case compareHeading(heading, HEADING_UP):
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
|
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||||
center,
|
center,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
);
|
);
|
||||||
case compareHeading(heading, HEADING_RIGHT):
|
case compareHeading(heading, HEADING_RIGHT):
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
|
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||||
center,
|
center,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
);
|
);
|
||||||
case compareHeading(heading, HEADING_DOWN):
|
case compareHeading(heading, HEADING_DOWN):
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
|
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||||
center,
|
center,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
|
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||||
center,
|
center,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
);
|
);
|
||||||
@@ -836,22 +842,25 @@ const headingToMidBindPoint = (
|
|||||||
|
|
||||||
export const avoidRectangularCorner = (
|
export const avoidRectangularCorner = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
p: Point,
|
p: GlobalPoint,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
const center = getCenterForElement(element);
|
const center = point<GlobalPoint>(
|
||||||
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
|
element.x + element.width / 2,
|
||||||
|
element.y + element.height / 2,
|
||||||
|
);
|
||||||
|
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
|
|
||||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||||
// Top left
|
// Top left
|
||||||
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||||
return rotatePoint(
|
return pointRotateRads<GlobalPoint>(
|
||||||
[element.x - FIXED_BINDING_DISTANCE, element.y],
|
point(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x, element.y - FIXED_BINDING_DISTANCE],
|
point(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -861,14 +870,14 @@ export const avoidRectangularCorner = (
|
|||||||
) {
|
) {
|
||||||
// Bottom left
|
// Bottom left
|
||||||
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
|
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
|
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -881,20 +890,20 @@ export const avoidRectangularCorner = (
|
|||||||
nonRotatedPoint[0] - element.x <
|
nonRotatedPoint[0] - element.x <
|
||||||
element.width + FIXED_BINDING_DISTANCE
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[
|
point(
|
||||||
element.x + element.width,
|
element.x + element.width,
|
||||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||||
],
|
),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[
|
point(
|
||||||
element.x + element.width + FIXED_BINDING_DISTANCE,
|
element.x + element.width + FIXED_BINDING_DISTANCE,
|
||||||
element.y + element.height,
|
element.y + element.height,
|
||||||
],
|
),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -907,14 +916,14 @@ export const avoidRectangularCorner = (
|
|||||||
nonRotatedPoint[0] - element.x <
|
nonRotatedPoint[0] - element.x <
|
||||||
element.width + FIXED_BINDING_DISTANCE
|
element.width + FIXED_BINDING_DISTANCE
|
||||||
) {
|
) {
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
|
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
|
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||||
center,
|
center,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
@@ -925,12 +934,12 @@ export const avoidRectangularCorner = (
|
|||||||
|
|
||||||
export const snapToMid = (
|
export const snapToMid = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
p: Point,
|
p: GlobalPoint,
|
||||||
tolerance: number = 0.05,
|
tolerance: number = 0.05,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
const { x, y, width, height, angle } = element;
|
const { x, y, width, height, angle } = element;
|
||||||
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
|
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
||||||
const nonRotated = rotatePoint(p, center, -angle);
|
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||||
|
|
||||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||||
// above and below certain px distance
|
// above and below certain px distance
|
||||||
@@ -943,22 +952,30 @@ export const snapToMid = (
|
|||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThrehsold
|
||||||
) {
|
) {
|
||||||
// LEFT
|
// LEFT
|
||||||
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
|
return pointRotateRads(
|
||||||
|
point(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||||
|
center,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[1] <= y + height / 2 &&
|
nonRotated[1] <= y + height / 2 &&
|
||||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThrehsold
|
||||||
) {
|
) {
|
||||||
// TOP
|
// TOP
|
||||||
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
|
return pointRotateRads(
|
||||||
|
point(center[0], y - FIXED_BINDING_DISTANCE),
|
||||||
|
center,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[0] >= x + width / 2 &&
|
nonRotated[0] >= x + width / 2 &&
|
||||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThrehsold
|
||||||
) {
|
) {
|
||||||
// RIGHT
|
// RIGHT
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[x + width + FIXED_BINDING_DISTANCE, center[1]],
|
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -968,8 +985,8 @@ export const snapToMid = (
|
|||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThrehsold
|
||||||
) {
|
) {
|
||||||
// DOWN
|
// DOWN
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[center[0], y + height + FIXED_BINDING_DISTANCE],
|
point(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
@@ -984,7 +1001,7 @@ const updateBoundPoint = (
|
|||||||
binding: PointBinding | null | undefined,
|
binding: PointBinding | null | undefined,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point | null => {
|
): LocalPoint | null => {
|
||||||
if (
|
if (
|
||||||
binding == null ||
|
binding == null ||
|
||||||
// We only need to update the other end if this is a 2 point line element
|
// We only need to update the other end if this is a 2 point line element
|
||||||
@@ -997,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(
|
||||||
@@ -1006,15 +1023,15 @@ const updateBoundPoint = (
|
|||||||
startOrEnd === "startBinding" ? "start" : "end",
|
startOrEnd === "startBinding" ? "start" : "end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
const globalMidPoint = [
|
const globalMidPoint = point<GlobalPoint>(
|
||||||
bindableElement.x + bindableElement.width / 2,
|
bindableElement.x + bindableElement.width / 2,
|
||||||
bindableElement.y + bindableElement.height / 2,
|
bindableElement.y + bindableElement.height / 2,
|
||||||
] as Point;
|
);
|
||||||
const global = [
|
const global = point<GlobalPoint>(
|
||||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||||
] as Point;
|
);
|
||||||
const rotatedGlobal = rotatePoint(
|
const rotatedGlobal = pointRotateRads(
|
||||||
global,
|
global,
|
||||||
globalMidPoint,
|
globalMidPoint,
|
||||||
bindableElement.angle,
|
bindableElement.angle,
|
||||||
@@ -1040,7 +1057,7 @@ const updateBoundPoint = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
let newEdgePoint: Point;
|
let newEdgePoint: GlobalPoint;
|
||||||
|
|
||||||
// The linear element was not originally pointing inside the bound shape,
|
// The linear element was not originally pointing inside the bound shape,
|
||||||
// we can point directly at the focus point
|
// we can point directly at the focus point
|
||||||
@@ -1054,7 +1071,7 @@ const updateBoundPoint = (
|
|||||||
binding.gap,
|
binding.gap,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
if (intersections.length === 0) {
|
if (!intersections || intersections.length === 0) {
|
||||||
// This should never happen, since focusPoint should always be
|
// This should never happen, since focusPoint should always be
|
||||||
// inside the element, but just in case, bail out
|
// inside the element, but just in case, bail out
|
||||||
newEdgePoint = focusPointAbsolute;
|
newEdgePoint = focusPointAbsolute;
|
||||||
@@ -1101,15 +1118,15 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
hoveredElement,
|
hoveredElement,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const globalMidPoint = [
|
const globalMidPoint = point(
|
||||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
] as Point;
|
);
|
||||||
const nonRotatedSnappedGlobalPoint = rotatePoint(
|
const nonRotatedSnappedGlobalPoint = pointRotateRads(
|
||||||
snappedPoint,
|
snappedPoint,
|
||||||
globalMidPoint,
|
globalMidPoint,
|
||||||
-hoveredElement.angle,
|
-hoveredElement.angle as Radians,
|
||||||
) as Point;
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fixedPoint: normalizeFixedPoint([
|
fixedPoint: normalizeFixedPoint([
|
||||||
@@ -1320,8 +1337,9 @@ export const bindingBorderTest = (
|
|||||||
const threshold = maxBindingGap(element, element.width, element.height);
|
const threshold = maxBindingGap(element, element.width, element.height);
|
||||||
const shape = getElementShape(element, elementsMap);
|
const shape = getElementShape(element, elementsMap);
|
||||||
return (
|
return (
|
||||||
isPointOnShape([x, y], shape, threshold) ||
|
isPointOnShape(point(x, y), shape, threshold) ||
|
||||||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
|
(fullShape === true &&
|
||||||
|
pointInsideBounds(point(x, y), aabbForElement(element)))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1339,7 +1357,7 @@ export const maxBindingGap = (
|
|||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToBindableElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
point: Point,
|
point: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
@@ -1359,19 +1377,13 @@ export const distanceToBindableElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const distanceToRectangle = (
|
const distanceToRectangle = (
|
||||||
element:
|
element: ExcalidrawRectanguloidElement,
|
||||||
| ExcalidrawRectangleElement
|
p: GlobalPoint,
|
||||||
| ExcalidrawTextElement
|
|
||||||
| ExcalidrawFreeDrawElement
|
|
||||||
| ExcalidrawImageElement
|
|
||||||
| ExcalidrawIframeLikeElement
|
|
||||||
| ExcalidrawFrameLikeElement,
|
|
||||||
point: Point,
|
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): number => {
|
): number => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
element,
|
element,
|
||||||
point,
|
p,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
return Math.max(
|
return Math.max(
|
||||||
@@ -1382,7 +1394,7 @@ const distanceToRectangle = (
|
|||||||
|
|
||||||
const distanceToDiamond = (
|
const distanceToDiamond = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
point: Point,
|
point: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): number => {
|
): number => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
@@ -1396,7 +1408,7 @@ const distanceToDiamond = (
|
|||||||
|
|
||||||
const distanceToEllipse = (
|
const distanceToEllipse = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
point: Point,
|
point: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): number => {
|
): number => {
|
||||||
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
|
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
|
||||||
@@ -1405,7 +1417,7 @@ const distanceToEllipse = (
|
|||||||
|
|
||||||
const ellipseParamsForTest = (
|
const ellipseParamsForTest = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
point: Point,
|
point: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [GA.Point, GA.Line] => {
|
): [GA.Point, GA.Line] => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||||
@@ -1467,7 +1479,7 @@ const ellipseParamsForTest = (
|
|||||||
// so we only need to perform hit tests for the positive quadrant.
|
// so we only need to perform hit tests for the positive quadrant.
|
||||||
const pointRelativeToElement = (
|
const pointRelativeToElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
pointTuple: Point,
|
pointTuple: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [GA.Point, GA.Point, number, number] => {
|
): [GA.Point, GA.Point, number, number] => {
|
||||||
const point = GAPoint.from(pointTuple);
|
const point = GAPoint.from(pointTuple);
|
||||||
@@ -1516,9 +1528,9 @@ const coordsCenter = (
|
|||||||
const determineFocusDistance = (
|
const determineFocusDistance = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: Point,
|
a: GlobalPoint,
|
||||||
// Another point on the line, in absolute coordinates (closer to element)
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
b: Point,
|
b: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): number => {
|
): number => {
|
||||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
@@ -1559,13 +1571,13 @@ const determineFocusPoint = (
|
|||||||
// The oriented, relative distance from the center of `element` of the
|
// The oriented, relative distance from the center of `element` of the
|
||||||
// returned focusPoint
|
// returned focusPoint
|
||||||
focus: number,
|
focus: number,
|
||||||
adjecentPoint: Point,
|
adjecentPoint: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const center = coordsCenter(x1, y1, x2, y2);
|
const center = coordsCenter(x1, y1, x2, y2);
|
||||||
return GAPoint.toTuple(center);
|
return pointFromPair(GAPoint.toTuple(center));
|
||||||
}
|
}
|
||||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
const adjecentPointRel = GATransform.apply(
|
const adjecentPointRel = GATransform.apply(
|
||||||
@@ -1589,7 +1601,9 @@ const determineFocusPoint = (
|
|||||||
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
|
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
|
return pointFromPair(
|
||||||
|
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
// Returns 2 or 0 intersection points between line going through `a` and `b`
|
||||||
@@ -1597,15 +1611,15 @@ const determineFocusPoint = (
|
|||||||
const intersectElementWithLine = (
|
const intersectElementWithLine = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: Point,
|
a: GlobalPoint,
|
||||||
// Another point on the line, in absolute coordinates
|
// Another point on the line, in absolute coordinates
|
||||||
b: Point,
|
b: GlobalPoint,
|
||||||
// If given, the element is inflated by this value
|
// If given, the element is inflated by this value
|
||||||
gap: number = 0,
|
gap: number = 0,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point[] => {
|
): GlobalPoint[] | undefined => {
|
||||||
if (isRectangularElement(element)) {
|
if (isRectangularElement(element)) {
|
||||||
return segmentIntersectRectangleElement(element, [a, b], gap);
|
return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||||
@@ -1619,8 +1633,14 @@ const intersectElementWithLine = (
|
|||||||
aRel,
|
aRel,
|
||||||
gap,
|
gap,
|
||||||
);
|
);
|
||||||
return intersections.map((point) =>
|
return intersections.map(
|
||||||
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
(point) =>
|
||||||
|
pointFromPair(
|
||||||
|
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||||
|
),
|
||||||
|
// pointFromArray(
|
||||||
|
// ,
|
||||||
|
// ),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2173,12 +2193,18 @@ export class BindableElement {
|
|||||||
export const getGlobalFixedPointForBindableElement = (
|
export const getGlobalFixedPointForBindableElement = (
|
||||||
fixedPointRatio: [number, number],
|
fixedPointRatio: [number, number],
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
) => {
|
): GlobalPoint => {
|
||||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
return rotatePoint(
|
return pointRotateRads(
|
||||||
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
point(
|
||||||
getCenterForElement(element),
|
element.x + element.width * fixedX,
|
||||||
|
element.y + element.height * fixedY,
|
||||||
|
),
|
||||||
|
point<GlobalPoint>(
|
||||||
|
element.x + element.width / 2,
|
||||||
|
element.y + element.height / 2,
|
||||||
|
),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2186,7 +2212,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
const getGlobalFixedPoints = (
|
const getGlobalFixedPoints = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
): [GlobalPoint, GlobalPoint] => {
|
||||||
const startElement =
|
const startElement =
|
||||||
arrow.startBinding &&
|
arrow.startBinding &&
|
||||||
(elementsMap.get(arrow.startBinding.elementId) as
|
(elementsMap.get(arrow.startBinding.elementId) as
|
||||||
@@ -2197,23 +2223,26 @@ const getGlobalFixedPoints = (
|
|||||||
(elementsMap.get(arrow.endBinding.elementId) as
|
(elementsMap.get(arrow.endBinding.elementId) as
|
||||||
| ExcalidrawBindableElement
|
| ExcalidrawBindableElement
|
||||||
| undefined);
|
| undefined);
|
||||||
const startPoint: Point =
|
const startPoint =
|
||||||
startElement && arrow.startBinding
|
startElement && arrow.startBinding
|
||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.startBinding.fixedPoint,
|
arrow.startBinding.fixedPoint,
|
||||||
startElement as ExcalidrawBindableElement,
|
startElement as ExcalidrawBindableElement,
|
||||||
)
|
)
|
||||||
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
|
: point<GlobalPoint>(
|
||||||
const endPoint: Point =
|
arrow.x + arrow.points[0][0],
|
||||||
|
arrow.y + arrow.points[0][1],
|
||||||
|
);
|
||||||
|
const endPoint =
|
||||||
endElement && arrow.endBinding
|
endElement && arrow.endBinding
|
||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.endBinding.fixedPoint,
|
arrow.endBinding.fixedPoint,
|
||||||
endElement as ExcalidrawBindableElement,
|
endElement as ExcalidrawBindableElement,
|
||||||
)
|
)
|
||||||
: [
|
: point<GlobalPoint>(
|
||||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||||
arrow.y + arrow.points[arrow.points.length - 1][1],
|
arrow.y + arrow.points[arrow.points.length - 1][1],
|
||||||
];
|
);
|
||||||
|
|
||||||
return [startPoint, endPoint];
|
return [startPoint, endPoint];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { LocalPoint } from "../../math";
|
||||||
|
import { point } from "../../math";
|
||||||
import { ROUNDNESS } from "../constants";
|
import { ROUNDNESS } from "../constants";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap } from "../utils";
|
||||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
|
|||||||
a: 0.6447741904932416,
|
a: 0.6447741904932416,
|
||||||
}),
|
}),
|
||||||
points: [
|
points: [
|
||||||
[0, 0] as [number, number],
|
point<LocalPoint>(0, 0),
|
||||||
[67.33984375, 92.48828125] as [number, number],
|
point<LocalPoint>(67.33984375, 92.48828125),
|
||||||
[-102.7890625, 52.15625] as [number, number],
|
point<LocalPoint>(-102.7890625, 52.15625),
|
||||||
],
|
],
|
||||||
} as ExcalidrawLinearElement;
|
} as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate, rotatePoint } from "../math";
|
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { AppState, Point } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { generateRoughOptions } from "../scene/Shape";
|
import { generateRoughOptions } from "../scene/Shape";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -22,9 +22,24 @@ import {
|
|||||||
import { rescalePoints } from "../points";
|
import { rescalePoints } from "../points";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import type { Mutable } from "../utility-types";
|
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import { arrayToMap } from "../utils";
|
import { arrayToMap, invariant } from "../utils";
|
||||||
|
import type {
|
||||||
|
Degrees,
|
||||||
|
GlobalPoint,
|
||||||
|
LineSegment,
|
||||||
|
LocalPoint,
|
||||||
|
Radians,
|
||||||
|
} from "../../math";
|
||||||
|
import {
|
||||||
|
degreesToRadians,
|
||||||
|
lineSegment,
|
||||||
|
point,
|
||||||
|
pointDistance,
|
||||||
|
pointFromArray,
|
||||||
|
pointRotateRads,
|
||||||
|
} from "../../math";
|
||||||
|
import type { Mutable } from "../utility-types";
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -97,7 +112,11 @@ export class ElementBounds {
|
|||||||
if (isFreeDrawElement(element)) {
|
if (isFreeDrawElement(element)) {
|
||||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||||
element.points.map(([x, y]) =>
|
element.points.map(([x, y]) =>
|
||||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
pointRotateRads(
|
||||||
|
point(x, y),
|
||||||
|
point(cx - element.x, cy - element.y),
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -110,10 +129,26 @@ export class ElementBounds {
|
|||||||
} else if (isLinearElement(element)) {
|
} else if (isLinearElement(element)) {
|
||||||
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
||||||
} else if (element.type === "diamond") {
|
} else if (element.type === "diamond") {
|
||||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
const [x11, y11] = pointRotateRads(
|
||||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
point(cx, y1),
|
||||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
point(cx, cy),
|
||||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x12, y12] = pointRotateRads(
|
||||||
|
point(cx, y2),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x22, y22] = pointRotateRads(
|
||||||
|
point(x1, cy),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x21, y21] = pointRotateRads(
|
||||||
|
point(x2, cy),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
const minX = Math.min(x11, x12, x22, x21);
|
const minX = Math.min(x11, x12, x22, x21);
|
||||||
const minY = Math.min(y11, y12, y22, y21);
|
const minY = Math.min(y11, y12, y22, y21);
|
||||||
const maxX = Math.max(x11, x12, x22, x21);
|
const maxX = Math.max(x11, x12, x22, x21);
|
||||||
@@ -128,10 +163,26 @@ export class ElementBounds {
|
|||||||
const hh = Math.hypot(h * cos, w * sin);
|
const hh = Math.hypot(h * cos, w * sin);
|
||||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||||
} else {
|
} else {
|
||||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
const [x11, y11] = pointRotateRads(
|
||||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
point(x1, y1),
|
||||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
point(cx, cy),
|
||||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x12, y12] = pointRotateRads(
|
||||||
|
point(x1, y2),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x22, y22] = pointRotateRads(
|
||||||
|
point(x2, y2),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [x21, y21] = pointRotateRads(
|
||||||
|
point(x2, y1),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
const minX = Math.min(x11, x12, x22, x21);
|
const minX = Math.min(x11, x12, x22, x21);
|
||||||
const minY = Math.min(y11, y12, y22, y21);
|
const minY = Math.min(y11, y12, y22, y21);
|
||||||
const maxX = Math.max(x11, x12, x22, x21);
|
const maxX = Math.max(x11, x12, x22, x21);
|
||||||
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
|
|||||||
? getContainerElement(element, elementsMap)
|
? getContainerElement(element, elementsMap)
|
||||||
: null;
|
: null;
|
||||||
if (isArrowElement(container)) {
|
if (isArrowElement(container)) {
|
||||||
const coords = LinearElementEditor.getBoundTextElementPosition(
|
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||||
container,
|
container,
|
||||||
element as ExcalidrawTextElementWithContainer,
|
element as ExcalidrawTextElementWithContainer,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
coords.x,
|
x,
|
||||||
coords.y,
|
y,
|
||||||
coords.x + element.width,
|
x + element.width,
|
||||||
coords.y + element.height,
|
y + element.height,
|
||||||
coords.x + element.width / 2,
|
x + element.width / 2,
|
||||||
coords.y + element.height / 2,
|
y + element.height / 2,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
|
|||||||
export const getElementLineSegments = (
|
export const getElementLineSegments = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): [Point, Point][] => {
|
): LineSegment<GlobalPoint>[] => {
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const center: Point = [cx, cy];
|
const center: GlobalPoint = point(cx, cy);
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
const segments: [Point, Point][] = [];
|
const segments: LineSegment<GlobalPoint>[] = [];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < element.points.length - 1) {
|
while (i < element.points.length - 1) {
|
||||||
segments.push([
|
segments.push(
|
||||||
rotatePoint(
|
lineSegment(
|
||||||
[
|
pointRotateRads(
|
||||||
element.points[i][0] + element.x,
|
point(
|
||||||
element.points[i][1] + element.y,
|
element.points[i][0] + element.x,
|
||||||
] as Point,
|
element.points[i][1] + element.y,
|
||||||
center,
|
),
|
||||||
element.angle,
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
point(
|
||||||
|
element.points[i + 1][0] + element.x,
|
||||||
|
element.points[i + 1][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
rotatePoint(
|
);
|
||||||
[
|
|
||||||
element.points[i + 1][0] + element.x,
|
|
||||||
element.points[i + 1][1] + element.y,
|
|
||||||
] as Point,
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
|
|||||||
[cx, y2],
|
[cx, y2],
|
||||||
[x1, cy],
|
[x1, cy],
|
||||||
[x2, cy],
|
[x2, cy],
|
||||||
] as Point[]
|
] as GlobalPoint[]
|
||||||
).map((point) => rotatePoint(point, center, element.angle));
|
).map((point) => pointRotateRads(point, center, element.angle));
|
||||||
|
|
||||||
if (element.type === "diamond") {
|
if (element.type === "diamond") {
|
||||||
return [
|
return [
|
||||||
[n, w],
|
lineSegment(n, w),
|
||||||
[n, e],
|
lineSegment(n, e),
|
||||||
[s, w],
|
lineSegment(s, w),
|
||||||
[s, e],
|
lineSegment(s, e),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === "ellipse") {
|
if (element.type === "ellipse") {
|
||||||
return [
|
return [
|
||||||
[n, w],
|
lineSegment(n, w),
|
||||||
[n, e],
|
lineSegment(n, e),
|
||||||
[s, w],
|
lineSegment(s, w),
|
||||||
[s, e],
|
lineSegment(s, e),
|
||||||
[n, w],
|
lineSegment(n, w),
|
||||||
[n, e],
|
lineSegment(n, e),
|
||||||
[s, w],
|
lineSegment(s, w),
|
||||||
[s, e],
|
lineSegment(s, e),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[nw, ne],
|
lineSegment(nw, ne),
|
||||||
[sw, se],
|
lineSegment(sw, se),
|
||||||
[nw, sw],
|
lineSegment(nw, sw),
|
||||||
[ne, se],
|
lineSegment(ne, se),
|
||||||
[nw, e],
|
lineSegment(nw, e),
|
||||||
[sw, e],
|
lineSegment(sw, e),
|
||||||
[ne, w],
|
lineSegment(ne, w),
|
||||||
[se, w],
|
lineSegment(se, w),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -386,10 +439,10 @@ const solveQuadratic = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getCubicBezierCurveBound = (
|
const getCubicBezierCurveBound = (
|
||||||
p0: Point,
|
p0: GlobalPoint,
|
||||||
p1: Point,
|
p1: GlobalPoint,
|
||||||
p2: Point,
|
p2: GlobalPoint,
|
||||||
p3: Point,
|
p3: GlobalPoint,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||||
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||||
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
|
|||||||
|
|
||||||
export const getMinMaxXYFromCurvePathOps = (
|
export const getMinMaxXYFromCurvePathOps = (
|
||||||
ops: Op[],
|
ops: Op[],
|
||||||
transformXY?: (x: number, y: number) => [number, number],
|
transformXY?: (p: GlobalPoint) => GlobalPoint,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
let currentP: Point = [0, 0];
|
let currentP: GlobalPoint = point(0, 0);
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||||
(limits, { op, data }) => {
|
(limits, { op, data }) => {
|
||||||
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
|
|||||||
// move, bcurveTo, lineTo, and curveTo
|
// move, bcurveTo, lineTo, and curveTo
|
||||||
if (op === "move") {
|
if (op === "move") {
|
||||||
// change starting point
|
// change starting point
|
||||||
currentP = data as unknown as Point;
|
const p: GlobalPoint | undefined = pointFromArray(data);
|
||||||
|
invariant(p != null, "Op data is not a point");
|
||||||
|
currentP = p;
|
||||||
// move operation does not draw anything; so, it always
|
// move operation does not draw anything; so, it always
|
||||||
// returns false
|
// returns false
|
||||||
} else if (op === "bcurveTo") {
|
} else if (op === "bcurveTo") {
|
||||||
const _p1 = [data[0], data[1]] as Point;
|
const _p1 = point<GlobalPoint>(data[0], data[1]);
|
||||||
const _p2 = [data[2], data[3]] as Point;
|
const _p2 = point<GlobalPoint>(data[2], data[3]);
|
||||||
const _p3 = [data[4], data[5]] as Point;
|
const _p3 = point<GlobalPoint>(data[4], data[5]);
|
||||||
|
|
||||||
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
const p1 = transformXY ? transformXY(_p1) : _p1;
|
||||||
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
const p2 = transformXY ? transformXY(_p2) : _p2;
|
||||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
const p3 = transformXY ? transformXY(_p3) : _p3;
|
||||||
|
|
||||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
const p0 = transformXY ? transformXY(currentP) : currentP;
|
||||||
currentP = _p3;
|
currentP = _p3;
|
||||||
|
|
||||||
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||||
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** @returns number in degrees */
|
/** @returns number in degrees */
|
||||||
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
|
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
|
||||||
switch (arrowhead) {
|
switch (arrowhead) {
|
||||||
case "bar":
|
case "bar":
|
||||||
return 90;
|
return 90 as Degrees;
|
||||||
case "arrow":
|
case "arrow":
|
||||||
return 20;
|
return 20 as Degrees;
|
||||||
default:
|
default:
|
||||||
return 25;
|
return 25 as Degrees;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
|
|||||||
const index = position === "start" ? 1 : ops.length - 1;
|
const index = position === "start" ? 1 : ops.length - 1;
|
||||||
|
|
||||||
const data = ops[index].data;
|
const data = ops[index].data;
|
||||||
const p3 = [data[4], data[5]] as Point;
|
|
||||||
const p2 = [data[2], data[3]] as Point;
|
invariant(data.length === 6, "Op data length is not 6");
|
||||||
const p1 = [data[0], data[1]] as Point;
|
|
||||||
|
const p3 = point(data[4], data[5]);
|
||||||
|
const p2 = point(data[2], data[3]);
|
||||||
|
const p1 = point(data[0], data[1]);
|
||||||
|
|
||||||
// We need to find p0 of the bezier curve.
|
// We need to find p0 of the bezier curve.
|
||||||
// It is typically the last point of the previous
|
// It is typically the last point of the previous
|
||||||
// curve; it can also be the position of moveTo operation.
|
// curve; it can also be the position of moveTo operation.
|
||||||
const prevOp = ops[index - 1];
|
const prevOp = ops[index - 1];
|
||||||
let p0: Point = [0, 0];
|
let p0 = point(0, 0);
|
||||||
if (prevOp.op === "move") {
|
if (prevOp.op === "move") {
|
||||||
p0 = prevOp.data as unknown as Point;
|
const p = pointFromArray(prevOp.data);
|
||||||
|
invariant(p != null, "Op data is not a point");
|
||||||
|
p0 = p;
|
||||||
} else if (prevOp.op === "bcurveTo") {
|
} else if (prevOp.op === "bcurveTo") {
|
||||||
p0 = [prevOp.data[4], prevOp.data[5]];
|
p0 = point(prevOp.data[4], prevOp.data[5]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
||||||
@@ -610,8 +670,16 @@ export const getArrowheadPoints = (
|
|||||||
const angle = getArrowheadAngle(arrowhead);
|
const angle = getArrowheadAngle(arrowhead);
|
||||||
|
|
||||||
// Return points
|
// Return points
|
||||||
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
|
const [x3, y3] = pointRotateRads(
|
||||||
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
|
point(xs, ys),
|
||||||
|
point(x2, y2),
|
||||||
|
((-angle * Math.PI) / 180) as Radians,
|
||||||
|
);
|
||||||
|
const [x4, y4] = pointRotateRads(
|
||||||
|
point(xs, ys),
|
||||||
|
point(x2, y2),
|
||||||
|
degreesToRadians(angle),
|
||||||
|
);
|
||||||
|
|
||||||
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
|
if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
|
||||||
// point opposite to the arrowhead point
|
// point opposite to the arrowhead point
|
||||||
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
|
|||||||
if (position === "start") {
|
if (position === "start") {
|
||||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||||
|
|
||||||
[ox, oy] = rotate(
|
[ox, oy] = pointRotateRads(
|
||||||
x2 + minSize * 2,
|
point(x2 + minSize * 2, y2),
|
||||||
y2,
|
point(x2, y2),
|
||||||
x2,
|
Math.atan2(py - y2, px - x2) as Radians,
|
||||||
y2,
|
|
||||||
Math.atan2(py - y2, px - x2),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [px, py] =
|
const [px, py] =
|
||||||
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
|
|||||||
? element.points[element.points.length - 2]
|
? element.points[element.points.length - 2]
|
||||||
: [0, 0];
|
: [0, 0];
|
||||||
|
|
||||||
[ox, oy] = rotate(
|
[ox, oy] = pointRotateRads(
|
||||||
x2 - minSize * 2,
|
point(x2 - minSize * 2, y2),
|
||||||
y2,
|
point(x2, y2),
|
||||||
x2,
|
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||||
y2,
|
|
||||||
Math.atan2(y2 - py, x2 - px),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
|
|||||||
return "linearPath";
|
return "linearPath";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return generator[method](element.points as Mutable<Point>[], options);
|
return generator[method](
|
||||||
|
element.points as Mutable<LocalPoint>[] as RoughPoint[],
|
||||||
|
options,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLinearElementRotatedBounds = (
|
const getLinearElementRotatedBounds = (
|
||||||
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
|
|||||||
|
|
||||||
if (element.points.length < 2) {
|
if (element.points.length < 2) {
|
||||||
const [pointX, pointY] = element.points[0];
|
const [pointX, pointY] = element.points[0];
|
||||||
const [x, y] = rotate(
|
const [x, y] = pointRotateRads(
|
||||||
element.x + pointX,
|
point(element.x + pointX, element.y + pointY),
|
||||||
element.y + pointY,
|
point(cx, cy),
|
||||||
cx,
|
|
||||||
cy,
|
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
|
|||||||
const cachedShape = ShapeCache.get(element)?.[0];
|
const cachedShape = ShapeCache.get(element)?.[0];
|
||||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||||
const ops = getCurvePathOps(shape);
|
const ops = getCurvePathOps(shape);
|
||||||
const transformXY = (x: number, y: number) =>
|
const transformXY = ([x, y]: GlobalPoint) =>
|
||||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
pointRotateRads<GlobalPoint>(
|
||||||
|
point(element.x + x, element.y + y),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
|
|||||||
const elementsMap = arrayToMap(elements);
|
const elementsMap = arrayToMap(elements);
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||||
const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
|
const distance = pointDistance(
|
||||||
|
point((x1 + x2) / 2, (y1 + y2) / 2),
|
||||||
|
point(from.x, from.y),
|
||||||
|
);
|
||||||
|
|
||||||
if (distance < minDistance) {
|
if (distance < minDistance) {
|
||||||
minDistance = distance;
|
minDistance = distance;
|
||||||
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
|
|||||||
-scrollY + height / zoom.value,
|
-scrollY + height / zoom.value,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||||
|
point(
|
||||||
|
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||||
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { isPathALoop, isPointWithinBounds } from "../math";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawRectangleElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementBounds } from "./bounds";
|
import { getElementBounds } from "./bounds";
|
||||||
import type { FrameNameBounds } from "../types";
|
import type { FrameNameBounds } from "../types";
|
||||||
import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
|
import type { GeometricShape } from "../../utils/geometry/shape";
|
||||||
import { getPolygonShape } from "../../utils/geometry/shape";
|
import { getPolygonShape } from "../../utils/geometry/shape";
|
||||||
import { isPointInShape, isPointOnShape } from "../../utils/collision";
|
import { isPointInShape, isPointOnShape } from "../../utils/collision";
|
||||||
import { isTransparent } from "../utils";
|
import { isTransparent } from "../utils";
|
||||||
@@ -18,7 +15,9 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getBoundTextShape } from "../shapes";
|
import { getBoundTextShape, isPathALoop } from "../shapes";
|
||||||
|
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
|
||||||
|
import { isPointWithinBounds, point } from "../../math";
|
||||||
|
|
||||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||||
if (element.type === "arrow") {
|
if (element.type === "arrow") {
|
||||||
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
|||||||
return isDraggableFromInside || isImageElement(element);
|
return isDraggableFromInside || isImageElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HitTestArgs = {
|
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape;
|
shape: GeometricShape<Point>;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = ({
|
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
element,
|
element,
|
||||||
shape,
|
shape,
|
||||||
threshold = 10,
|
threshold = 10,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs) => {
|
}: HitTestArgs<Point>) => {
|
||||||
let hit = shouldTestInside(element)
|
let hit = shouldTestInside(element)
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||||
// we would need `onShape` as well to include the "borders"
|
// we would need `onShape` as well to include the "borders"
|
||||||
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
|
isPointInShape(point(x, y), shape) ||
|
||||||
: isPointOnShape([x, y], shape, threshold);
|
isPointOnShape(point(x, y), shape, threshold)
|
||||||
|
: isPointOnShape(point(x, y), shape, threshold);
|
||||||
|
|
||||||
// hit test against a frame's name
|
// hit test against a frame's name
|
||||||
if (!hit && frameNameBound) {
|
if (!hit && frameNameBound) {
|
||||||
hit = isPointInShape([x, y], {
|
hit = isPointInShape(point(x, y), {
|
||||||
type: "polygon",
|
type: "polygon",
|
||||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||||
.data as Polygon,
|
.data as Polygon<Point>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
|
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = (
|
export const hitElementBoundingBoxOnly = <
|
||||||
hitArgs: HitTestArgs,
|
Point extends GlobalPoint | LocalPoint,
|
||||||
|
>(
|
||||||
|
hitArgs: HitTestArgs<Point>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
@@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundText = (
|
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
textShape: GeometricShape | null,
|
textShape: GeometricShape<Point> | null,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return !!textShape && isPointInShape([x, y], textShape);
|
return !!textShape && isPointInShape(point(x, y), textShape);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
PointerDownState,
|
PointerDownState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||||
import { getGridPoint } from "../math";
|
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getFontString } from "../utils";
|
import { getFontString } from "../utils";
|
||||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||||
|
import { getGridPoint } from "../snapping";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { bindLinearElement } from "./binding";
|
import { bindLinearElement } from "./binding";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import { aabbForElement } from "../math";
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
@@ -20,7 +19,7 @@ import type {
|
|||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import type { AppState, PendingExcalidrawElements, Point } from "../types";
|
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +29,8 @@ import {
|
|||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { invariant } from "../utils";
|
import { invariant } from "../utils";
|
||||||
|
import { point, type LocalPoint } from "../../math";
|
||||||
|
import { aabbForElement } from "../shapes";
|
||||||
|
|
||||||
type LinkDirection = "up" | "right" | "down" | "left";
|
type LinkDirection = "up" | "right" | "down" | "left";
|
||||||
|
|
||||||
@@ -81,13 +82,14 @@ const getNodeRelatives = (
|
|||||||
"not an ExcalidrawBindableElement",
|
"not an ExcalidrawBindableElement",
|
||||||
);
|
);
|
||||||
|
|
||||||
const edgePoint: Point =
|
const edgePoint = (
|
||||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||||
|
) as Readonly<LocalPoint>;
|
||||||
|
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||||
edgePoint[0] + el.x,
|
edgePoint[0] + el.x,
|
||||||
edgePoint[1] + el.y,
|
edgePoint[1] + el.y,
|
||||||
]);
|
] as Readonly<LocalPoint>);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
@@ -419,10 +421,7 @@ const createBindingArrow = (
|
|||||||
strokeColor: appState.currentItemStrokeColor,
|
strokeColor: appState.currentItemStrokeColor,
|
||||||
strokeStyle: appState.currentItemStrokeStyle,
|
strokeStyle: appState.currentItemStrokeStyle,
|
||||||
strokeWidth: appState.currentItemStrokeWidth,
|
strokeWidth: appState.currentItemStrokeWidth,
|
||||||
points: [
|
points: [point(0, 0), point(endX, endY)],
|
||||||
[0, 0],
|
|
||||||
[endX, endY],
|
|
||||||
],
|
|
||||||
elbowed: true,
|
elbowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { lineAngle } from "../../utils/geometry/geometry";
|
import type {
|
||||||
import type { Point, Vector } from "../../utils/geometry/shape";
|
LocalPoint,
|
||||||
|
GlobalPoint,
|
||||||
|
Triangle,
|
||||||
|
Vector,
|
||||||
|
Radians,
|
||||||
|
} from "../../math";
|
||||||
import {
|
import {
|
||||||
getCenterForBounds,
|
point,
|
||||||
PointInTriangle,
|
pointRotateRads,
|
||||||
rotatePoint,
|
pointScaleFromOrigin,
|
||||||
scalePointFromOrigin,
|
radiansToDegrees,
|
||||||
} from "../math";
|
triangleIncludesPoint,
|
||||||
import type { Bounds } from "./bounds";
|
} from "../../math";
|
||||||
|
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||||
import type { ExcalidrawBindableElement } from "./types";
|
import type { ExcalidrawBindableElement } from "./types";
|
||||||
|
|
||||||
export const HEADING_RIGHT = [1, 0] as Heading;
|
export const HEADING_RIGHT = [1, 0] as Heading;
|
||||||
@@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
|
|||||||
export const HEADING_UP = [0, -1] as Heading;
|
export const HEADING_UP = [0, -1] as Heading;
|
||||||
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
||||||
|
|
||||||
export const headingForDiamond = (a: Point, b: Point) => {
|
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
||||||
const angle = lineAngle([a, b]);
|
a: Point,
|
||||||
|
b: Point,
|
||||||
|
) => {
|
||||||
|
const angle = radiansToDegrees(
|
||||||
|
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
|
||||||
|
);
|
||||||
if (angle >= 315 || angle < 45) {
|
if (angle >= 315 || angle < 45) {
|
||||||
return HEADING_UP;
|
return HEADING_UP;
|
||||||
} else if (angle >= 45 && angle < 135) {
|
} else if (angle >= 45 && angle < 135) {
|
||||||
@@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
|
|||||||
// Gets the heading for the point by creating a bounding box around the rotated
|
// Gets the heading for the point by creating a bounding box around the rotated
|
||||||
// close fitting bounding box, then creating 4 search cones around the center of
|
// close fitting bounding box, then creating 4 search cones around the center of
|
||||||
// the external bbox.
|
// the external bbox.
|
||||||
export const headingForPointFromElement = (
|
export const headingForPointFromElement = <
|
||||||
|
Point extends GlobalPoint | LocalPoint,
|
||||||
|
>(
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
element: Readonly<ExcalidrawBindableElement>,
|
||||||
aabb: Readonly<Bounds>,
|
aabb: Readonly<Bounds>,
|
||||||
point: Readonly<Point>,
|
p: Readonly<LocalPoint | GlobalPoint>,
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const SEARCH_CONE_MULTIPLIER = 2;
|
const SEARCH_CONE_MULTIPLIER = 2;
|
||||||
|
|
||||||
const midPoint = getCenterForBounds(aabb);
|
const midPoint = getCenterForBounds(aabb);
|
||||||
|
|
||||||
if (element.type === "diamond") {
|
if (element.type === "diamond") {
|
||||||
if (point[0] < element.x) {
|
if (p[0] < element.x) {
|
||||||
return HEADING_LEFT;
|
return HEADING_LEFT;
|
||||||
} else if (point[1] < element.y) {
|
} else if (p[1] < element.y) {
|
||||||
return HEADING_UP;
|
return HEADING_UP;
|
||||||
} else if (point[0] > element.x + element.width) {
|
} else if (p[0] > element.x + element.width) {
|
||||||
return HEADING_RIGHT;
|
return HEADING_RIGHT;
|
||||||
} else if (point[1] > element.y + element.height) {
|
} else if (p[1] > element.y + element.height) {
|
||||||
return HEADING_DOWN;
|
return HEADING_DOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
const top = rotatePoint(
|
const top = pointRotateRads(
|
||||||
scalePointFromOrigin(
|
pointScaleFromOrigin(
|
||||||
[element.x + element.width / 2, element.y],
|
point(element.x + element.width / 2, element.y),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
),
|
),
|
||||||
midPoint,
|
midPoint,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const right = rotatePoint(
|
const right = pointRotateRads(
|
||||||
scalePointFromOrigin(
|
pointScaleFromOrigin(
|
||||||
[element.x + element.width, element.y + element.height / 2],
|
point(element.x + element.width, element.y + element.height / 2),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
),
|
),
|
||||||
midPoint,
|
midPoint,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const bottom = rotatePoint(
|
const bottom = pointRotateRads(
|
||||||
scalePointFromOrigin(
|
pointScaleFromOrigin(
|
||||||
[element.x + element.width / 2, element.y + element.height],
|
point(element.x + element.width / 2, element.y + element.height),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
),
|
),
|
||||||
midPoint,
|
midPoint,
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
const left = rotatePoint(
|
const left = pointRotateRads(
|
||||||
scalePointFromOrigin(
|
pointScaleFromOrigin(
|
||||||
[element.x, element.y + element.height / 2],
|
point(element.x, element.y + element.height / 2),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
),
|
),
|
||||||
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
|
|||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (PointInTriangle(point, top, right, midPoint)) {
|
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
|
||||||
return headingForDiamond(top, right);
|
return headingForDiamond(top, right);
|
||||||
} else if (PointInTriangle(point, right, bottom, midPoint)) {
|
} else if (
|
||||||
|
triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
|
||||||
|
) {
|
||||||
return headingForDiamond(right, bottom);
|
return headingForDiamond(right, bottom);
|
||||||
} else if (PointInTriangle(point, bottom, left, midPoint)) {
|
} else if (
|
||||||
|
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
||||||
|
) {
|
||||||
return headingForDiamond(bottom, left);
|
return headingForDiamond(bottom, left);
|
||||||
}
|
}
|
||||||
|
|
||||||
return headingForDiamond(left, top);
|
return headingForDiamond(left, top);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topLeft = scalePointFromOrigin(
|
const topLeft = pointScaleFromOrigin(
|
||||||
[aabb[0], aabb[1]],
|
point(aabb[0], aabb[1]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
);
|
) as Point;
|
||||||
const topRight = scalePointFromOrigin(
|
const topRight = pointScaleFromOrigin(
|
||||||
[aabb[2], aabb[1]],
|
point(aabb[2], aabb[1]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
);
|
) as Point;
|
||||||
const bottomLeft = scalePointFromOrigin(
|
const bottomLeft = pointScaleFromOrigin(
|
||||||
[aabb[0], aabb[3]],
|
point(aabb[0], aabb[3]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
);
|
) as Point;
|
||||||
const bottomRight = scalePointFromOrigin(
|
const bottomRight = pointScaleFromOrigin(
|
||||||
[aabb[2], aabb[3]],
|
point(aabb[2], aabb[3]),
|
||||||
midPoint,
|
midPoint,
|
||||||
SEARCH_CONE_MULTIPLIER,
|
SEARCH_CONE_MULTIPLIER,
|
||||||
);
|
) as Point;
|
||||||
|
|
||||||
return PointInTriangle(point, topLeft, topRight, midPoint)
|
return triangleIncludesPoint(
|
||||||
|
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||||
|
p,
|
||||||
|
)
|
||||||
? HEADING_UP
|
? HEADING_UP
|
||||||
: PointInTriangle(point, topRight, bottomRight, midPoint)
|
: triangleIncludesPoint(
|
||||||
|
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||||
|
p,
|
||||||
|
)
|
||||||
? HEADING_RIGHT
|
? HEADING_RIGHT
|
||||||
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
|
: triangleIncludesPoint(
|
||||||
|
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||||
|
p,
|
||||||
|
)
|
||||||
? HEADING_DOWN
|
? HEADING_DOWN
|
||||||
: HEADING_LEFT;
|
: HEADING_LEFT;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const flipHeading = (h: Heading): Heading =>
|
||||||
|
[
|
||||||
|
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
|
||||||
|
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
|
||||||
|
] as Heading;
|
||||||
|
|||||||
@@ -11,19 +11,6 @@ import type {
|
|||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
|
||||||
distance2d,
|
|
||||||
rotate,
|
|
||||||
isPathALoop,
|
|
||||||
getGridPoint,
|
|
||||||
rotatePoint,
|
|
||||||
centerPoint,
|
|
||||||
getControlPointsForBezierCurve,
|
|
||||||
getBezierXY,
|
|
||||||
getBezierCurveLength,
|
|
||||||
mapIntervalToBezierT,
|
|
||||||
arePointsEqual,
|
|
||||||
} from "../math";
|
|
||||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +19,6 @@ import {
|
|||||||
getMinMaxXYFromCurvePathOps,
|
getMinMaxXYFromCurvePathOps,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
Point,
|
|
||||||
AppState,
|
AppState,
|
||||||
PointerCoords,
|
PointerCoords,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
@@ -46,7 +32,7 @@ import {
|
|||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { toBrandedType, tupleToCoors } from "../utils";
|
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
|
|||||||
import type { Store } from "../store";
|
import type { Store } from "../store";
|
||||||
import { mutateElbowArrow } from "./routing";
|
import { mutateElbowArrow } from "./routing";
|
||||||
import type Scene from "../scene/Scene";
|
import type Scene from "../scene/Scene";
|
||||||
|
import type { Radians } from "../../math";
|
||||||
|
import {
|
||||||
|
pointCenter,
|
||||||
|
point,
|
||||||
|
pointRotateRads,
|
||||||
|
pointsEqual,
|
||||||
|
vector,
|
||||||
|
type GlobalPoint,
|
||||||
|
type LocalPoint,
|
||||||
|
pointDistance,
|
||||||
|
} from "../../math";
|
||||||
|
import {
|
||||||
|
getBezierCurveLength,
|
||||||
|
getBezierXY,
|
||||||
|
getControlPointsForBezierCurve,
|
||||||
|
isPathALoop,
|
||||||
|
mapIntervalToBezierT,
|
||||||
|
} from "../shapes";
|
||||||
|
import { getGridPoint } from "../snapping";
|
||||||
|
|
||||||
const editorMidPointsCache: {
|
const editorMidPointsCache: {
|
||||||
version: number | null;
|
version: number | null;
|
||||||
points: (Point | null)[];
|
points: (GlobalPoint | null)[];
|
||||||
zoom: number | null;
|
zoom: number | null;
|
||||||
} = { version: null, points: [], zoom: null };
|
} = { version: null, points: [], zoom: null };
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
@@ -80,7 +85,7 @@ export class LinearElementEditor {
|
|||||||
lastClickedIsEndPoint: boolean;
|
lastClickedIsEndPoint: boolean;
|
||||||
origin: Readonly<{ x: number; y: number }> | null;
|
origin: Readonly<{ x: number; y: number }> | null;
|
||||||
segmentMidpoint: {
|
segmentMidpoint: {
|
||||||
value: Point | null;
|
value: GlobalPoint | null;
|
||||||
index: number | null;
|
index: number | null;
|
||||||
added: boolean;
|
added: boolean;
|
||||||
};
|
};
|
||||||
@@ -88,7 +93,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
/** whether you're dragging a point */
|
/** whether you're dragging a point */
|
||||||
public readonly isDragging: boolean;
|
public readonly isDragging: boolean;
|
||||||
public readonly lastUncommittedPoint: Point | null;
|
public readonly lastUncommittedPoint: LocalPoint | null;
|
||||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||||
public readonly startBindingElement:
|
public readonly startBindingElement:
|
||||||
| ExcalidrawBindableElement
|
| ExcalidrawBindableElement
|
||||||
@@ -96,13 +101,14 @@ export class LinearElementEditor {
|
|||||||
| "keep";
|
| "keep";
|
||||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
public readonly hoverPointIndex: number;
|
public readonly hoverPointIndex: number;
|
||||||
public readonly segmentMidPointHoveredCoords: Point | 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 & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
if (!arePointsEqual(element.points[0], [0, 0])) {
|
if (!pointsEqual(element.points[0], point(0, 0))) {
|
||||||
console.error("Linear element is not normalized", Error().stack);
|
console.error("Linear element is not normalized", Error().stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +132,7 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
this.hoverPointIndex = -1;
|
this.hoverPointIndex = -1;
|
||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
|
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -280,7 +287,7 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
referencePoint,
|
referencePoint,
|
||||||
[scenePointerX, scenePointerY],
|
point(scenePointerX, scenePointerY),
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -289,7 +296,10 @@ export class LinearElementEditor {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
index: selectedIndex,
|
index: selectedIndex,
|
||||||
point: [width + referencePoint[0], height + referencePoint[1]],
|
point: point(
|
||||||
|
width + referencePoint[0],
|
||||||
|
height + referencePoint[1],
|
||||||
|
),
|
||||||
isDragging: selectedIndex === lastClickedPoint,
|
isDragging: selectedIndex === lastClickedPoint,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -310,7 +320,7 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition =
|
const newPointPosition: LocalPoint =
|
||||||
pointIndex === lastClickedPoint
|
pointIndex === lastClickedPoint
|
||||||
? LinearElementEditor.createPointAt(
|
? LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@@ -319,10 +329,10 @@ export class LinearElementEditor {
|
|||||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
)
|
)
|
||||||
: ([
|
: point(
|
||||||
element.points[pointIndex][0] + deltaX,
|
element.points[pointIndex][0] + deltaX,
|
||||||
element.points[pointIndex][1] + deltaY,
|
element.points[pointIndex][1] + deltaY,
|
||||||
] as const);
|
);
|
||||||
return {
|
return {
|
||||||
index: pointIndex,
|
index: pointIndex,
|
||||||
point: newPointPosition,
|
point: newPointPosition,
|
||||||
@@ -515,7 +525,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const midpoints: (Point | null)[] = [];
|
const midpoints: (GlobalPoint | null)[] = [];
|
||||||
while (index < points.length - 1) {
|
while (index < points.length - 1) {
|
||||||
if (
|
if (
|
||||||
LinearElementEditor.isSegmentTooShort(
|
LinearElementEditor.isSegmentTooShort(
|
||||||
@@ -549,7 +559,7 @@ export class LinearElementEditor {
|
|||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
): GlobalPoint | null => {
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -579,11 +589,12 @@ export class LinearElementEditor {
|
|||||||
const existingSegmentMidpointHitCoords =
|
const existingSegmentMidpointHitCoords =
|
||||||
linearElementEditor.segmentMidPointHoveredCoords;
|
linearElementEditor.segmentMidPointHoveredCoords;
|
||||||
if (existingSegmentMidpointHitCoords) {
|
if (existingSegmentMidpointHitCoords) {
|
||||||
const distance = distance2d(
|
const distance = pointDistance(
|
||||||
existingSegmentMidpointHitCoords[0],
|
point(
|
||||||
existingSegmentMidpointHitCoords[1],
|
existingSegmentMidpointHitCoords[0],
|
||||||
scenePointer.x,
|
existingSegmentMidpointHitCoords[1],
|
||||||
scenePointer.y,
|
),
|
||||||
|
point(scenePointer.x, scenePointer.y),
|
||||||
);
|
);
|
||||||
if (distance <= threshold) {
|
if (distance <= threshold) {
|
||||||
return existingSegmentMidpointHitCoords;
|
return existingSegmentMidpointHitCoords;
|
||||||
@@ -594,11 +605,9 @@ export class LinearElementEditor {
|
|||||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||||
while (index < midPoints.length) {
|
while (index < midPoints.length) {
|
||||||
if (midPoints[index] !== null) {
|
if (midPoints[index] !== null) {
|
||||||
const distance = distance2d(
|
const distance = pointDistance(
|
||||||
midPoints[index]![0],
|
point(midPoints[index]![0], midPoints[index]![1]),
|
||||||
midPoints[index]![1],
|
point(scenePointer.x, scenePointer.y),
|
||||||
scenePointer.x,
|
|
||||||
scenePointer.y,
|
|
||||||
);
|
);
|
||||||
if (distance <= threshold) {
|
if (distance <= threshold) {
|
||||||
return midPoints[index];
|
return midPoints[index];
|
||||||
@@ -612,15 +621,13 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static isSegmentTooShort(
|
static isSegmentTooShort(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: Point,
|
startPoint: GlobalPoint | LocalPoint,
|
||||||
endPoint: Point,
|
endPoint: GlobalPoint | LocalPoint,
|
||||||
zoom: AppState["zoom"],
|
zoom: AppState["zoom"],
|
||||||
) {
|
) {
|
||||||
let distance = distance2d(
|
let distance = pointDistance(
|
||||||
startPoint[0],
|
point(startPoint[0], startPoint[1]),
|
||||||
startPoint[1],
|
point(endPoint[0], endPoint[1]),
|
||||||
endPoint[0],
|
|
||||||
endPoint[1],
|
|
||||||
);
|
);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
distance = getBezierCurveLength(element, endPoint);
|
distance = getBezierCurveLength(element, endPoint);
|
||||||
@@ -631,12 +638,12 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static getSegmentMidPoint(
|
static getSegmentMidPoint(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: Point,
|
startPoint: GlobalPoint,
|
||||||
endPoint: Point,
|
endPoint: GlobalPoint,
|
||||||
endPointIndex: number,
|
endPointIndex: number,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) {
|
): GlobalPoint {
|
||||||
let segmentMidPoint = centerPoint(startPoint, endPoint);
|
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
const controlPoints = getControlPointsForBezierCurve(
|
const controlPoints = getControlPointsForBezierCurve(
|
||||||
element,
|
element,
|
||||||
@@ -649,16 +656,15 @@ export class LinearElementEditor {
|
|||||||
0.5,
|
0.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [tx, ty] = getBezierXY(
|
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
[tx, ty],
|
getBezierXY(
|
||||||
|
controlPoints[0],
|
||||||
|
controlPoints[1],
|
||||||
|
controlPoints[2],
|
||||||
|
controlPoints[3],
|
||||||
|
t,
|
||||||
|
),
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -670,7 +676,7 @@ export class LinearElementEditor {
|
|||||||
static getSegmentMidPointIndex(
|
static getSegmentMidPointIndex(
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
midPoint: Point,
|
midPoint: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) {
|
) {
|
||||||
const element = LinearElementEditor.getElement(
|
const element = LinearElementEditor.getElement(
|
||||||
@@ -822,11 +828,12 @@ export class LinearElementEditor {
|
|||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
const targetPoint =
|
const targetPoint =
|
||||||
clickedPointIndex > -1 &&
|
clickedPointIndex > -1 &&
|
||||||
rotate(
|
pointRotateRads(
|
||||||
element.x + element.points[clickedPointIndex][0],
|
point(
|
||||||
element.y + element.points[clickedPointIndex][1],
|
element.x + element.points[clickedPointIndex][0],
|
||||||
cx,
|
element.y + element.points[clickedPointIndex][1],
|
||||||
cy,
|
),
|
||||||
|
point(cx, cy),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -865,14 +872,17 @@ export class LinearElementEditor {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
static arePointsEqual(point1: Point | null, point2: Point | null) {
|
static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
|
||||||
|
point1: Point | null,
|
||||||
|
point2: Point | null,
|
||||||
|
) {
|
||||||
if (!point1 && !point2) {
|
if (!point1 && !point2) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!point1 || !point2) {
|
if (!point1 || !point2) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return arePointsEqual(point1, point2);
|
return pointsEqual(point1, point2);
|
||||||
}
|
}
|
||||||
|
|
||||||
static handlePointerMove(
|
static handlePointerMove(
|
||||||
@@ -909,7 +919,7 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPoint: Point;
|
let newPoint: LocalPoint;
|
||||||
|
|
||||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||||
const lastCommittedPoint = points[points.length - 2];
|
const lastCommittedPoint = points[points.length - 2];
|
||||||
@@ -918,14 +928,14 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
lastCommittedPoint,
|
lastCommittedPoint,
|
||||||
[scenePointerX, scenePointerY],
|
point(scenePointerX, scenePointerY),
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
);
|
);
|
||||||
|
|
||||||
newPoint = [
|
newPoint = point(
|
||||||
width + lastCommittedPoint[0],
|
width + lastCommittedPoint[0],
|
||||||
height + lastCommittedPoint[1],
|
height + lastCommittedPoint[1],
|
||||||
];
|
);
|
||||||
} else {
|
} else {
|
||||||
newPoint = LinearElementEditor.createPointAt(
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
@@ -965,30 +975,36 @@ export class LinearElementEditor {
|
|||||||
/** scene coords */
|
/** scene coords */
|
||||||
static getPointGlobalCoordinates(
|
static getPointGlobalCoordinates(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
point: Point,
|
p: LocalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) {
|
): GlobalPoint {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
|
||||||
let { x, y } = element;
|
const { x, y } = element;
|
||||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
return pointRotateRads(
|
||||||
return [x, y] as const;
|
point(x + p[0], y + p[1]),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** scene coords */
|
/** scene coords */
|
||||||
static getPointsGlobalCoordinates(
|
static getPointsGlobalCoordinates(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point[] {
|
): GlobalPoint[] {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
return element.points.map((point) => {
|
return element.points.map((p) => {
|
||||||
let { x, y } = element;
|
const { x, y } = element;
|
||||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
return pointRotateRads(
|
||||||
return [x, y] as const;
|
point(x + p[0], y + p[1]),
|
||||||
|
point(cx, cy),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -997,7 +1013,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
indexMaybeFromEnd: number, // -1 for last element
|
indexMaybeFromEnd: number, // -1 for last element
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point {
|
): GlobalPoint {
|
||||||
const index =
|
const index =
|
||||||
indexMaybeFromEnd < 0
|
indexMaybeFromEnd < 0
|
||||||
? element.points.length + indexMaybeFromEnd
|
? element.points.length + indexMaybeFromEnd
|
||||||
@@ -1005,35 +1021,36 @@ export class LinearElementEditor {
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
|
const p = element.points[index];
|
||||||
const point = element.points[index];
|
|
||||||
const { x, y } = element;
|
const { x, y } = element;
|
||||||
return point
|
|
||||||
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
return p
|
||||||
: rotate(x, y, cx, cy, element.angle);
|
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
|
||||||
|
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
|
||||||
}
|
}
|
||||||
|
|
||||||
static pointFromAbsoluteCoords(
|
static pointFromAbsoluteCoords(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
absoluteCoords: Point,
|
absoluteCoords: GlobalPoint,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
): Point {
|
): LocalPoint {
|
||||||
if (isElbowArrow(element)) {
|
if (isElbowArrow(element)) {
|
||||||
// No rotation for elbow arrows
|
// No rotation for elbow arrows
|
||||||
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
|
return point(
|
||||||
|
absoluteCoords[0] - element.x,
|
||||||
|
absoluteCoords[1] - element.y,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
const [x, y] = rotate(
|
const [x, y] = pointRotateRads(
|
||||||
absoluteCoords[0],
|
point(absoluteCoords[0], absoluteCoords[1]),
|
||||||
absoluteCoords[1],
|
point(cx, cy),
|
||||||
cx,
|
-element.angle as Radians,
|
||||||
cy,
|
|
||||||
-element.angle,
|
|
||||||
);
|
);
|
||||||
return [x - element.x, y - element.y];
|
return point(x - element.x, y - element.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPointIndexUnderCursor(
|
static getPointIndexUnderCursor(
|
||||||
@@ -1052,9 +1069,9 @@ export class LinearElementEditor {
|
|||||||
// points on the left, thus should take precedence when clicking, if they
|
// points on the left, thus should take precedence when clicking, if they
|
||||||
// overlap
|
// overlap
|
||||||
while (--idx > -1) {
|
while (--idx > -1) {
|
||||||
const point = pointHandles[idx];
|
const p = pointHandles[idx];
|
||||||
if (
|
if (
|
||||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
|
||||||
// +1px to account for outline stroke
|
// +1px to account for outline stroke
|
||||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||||
) {
|
) {
|
||||||
@@ -1070,20 +1087,18 @@ export class LinearElementEditor {
|
|||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
gridSize: NullableGridSize,
|
gridSize: NullableGridSize,
|
||||||
): Point {
|
): LocalPoint {
|
||||||
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
|
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
const [rotatedX, rotatedY] = rotate(
|
const [rotatedX, rotatedY] = pointRotateRads(
|
||||||
pointerOnGrid[0],
|
point(pointerOnGrid[0], pointerOnGrid[1]),
|
||||||
pointerOnGrid[1],
|
point(cx, cy),
|
||||||
cx,
|
-element.angle as Radians,
|
||||||
cy,
|
|
||||||
-element.angle,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return [rotatedX - element.x, rotatedY - element.y];
|
return point(rotatedX - element.x, rotatedY - element.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1091,15 +1106,19 @@ export class LinearElementEditor {
|
|||||||
* expected in various parts of the codebase. Also returns new x/y to account
|
* expected in various parts of the codebase. Also returns new x/y to account
|
||||||
* for the potential normalization.
|
* for the potential normalization.
|
||||||
*/
|
*/
|
||||||
static getNormalizedPoints(element: ExcalidrawLinearElement) {
|
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
||||||
|
points: LocalPoint[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
const offsetX = points[0][0];
|
const offsetX = points[0][0];
|
||||||
const offsetY = points[0][1];
|
const offsetY = points[0][1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points: points.map((point) => {
|
points: points.map((p) => {
|
||||||
return [point[0] - offsetX, point[1] - offsetY] as const;
|
return point(p[0] - offsetX, p[1] - offsetY);
|
||||||
}),
|
}),
|
||||||
x: element.x + offsetX,
|
x: element.x + offsetX,
|
||||||
y: element.y + offsetY,
|
y: element.y + offsetY,
|
||||||
@@ -1116,17 +1135,23 @@ export class LinearElementEditor {
|
|||||||
static duplicateSelectedPoints(
|
static duplicateSelectedPoints(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) {
|
): AppState {
|
||||||
if (!appState.editingLinearElement) {
|
invariant(
|
||||||
return false;
|
appState.editingLinearElement,
|
||||||
}
|
"Not currently editing a linear element",
|
||||||
|
);
|
||||||
|
|
||||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (!element || selectedPointsIndices === null) {
|
invariant(
|
||||||
return false;
|
element,
|
||||||
}
|
"The linear element does not exist in the provided Scene",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
selectedPointsIndices != null,
|
||||||
|
"There are no selected points to duplicate",
|
||||||
|
);
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
@@ -1134,9 +1159,9 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
let pointAddedToEnd = false;
|
let pointAddedToEnd = false;
|
||||||
let indexCursor = -1;
|
let indexCursor = -1;
|
||||||
const nextPoints = points.reduce((acc: Point[], point, index) => {
|
const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
|
||||||
++indexCursor;
|
++indexCursor;
|
||||||
acc.push(point);
|
acc.push(p);
|
||||||
|
|
||||||
const isSelected = selectedPointsIndices.includes(index);
|
const isSelected = selectedPointsIndices.includes(index);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -1147,8 +1172,8 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
acc.push(
|
acc.push(
|
||||||
nextPoint
|
nextPoint
|
||||||
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
|
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||||
: [point[0], point[1]],
|
: point(p[0], p[1]),
|
||||||
);
|
);
|
||||||
|
|
||||||
nextSelectedIndices.push(indexCursor + 1);
|
nextSelectedIndices.push(indexCursor + 1);
|
||||||
@@ -1169,7 +1194,7 @@ export class LinearElementEditor {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
index: element.points.length - 1,
|
index: element.points.length - 1,
|
||||||
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -1177,12 +1202,10 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
...appState,
|
||||||
...appState,
|
editingLinearElement: {
|
||||||
editingLinearElement: {
|
...appState.editingLinearElement,
|
||||||
...appState.editingLinearElement,
|
selectedPointsIndices: nextSelectedIndices,
|
||||||
selectedPointsIndices: nextSelectedIndices,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1209,10 +1232,10 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
|
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
||||||
if (!pointIndices.includes(idx)) {
|
if (!pointIndices.includes(idx)) {
|
||||||
acc.push(
|
acc.push(
|
||||||
!acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
|
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -1229,7 +1252,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { point: Point }[],
|
targetPoints: { point: LocalPoint }[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const offsetX = 0;
|
||||||
@@ -1247,7 +1270,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static movePoints(
|
static movePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
otherUpdates?: {
|
otherUpdates?: {
|
||||||
startBinding?: PointBinding | null;
|
startBinding?: PointBinding | null;
|
||||||
@@ -1277,11 +1300,11 @@ export class LinearElementEditor {
|
|||||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextPoints = points.map((point, idx) => {
|
const nextPoints: LocalPoint[] = points.map((p, idx) => {
|
||||||
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
const selectedPointData = targetPoints.find((t) => t.index === idx);
|
||||||
if (selectedPointData) {
|
if (selectedPointData) {
|
||||||
if (selectedPointData.index === 0) {
|
if (selectedPointData.index === 0) {
|
||||||
return point;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaX =
|
const deltaX =
|
||||||
@@ -1289,14 +1312,9 @@ export class LinearElementEditor {
|
|||||||
const deltaY =
|
const deltaY =
|
||||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||||
|
|
||||||
return [
|
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||||
point[0] + deltaX - offsetX,
|
|
||||||
point[1] + deltaY - offsetY,
|
|
||||||
] as const;
|
|
||||||
}
|
}
|
||||||
return offsetX || offsetY
|
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
|
||||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
|
||||||
: point;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
@@ -1349,11 +1367,9 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const origin = linearElementEditor.pointerDownState.origin!;
|
const origin = linearElementEditor.pointerDownState.origin!;
|
||||||
const dist = distance2d(
|
const dist = pointDistance(
|
||||||
origin.x,
|
point(origin.x, origin.y),
|
||||||
origin.y,
|
point(pointerCoords.x, pointerCoords.y),
|
||||||
pointerCoords.x,
|
|
||||||
pointerCoords.y,
|
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.editingLinearElement &&
|
||||||
@@ -1418,7 +1434,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
private static _updatePoints(
|
private static _updatePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
nextPoints: readonly Point[],
|
nextPoints: readonly LocalPoint[],
|
||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
@@ -1461,9 +1477,11 @@ export class LinearElementEditor {
|
|||||||
element,
|
element,
|
||||||
mergedElementsMap,
|
mergedElementsMap,
|
||||||
nextPoints,
|
nextPoints,
|
||||||
[offsetX, offsetY],
|
vector(offsetX, offsetY),
|
||||||
bindings,
|
bindings,
|
||||||
options,
|
{
|
||||||
|
isDragging: options?.isDragging,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
@@ -1474,7 +1492,11 @@ export class LinearElementEditor {
|
|||||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||||
const dX = prevCenterX - nextCenterX;
|
const dX = prevCenterX - nextCenterX;
|
||||||
const dY = prevCenterY - nextCenterY;
|
const dY = prevCenterY - nextCenterY;
|
||||||
const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
|
const rotated = pointRotateRads(
|
||||||
|
point(offsetX, offsetY),
|
||||||
|
point(dX, dY),
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
...otherUpdates,
|
...otherUpdates,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
@@ -1487,8 +1509,8 @@ export class LinearElementEditor {
|
|||||||
private static _getShiftLockedDelta(
|
private static _getShiftLockedDelta(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
referencePoint: Point,
|
referencePoint: LocalPoint,
|
||||||
scenePointer: Point,
|
scenePointer: GlobalPoint,
|
||||||
gridSize: NullableGridSize,
|
gridSize: NullableGridSize,
|
||||||
) {
|
) {
|
||||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
@@ -1517,7 +1539,11 @@ export class LinearElementEditor {
|
|||||||
gridY,
|
gridY,
|
||||||
);
|
);
|
||||||
|
|
||||||
return rotatePoint([width, height], [0, 0], -element.angle);
|
return pointRotateRads(
|
||||||
|
point(width, height),
|
||||||
|
point(0, 0),
|
||||||
|
-element.angle as Radians,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getBoundTextElementPosition = (
|
static getBoundTextElementPosition = (
|
||||||
@@ -1548,7 +1574,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||||
if (element.points.length === 2) {
|
if (element.points.length === 2) {
|
||||||
midSegmentMidpoint = centerPoint(points[0], points[1]);
|
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!midSegmentMidpoint ||
|
!midSegmentMidpoint ||
|
||||||
@@ -1585,37 +1611,38 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
||||||
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
||||||
|
const centerPoint = point(cx, cy);
|
||||||
|
|
||||||
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
|
const topLeftRotatedPoint = pointRotateRads(
|
||||||
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
|
point(x1, y1),
|
||||||
|
centerPoint,
|
||||||
const counterRotateBoundTextTopLeft = rotatePoint(
|
element.angle,
|
||||||
[boundTextX1, boundTextY1],
|
|
||||||
|
|
||||||
[cx, cy],
|
|
||||||
|
|
||||||
-element.angle,
|
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextTopRight = rotatePoint(
|
const topRightRotatedPoint = pointRotateRads(
|
||||||
[boundTextX2, boundTextY1],
|
point(x2, y1),
|
||||||
|
centerPoint,
|
||||||
[cx, cy],
|
element.angle,
|
||||||
|
|
||||||
-element.angle,
|
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextBottomLeft = rotatePoint(
|
|
||||||
[boundTextX1, boundTextY2],
|
|
||||||
|
|
||||||
[cx, cy],
|
const counterRotateBoundTextTopLeft = pointRotateRads(
|
||||||
|
point(boundTextX1, boundTextY1),
|
||||||
-element.angle,
|
centerPoint,
|
||||||
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const counterRotateBoundTextBottomRight = rotatePoint(
|
const counterRotateBoundTextTopRight = pointRotateRads(
|
||||||
[boundTextX2, boundTextY2],
|
point(boundTextX2, boundTextY1),
|
||||||
|
centerPoint,
|
||||||
[cx, cy],
|
-element.angle as Radians,
|
||||||
|
);
|
||||||
-element.angle,
|
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
||||||
|
point(boundTextX1, boundTextY2),
|
||||||
|
centerPoint,
|
||||||
|
-element.angle as Radians,
|
||||||
|
);
|
||||||
|
const counterRotateBoundTextBottomRight = pointRotateRads(
|
||||||
|
point(boundTextX2, boundTextY2),
|
||||||
|
centerPoint,
|
||||||
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
|
|||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import { randomInteger } from "../random";
|
import { randomInteger } from "../random";
|
||||||
import type { Point } from "../types";
|
|
||||||
import { getUpdatedTimestamp } from "../utils";
|
import { getUpdatedTimestamp } from "../utils";
|
||||||
import type { Mutable } from "../utility-types";
|
import type { Mutable } from "../utility-types";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
@@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
let didChangePoints = false;
|
let didChangePoints = false;
|
||||||
let index = prevPoints.length;
|
let index = prevPoints.length;
|
||||||
while (--index) {
|
while (--index) {
|
||||||
const prevPoint: Point = prevPoints[index];
|
const prevPoint = prevPoints[index];
|
||||||
const nextPoint: Point = nextPoints[index];
|
const nextPoint = nextPoints[index];
|
||||||
if (
|
if (
|
||||||
prevPoint[0] !== nextPoint[0] ||
|
prevPoint[0] !== nextPoint[0] ||
|
||||||
prevPoint[1] !== nextPoint[1]
|
prevPoint[1] !== nextPoint[1]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
|
|||||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||||
import { isPrimitive } from "../utils";
|
import { isPrimitive } from "../utils";
|
||||||
import type { ExcalidrawLinearElement } from "./types";
|
import type { ExcalidrawLinearElement } from "./types";
|
||||||
|
import type { LocalPoint } from "../../math";
|
||||||
|
import { point } from "../../math";
|
||||||
|
|
||||||
const assertCloneObjects = (source: any, clone: any) => {
|
const assertCloneObjects = (source: any, clone: any) => {
|
||||||
for (const key in clone) {
|
for (const key in clone) {
|
||||||
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
|
|||||||
element.__proto__ = { hello: "world" };
|
element.__proto__ = { hello: "world" };
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
points: [
|
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
|
||||||
[1, 2],
|
|
||||||
[3, 4],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element);
|
const copy = duplicateElement(null, new Map(), element);
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
|
|||||||
import { getNewGroupIdsForDuplication } from "../groups";
|
import { getNewGroupIdsForDuplication } from "../groups";
|
||||||
import type { AppState } from "../types";
|
import type { AppState } from "../types";
|
||||||
import { getElementAbsoluteCoords } from ".";
|
import { getElementAbsoluteCoords } from ".";
|
||||||
import { adjustXYWithRotation } from "../math";
|
|
||||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||||
import {
|
import {
|
||||||
measureText,
|
measureText,
|
||||||
@@ -48,6 +47,7 @@ import {
|
|||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||||
import { getLineHeight } from "../fonts";
|
import { getLineHeight } from "../fonts";
|
||||||
|
import type { Radians } from "../../math";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||||
@@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
||||||
width = 0,
|
width = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
angle = 0,
|
angle = 0 as Radians,
|
||||||
groupIds = [],
|
groupIds = [],
|
||||||
frameId = null,
|
frameId = null,
|
||||||
index = null,
|
index = null,
|
||||||
@@ -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> => {
|
||||||
@@ -348,6 +347,53 @@ const getAdjustedDimensions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const adjustXYWithRotation = (
|
||||||
|
sides: {
|
||||||
|
n?: boolean;
|
||||||
|
e?: boolean;
|
||||||
|
s?: boolean;
|
||||||
|
w?: boolean;
|
||||||
|
},
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
angle: number,
|
||||||
|
deltaX1: number,
|
||||||
|
deltaY1: number,
|
||||||
|
deltaX2: number,
|
||||||
|
deltaY2: number,
|
||||||
|
): [number, number] => {
|
||||||
|
const cos = Math.cos(angle);
|
||||||
|
const sin = Math.sin(angle);
|
||||||
|
if (sides.e && sides.w) {
|
||||||
|
x += deltaX1 + deltaX2;
|
||||||
|
} else if (sides.e) {
|
||||||
|
x += deltaX1 * (1 + cos);
|
||||||
|
y += deltaX1 * sin;
|
||||||
|
x += deltaX2 * (1 - cos);
|
||||||
|
y += deltaX2 * -sin;
|
||||||
|
} else if (sides.w) {
|
||||||
|
x += deltaX1 * (1 - cos);
|
||||||
|
y += deltaX1 * -sin;
|
||||||
|
x += deltaX2 * (1 + cos);
|
||||||
|
y += deltaX2 * sin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sides.n && sides.s) {
|
||||||
|
y += deltaY1 + deltaY2;
|
||||||
|
} else if (sides.n) {
|
||||||
|
x += deltaY1 * sin;
|
||||||
|
y += deltaY1 * (1 - cos);
|
||||||
|
x += deltaY2 * -sin;
|
||||||
|
y += deltaY2 * (1 + cos);
|
||||||
|
} else if (sides.s) {
|
||||||
|
x += deltaY1 * -sin;
|
||||||
|
y += deltaY1 * (1 + cos);
|
||||||
|
x += deltaY2 * sin;
|
||||||
|
y += deltaY2 * (1 - cos);
|
||||||
|
}
|
||||||
|
return [x, y];
|
||||||
|
};
|
||||||
|
|
||||||
export const refreshTextDimensions = (
|
export const refreshTextDimensions = (
|
||||||
textElement: ExcalidrawTextElement,
|
textElement: ExcalidrawTextElement,
|
||||||
container: ExcalidrawTextContainer | null,
|
container: ExcalidrawTextContainer | null,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||||
import { rescalePoints } from "../points";
|
import { rescalePoints } from "../points";
|
||||||
|
|
||||||
import { rotate, centerPoint, rotatePoint } from "../math";
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -11,6 +9,7 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
|
ExcalidrawArrowElement,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -38,7 +37,7 @@ import type {
|
|||||||
MaybeTransformHandleType,
|
MaybeTransformHandleType,
|
||||||
TransformHandleDirection,
|
TransformHandleDirection,
|
||||||
} from "./transformHandles";
|
} from "./transformHandles";
|
||||||
import type { Point, PointerDownState } from "../types";
|
import type { PointerDownState } from "../types";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import {
|
import {
|
||||||
getApproxMinLineWidth,
|
getApproxMinLineWidth,
|
||||||
@@ -55,16 +54,15 @@ import {
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { isInGroup } from "../groups";
|
import { isInGroup } from "../groups";
|
||||||
import { mutateElbowArrow } from "./routing";
|
import { mutateElbowArrow } from "./routing";
|
||||||
|
import type { GlobalPoint } from "../../math";
|
||||||
export const normalizeAngle = (angle: number): number => {
|
import {
|
||||||
if (angle < 0) {
|
pointCenter,
|
||||||
return angle + 2 * Math.PI;
|
normalizeRadians,
|
||||||
}
|
point,
|
||||||
if (angle >= 2 * Math.PI) {
|
pointFromPair,
|
||||||
return angle - 2 * Math.PI;
|
pointRotateRads,
|
||||||
}
|
type Radians,
|
||||||
return angle;
|
} from "../../math";
|
||||||
};
|
|
||||||
|
|
||||||
// Returns true when transform (resizing/rotation) happened
|
// Returns true when transform (resizing/rotation) happened
|
||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
@@ -158,16 +156,17 @@ const rotateSingleElement = (
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
let angle: number;
|
let angle: Radians;
|
||||||
if (isFrameLikeElement(element)) {
|
if (isFrameLikeElement(element)) {
|
||||||
angle = 0;
|
angle = 0 as Radians;
|
||||||
} else {
|
} else {
|
||||||
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
angle = ((5 * Math.PI) / 2 +
|
||||||
|
Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
|
||||||
if (shouldRotateWithDiscreteAngle) {
|
if (shouldRotateWithDiscreteAngle) {
|
||||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
|
||||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
|
||||||
}
|
}
|
||||||
angle = normalizeAngle(angle);
|
angle = normalizeRadians(angle as Radians);
|
||||||
}
|
}
|
||||||
const boundTextElementId = getBoundTextElementId(element);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
@@ -240,12 +239,10 @@ const resizeSingleTextElement = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
// rotation pointer with reverse angle
|
// rotation pointer with reverse angle
|
||||||
const [rotatedX, rotatedY] = rotate(
|
const [rotatedX, rotatedY] = pointRotateRads(
|
||||||
pointerX,
|
point(pointerX, pointerY),
|
||||||
pointerY,
|
point(cx, cy),
|
||||||
cx,
|
-element.angle as Radians,
|
||||||
cy,
|
|
||||||
-element.angle,
|
|
||||||
);
|
);
|
||||||
let scaleX = 0;
|
let scaleX = 0;
|
||||||
let scaleY = 0;
|
let scaleY = 0;
|
||||||
@@ -279,20 +276,26 @@ const resizeSingleTextElement = (
|
|||||||
const startBottomRight = [x2, y2];
|
const startBottomRight = [x2, y2];
|
||||||
const startCenter = [cx, cy];
|
const startCenter = [cx, cy];
|
||||||
|
|
||||||
let newTopLeft = [x1, y1] as [number, number];
|
let newTopLeft = point<GlobalPoint>(x1, y1);
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||||
newTopLeft = [
|
newTopLeft = point<GlobalPoint>(
|
||||||
startBottomRight[0] - Math.abs(nextWidth),
|
startBottomRight[0] - Math.abs(nextWidth),
|
||||||
startBottomRight[1] - Math.abs(nextHeight),
|
startBottomRight[1] - Math.abs(nextHeight),
|
||||||
];
|
);
|
||||||
}
|
}
|
||||||
if (transformHandleType === "ne") {
|
if (transformHandleType === "ne") {
|
||||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
|
newTopLeft = point<GlobalPoint>(
|
||||||
|
bottomLeft[0],
|
||||||
|
bottomLeft[1] - Math.abs(nextHeight),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (transformHandleType === "sw") {
|
if (transformHandleType === "sw") {
|
||||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||||
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
|
newTopLeft = point<GlobalPoint>(
|
||||||
|
topRight[0] - Math.abs(nextWidth),
|
||||||
|
topRight[1],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["s", "n"].includes(transformHandleType)) {
|
if (["s", "n"].includes(transformHandleType)) {
|
||||||
@@ -308,13 +311,17 @@ const resizeSingleTextElement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const angle = element.angle;
|
const angle = element.angle;
|
||||||
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
|
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
|
||||||
const newCenter: Point = [
|
const newCenter = point<GlobalPoint>(
|
||||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||||
];
|
);
|
||||||
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
|
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
|
||||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
newTopLeft = pointRotateRads(
|
||||||
|
rotatedTopLeft,
|
||||||
|
rotatedNewCenter,
|
||||||
|
-angle as Radians,
|
||||||
|
);
|
||||||
const [nextX, nextY] = newTopLeft;
|
const [nextX, nextY] = newTopLeft;
|
||||||
|
|
||||||
mutateElement(element, {
|
mutateElement(element, {
|
||||||
@@ -334,14 +341,14 @@ const resizeSingleTextElement = (
|
|||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft: Point = [x1, y1];
|
const startTopLeft = point<GlobalPoint>(x1, y1);
|
||||||
const startBottomRight: Point = [x2, y2];
|
const startBottomRight = point<GlobalPoint>(x2, y2);
|
||||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||||
|
|
||||||
const rotatedPointer = rotatePoint(
|
const rotatedPointer = pointRotateRads(
|
||||||
[pointerX, pointerY],
|
point(pointerX, pointerY),
|
||||||
startCenter,
|
startCenter,
|
||||||
-stateAtResizeStart.angle,
|
-stateAtResizeStart.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
||||||
@@ -407,13 +414,21 @@ const resizeSingleTextElement = (
|
|||||||
|
|
||||||
// adjust topLeft to new rotation point
|
// adjust topLeft to new rotation point
|
||||||
const angle = stateAtResizeStart.angle;
|
const angle = stateAtResizeStart.angle;
|
||||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
const rotatedTopLeft = pointRotateRads(
|
||||||
const newCenter: Point = [
|
pointFromPair(newTopLeft),
|
||||||
|
startCenter,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
const newCenter = point(
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
];
|
);
|
||||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
newTopLeft = pointRotateRads(
|
||||||
|
rotatedTopLeft,
|
||||||
|
rotatedNewCenter,
|
||||||
|
-angle as Radians,
|
||||||
|
);
|
||||||
|
|
||||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(newWidth),
|
||||||
@@ -446,15 +461,15 @@ export const resizeSingleElement = (
|
|||||||
stateAtResizeStart.height,
|
stateAtResizeStart.height,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const startTopLeft: Point = [x1, y1];
|
const startTopLeft = point(x1, y1);
|
||||||
const startBottomRight: Point = [x2, y2];
|
const startBottomRight = point(x2, y2);
|
||||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||||
|
|
||||||
// Calculate new dimensions based on cursor position
|
// Calculate new dimensions based on cursor position
|
||||||
const rotatedPointer = rotatePoint(
|
const rotatedPointer = pointRotateRads(
|
||||||
[pointerX, pointerY],
|
point(pointerX, pointerY),
|
||||||
startCenter,
|
startCenter,
|
||||||
-stateAtResizeStart.angle,
|
-stateAtResizeStart.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get bounds corners rendered on screen
|
// Get bounds corners rendered on screen
|
||||||
@@ -628,13 +643,21 @@ export const resizeSingleElement = (
|
|||||||
|
|
||||||
// adjust topLeft to new rotation point
|
// adjust topLeft to new rotation point
|
||||||
const angle = stateAtResizeStart.angle;
|
const angle = stateAtResizeStart.angle;
|
||||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
const rotatedTopLeft = pointRotateRads(
|
||||||
const newCenter: Point = [
|
pointFromPair(newTopLeft),
|
||||||
|
startCenter,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
const newCenter = point(
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
];
|
);
|
||||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
newTopLeft = pointRotateRads(
|
||||||
|
rotatedTopLeft,
|
||||||
|
rotatedNewCenter,
|
||||||
|
-angle as Radians,
|
||||||
|
);
|
||||||
|
|
||||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||||
// So we need to readjust (x,y) to be where the first point should be
|
// So we need to readjust (x,y) to be where the first point should be
|
||||||
@@ -793,21 +816,21 @@ export const resizeMultipleElements = (
|
|||||||
|
|
||||||
const direction = transformHandleType;
|
const direction = transformHandleType;
|
||||||
|
|
||||||
const anchorsMap: Record<TransformHandleDirection, Point> = {
|
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
|
||||||
ne: [minX, maxY],
|
ne: point(minX, maxY),
|
||||||
se: [minX, minY],
|
se: point(minX, minY),
|
||||||
sw: [maxX, minY],
|
sw: point(maxX, minY),
|
||||||
nw: [maxX, maxY],
|
nw: point(maxX, maxY),
|
||||||
e: [minX, minY + height / 2],
|
e: point(minX, minY + height / 2),
|
||||||
w: [maxX, minY + height / 2],
|
w: point(maxX, minY + height / 2),
|
||||||
n: [minX + width / 2, maxY],
|
n: point(minX + width / 2, maxY),
|
||||||
s: [minX + width / 2, minY],
|
s: point(minX + width / 2, minY),
|
||||||
};
|
};
|
||||||
|
|
||||||
// anchor point must be on the opposite side of the dragged selection handle
|
// anchor point must be on the opposite side of the dragged selection handle
|
||||||
// or be the center of the selection if shouldResizeFromCenter
|
// or be the center of the selection if shouldResizeFromCenter
|
||||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
const [anchorX, anchorY] = shouldResizeFromCenter
|
||||||
? [midX, midY]
|
? point(midX, midY)
|
||||||
: anchorsMap[direction];
|
: anchorsMap[direction];
|
||||||
|
|
||||||
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||||
@@ -887,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"];
|
||||||
};
|
};
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@@ -898,7 +923,9 @@ export const resizeMultipleElements = (
|
|||||||
|
|
||||||
const width = orig.width * scaleX;
|
const width = orig.width * scaleX;
|
||||||
const height = orig.height * scaleY;
|
const height = orig.height * scaleY;
|
||||||
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
|
const angle = normalizeRadians(
|
||||||
|
(orig.angle * flipFactorX * flipFactorY) as Radians,
|
||||||
|
);
|
||||||
|
|
||||||
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
|
||||||
const offsetX = orig.x - anchorX;
|
const offsetX = orig.x - anchorX;
|
||||||
@@ -969,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 },
|
||||||
@@ -1029,15 +1043,13 @@ const rotateMultipleElements = (
|
|||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
const origAngle =
|
const origAngle =
|
||||||
originalElements.get(element.id)?.angle ?? element.angle;
|
originalElements.get(element.id)?.angle ?? element.angle;
|
||||||
const [rotatedCX, rotatedCY] = rotate(
|
const [rotatedCX, rotatedCY] = pointRotateRads(
|
||||||
cx,
|
point(cx, cy),
|
||||||
cy,
|
point(centerX, centerY),
|
||||||
centerX,
|
(centerAngle + origAngle - element.angle) as Radians,
|
||||||
centerY,
|
|
||||||
centerAngle + origAngle - element.angle,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
||||||
@@ -1046,7 +1058,7 @@ const rotateMultipleElements = (
|
|||||||
{
|
{
|
||||||
x: element.x + (rotatedCX - cx),
|
x: element.x + (rotatedCX - cx),
|
||||||
y: element.y + (rotatedCY - cy),
|
y: element.y + (rotatedCY - cy),
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -1063,7 +1075,7 @@ const rotateMultipleElements = (
|
|||||||
{
|
{
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x: boundText.x + (rotatedCX - cx),
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y: boundText.y + (rotatedCY - cy),
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
@@ -1086,25 +1098,43 @@ export const getResizeOffsetXY = (
|
|||||||
: getCommonBounds(selectedElements);
|
: getCommonBounds(selectedElements);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
|
const angle = (
|
||||||
[x, y] = rotate(x, y, cx, cy, -angle);
|
selectedElements.length === 1 ? selectedElements[0].angle : 0
|
||||||
|
) as Radians;
|
||||||
|
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
|
||||||
switch (transformHandleType) {
|
switch (transformHandleType) {
|
||||||
case "n":
|
case "n":
|
||||||
return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
|
return pointRotateRads(
|
||||||
|
point(x - (x1 + x2) / 2, y - y1),
|
||||||
|
point(0, 0),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
case "s":
|
case "s":
|
||||||
return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
|
return pointRotateRads(
|
||||||
|
point(x - (x1 + x2) / 2, y - y2),
|
||||||
|
point(0, 0),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
case "w":
|
case "w":
|
||||||
return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
|
return pointRotateRads(
|
||||||
|
point(x - x1, y - (y1 + y2) / 2),
|
||||||
|
point(0, 0),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
case "e":
|
case "e":
|
||||||
return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
|
return pointRotateRads(
|
||||||
|
point(x - x2, y - (y1 + y2) / 2),
|
||||||
|
point(0, 0),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
case "nw":
|
case "nw":
|
||||||
return rotate(x - x1, y - y1, 0, 0, angle);
|
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
|
||||||
case "ne":
|
case "ne":
|
||||||
return rotate(x - x2, y - y1, 0, 0, angle);
|
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
|
||||||
case "sw":
|
case "sw":
|
||||||
return rotate(x - x1, y - y2, 0, 0, angle);
|
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
|
||||||
case "se":
|
case "se":
|
||||||
return rotate(x - x2, y - y2, 0, 0, angle);
|
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
|
||||||
default:
|
default:
|
||||||
return [0, 0];
|
return [0, 0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
|
|||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||||
import {
|
|
||||||
angleToDegrees,
|
|
||||||
pointOnLine,
|
|
||||||
pointRotate,
|
|
||||||
} from "../../utils/geometry/geometry";
|
|
||||||
import type { Line, Point } from "../../utils/geometry/shape";
|
|
||||||
import { isLinearElement } from "./typeChecks";
|
import { isLinearElement } from "./typeChecks";
|
||||||
|
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||||
|
import {
|
||||||
|
point,
|
||||||
|
pointOnLineSegment,
|
||||||
|
pointRotateRads,
|
||||||
|
type Radians,
|
||||||
|
} from "../../math";
|
||||||
|
|
||||||
const isInsideTransformHandle = (
|
const isInsideTransformHandle = (
|
||||||
transformHandle: TransformHandle,
|
transformHandle: TransformHandle,
|
||||||
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
|
|||||||
y >= transformHandle[1] &&
|
y >= transformHandle[1] &&
|
||||||
y <= transformHandle[1] + transformHandle[3];
|
y <= transformHandle[1] + transformHandle[3];
|
||||||
|
|
||||||
export const resizeTest = (
|
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -91,15 +92,17 @@ export const resizeTest = (
|
|||||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
const sides = getSelectionBorders(
|
const sides = getSelectionBorders(
|
||||||
[x1 - SPACING, y1 - SPACING],
|
point(x1 - SPACING, y1 - SPACING),
|
||||||
[x2 + SPACING, y2 + SPACING],
|
point(x2 + SPACING, y2 + SPACING),
|
||||||
[cx, cy],
|
point(cx, cy),
|
||||||
angleToDegrees(element.angle),
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [dir, side] of Object.entries(sides)) {
|
for (const [dir, side] of Object.entries(sides)) {
|
||||||
// test to see if x, y are on the line segment
|
// test to see if x, y are on the line segment
|
||||||
if (pointOnLine([x, y], side as Line, SPACING)) {
|
if (
|
||||||
|
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
|
||||||
|
) {
|
||||||
return dir as TransformHandleType;
|
return dir as TransformHandleType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
|
|||||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTransformHandleTypeFromCoords = (
|
export const getTransformHandleTypeFromCoords = <
|
||||||
|
Point extends GlobalPoint | LocalPoint,
|
||||||
|
>(
|
||||||
[x1, y1, x2, y2]: Bounds,
|
[x1, y1, x2, y2]: Bounds,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
@@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
): MaybeTransformHandleType => {
|
): MaybeTransformHandleType => {
|
||||||
const transformHandles = getTransformHandlesFromCoords(
|
const transformHandles = getTransformHandlesFromCoords(
|
||||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||||
0,
|
0 as Radians,
|
||||||
zoom,
|
zoom,
|
||||||
pointerType,
|
pointerType,
|
||||||
getOmitSidesForDevice(device),
|
getOmitSidesForDevice(device),
|
||||||
@@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
|
|||||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||||
|
|
||||||
const sides = getSelectionBorders(
|
const sides = getSelectionBorders(
|
||||||
[x1 - SPACING, y1 - SPACING],
|
point(x1 - SPACING, y1 - SPACING),
|
||||||
[x2 + SPACING, y2 + SPACING],
|
point(x2 + SPACING, y2 + SPACING),
|
||||||
[cx, cy],
|
point(cx, cy),
|
||||||
angleToDegrees(0),
|
0 as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [dir, side] of Object.entries(sides)) {
|
for (const [dir, side] of Object.entries(sides)) {
|
||||||
// test to see if x, y are on the line segment
|
// test to see if x, y are on the line segment
|
||||||
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
|
if (
|
||||||
|
pointOnLineSegment(
|
||||||
|
point(scenePointerX, scenePointerY),
|
||||||
|
side as LineSegment<Point>,
|
||||||
|
SPACING,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return dir as TransformHandleType;
|
return dir as TransformHandleType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
|
|||||||
return cursor ? `${cursor}-resize` : "";
|
return cursor ? `${cursor}-resize` : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectionBorders = (
|
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||||
[x1, y1]: Point,
|
[x1, y1]: Point,
|
||||||
[x2, y2]: Point,
|
[x2, y2]: Point,
|
||||||
center: Point,
|
center: Point,
|
||||||
angleInDegrees: number,
|
angle: Radians,
|
||||||
) => {
|
) => {
|
||||||
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
|
const topLeft = pointRotateRads(point(x1, y1), center, angle);
|
||||||
const topRight = pointRotate([x2, y1], angleInDegrees, center);
|
const topRight = pointRotateRads(point(x2, y1), center, angle);
|
||||||
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
|
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
|
||||||
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
|
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
n: [topLeft, topRight],
|
n: [topLeft, topRight],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ARROW_TYPE } from "../constants";
|
import { ARROW_TYPE } from "../constants";
|
||||||
|
import { point } from "../../math";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
|
|||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(arrow);
|
scene.insertElement(arrow);
|
||||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||||
[-45 - arrow.x, -100.1 - arrow.y],
|
point(-45 - arrow.x, -100.1 - arrow.y),
|
||||||
[45 - arrow.x, 99.9 - arrow.y],
|
point(45 - arrow.x, 99.9 - arrow.y),
|
||||||
]);
|
]);
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
|
|||||||
y: -100.1,
|
y: -100.1,
|
||||||
width: 90,
|
width: 90,
|
||||||
height: 200,
|
height: 200,
|
||||||
points: [
|
points: [point(0, 0), point(90, 200)],
|
||||||
[0, 0],
|
|
||||||
[90, 200],
|
|
||||||
],
|
|
||||||
}) as ExcalidrawElbowArrowElement;
|
}) as ExcalidrawElbowArrowElement;
|
||||||
scene.insertElement(rectangle1);
|
scene.insertElement(rectangle1);
|
||||||
scene.insertElement(rectangle2);
|
scene.insertElement(rectangle2);
|
||||||
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
|
|||||||
expect(arrow.startBinding).not.toBe(null);
|
expect(arrow.startBinding).not.toBe(null);
|
||||||
expect(arrow.endBinding).not.toBe(null);
|
expect(arrow.endBinding).not.toBe(null);
|
||||||
|
|
||||||
mutateElbowArrow(arrow, elementsMap, [
|
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
|
||||||
[0, 0],
|
|
||||||
[90, 200],
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
@@ -99,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 () => {
|
||||||
@@ -135,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],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -168,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(
|
||||||
@@ -187,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],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { cross } from "../../utils/geometry/geometry";
|
import type { Radians } from "../../math";
|
||||||
import BinaryHeap from "../binaryheap";
|
|
||||||
import {
|
import {
|
||||||
aabbForElement,
|
point,
|
||||||
arePointsEqual,
|
pointScaleFromOrigin,
|
||||||
pointInsideBounds,
|
pointTranslate,
|
||||||
pointToVector,
|
vector,
|
||||||
scalePointFromOrigin,
|
vectorCross,
|
||||||
scaleVector,
|
vectorFromPoint,
|
||||||
translatePoint,
|
vectorScale,
|
||||||
} from "../math";
|
type GlobalPoint,
|
||||||
|
type LocalPoint,
|
||||||
|
type Vector,
|
||||||
|
} from "../../math";
|
||||||
|
import BinaryHeap from "../binaryheap";
|
||||||
import { getSizeFromPoints } from "../points";
|
import { getSizeFromPoints } from "../points";
|
||||||
import type { Point } from "../types";
|
import { aabbForElement, pointInsideBounds } from "../shapes";
|
||||||
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
||||||
import {
|
import {
|
||||||
bindPointToSnapToElementOutline,
|
bindPointToSnapToElementOutline,
|
||||||
@@ -25,22 +28,26 @@ import {
|
|||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
import {
|
import {
|
||||||
|
compareHeading,
|
||||||
|
flipHeading,
|
||||||
HEADING_DOWN,
|
HEADING_DOWN,
|
||||||
HEADING_LEFT,
|
HEADING_LEFT,
|
||||||
HEADING_RIGHT,
|
HEADING_RIGHT,
|
||||||
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";
|
||||||
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
|
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
|
||||||
|
|
||||||
|
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||||
|
|
||||||
type Node = {
|
type Node = {
|
||||||
f: number;
|
f: number;
|
||||||
g: number;
|
g: number;
|
||||||
@@ -48,8 +55,8 @@ type Node = {
|
|||||||
closed: boolean;
|
closed: boolean;
|
||||||
visited: boolean;
|
visited: boolean;
|
||||||
parent: Node | null;
|
parent: Node | null;
|
||||||
pos: Point;
|
pos: GlobalPoint;
|
||||||
addr: [number, number];
|
addr: GridAddress;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Grid = {
|
type Grid = {
|
||||||
@@ -63,26 +70,64 @@ const BASE_PADDING = 40;
|
|||||||
export const mutateElbowArrow = (
|
export const mutateElbowArrow = (
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
nextPoints: readonly Point[],
|
nextPoints: readonly LocalPoint[],
|
||||||
offset?: Point,
|
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 = translatePoint(nextPoints[0], [
|
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||||
arrow.x + (offset ? offset[0] : 0),
|
pointTranslate<LocalPoint, GlobalPoint>(
|
||||||
arrow.y + (offset ? offset[1] : 0),
|
nextPoints[0],
|
||||||
]);
|
vector(arrow.x, arrow.y),
|
||||||
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
|
),
|
||||||
arrow.x + (offset ? offset[0] : 0),
|
offset,
|
||||||
arrow.y + (offset ? offset[1] : 0),
|
);
|
||||||
]);
|
const origEndGlobalPoint: GlobalPoint = pointTranslate(
|
||||||
|
pointTranslate<LocalPoint, GlobalPoint>(
|
||||||
|
nextPoints[nextPoints.length - 1],
|
||||||
|
vector(arrow.x, arrow.y),
|
||||||
|
),
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
|
||||||
const startElement =
|
const startElement =
|
||||||
arrow.startBinding &&
|
arrow.startBinding &&
|
||||||
@@ -222,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],
|
||||||
@@ -275,22 +322,17 @@ export const mutateElbowArrow = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
|
const points = path.map((node) => [
|
||||||
|
node.pos[0],
|
||||||
|
node.pos[1],
|
||||||
|
]) as GlobalPoint[];
|
||||||
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,
|
|
||||||
},
|
|
||||||
options?.informMutation,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("Elbow arrow cannot find a route");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const offsetFromHeading = (
|
const offsetFromHeading = (
|
||||||
@@ -363,7 +405,7 @@ const astar = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Intersect
|
// Intersect
|
||||||
const neighborHalfPoint = scalePointFromOrigin(
|
const neighborHalfPoint = pointScaleFromOrigin(
|
||||||
neighbor.pos,
|
neighbor.pos,
|
||||||
current.pos,
|
current.pos,
|
||||||
0.5,
|
0.5,
|
||||||
@@ -380,17 +422,17 @@ const astar = (
|
|||||||
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
|
// We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
|
||||||
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
|
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
|
||||||
const previousDirection = current.parent
|
const previousDirection = current.parent
|
||||||
? vectorToHeading(pointToVector(current.pos, current.parent.pos))
|
? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
|
||||||
: startHeading;
|
: startHeading;
|
||||||
|
|
||||||
// Do not allow going in reverse
|
// Do not allow going in reverse
|
||||||
const reverseHeading = scaleVector(previousDirection, -1);
|
const reverseHeading = flipHeading(previousDirection);
|
||||||
const neighborIsReverseRoute =
|
const neighborIsReverseRoute =
|
||||||
arePointsEqual(reverseHeading, neighborHeading) ||
|
compareHeading(reverseHeading, neighborHeading) ||
|
||||||
(arePointsEqual(start.addr, neighbor.addr) &&
|
(gridAddressesEqual(start.addr, neighbor.addr) &&
|
||||||
arePointsEqual(neighborHeading, startHeading)) ||
|
compareHeading(neighborHeading, startHeading)) ||
|
||||||
(arePointsEqual(end.addr, neighbor.addr) &&
|
(gridAddressesEqual(end.addr, neighbor.addr) &&
|
||||||
arePointsEqual(neighborHeading, endHeading));
|
compareHeading(neighborHeading, endHeading));
|
||||||
if (neighborIsReverseRoute) {
|
if (neighborIsReverseRoute) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -444,7 +486,7 @@ const pathTo = (start: Node, node: Node) => {
|
|||||||
return path;
|
return path;
|
||||||
};
|
};
|
||||||
|
|
||||||
const m_dist = (a: Point, b: Point) =>
|
const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
|
||||||
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
|
Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,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,
|
||||||
];
|
];
|
||||||
@@ -468,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,
|
||||||
@@ -498,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,
|
||||||
@@ -541,7 +587,12 @@ const generateDynamicAABBs = (
|
|||||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||||
|
|
||||||
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
|
if (
|
||||||
|
vectorCross(
|
||||||
|
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||||
|
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||||
|
) > 0
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
[first[0], first[1], cX, first[3]],
|
[first[0], first[1], cX, first[3]],
|
||||||
[cX, second[1], second[2], second[3]],
|
[cX, second[1], second[2], second[3]],
|
||||||
@@ -557,7 +608,12 @@ const generateDynamicAABBs = (
|
|||||||
const cX = first[2] + (second[0] - first[2]) / 2;
|
const cX = first[2] + (second[0] - first[2]) / 2;
|
||||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||||
|
|
||||||
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
|
if (
|
||||||
|
vectorCross(
|
||||||
|
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||||
|
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||||
|
) > 0
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
[first[0], first[1], first[2], cY],
|
[first[0], first[1], first[2], cY],
|
||||||
[second[0], cY, second[2], second[3]],
|
[second[0], cY, second[2], second[3]],
|
||||||
@@ -573,7 +629,12 @@ const generateDynamicAABBs = (
|
|||||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||||
const cY = first[3] + (second[1] - first[3]) / 2;
|
const cY = first[3] + (second[1] - first[3]) / 2;
|
||||||
|
|
||||||
if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
|
if (
|
||||||
|
vectorCross(
|
||||||
|
vector(a[2] - endCenterX, a[1] - endCenterY),
|
||||||
|
vector(a[0] - endCenterX, a[3] - endCenterY),
|
||||||
|
) > 0
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
[cX, first[1], first[2], first[3]],
|
[cX, first[1], first[2], first[3]],
|
||||||
[second[0], second[1], cX, second[3]],
|
[second[0], second[1], cX, second[3]],
|
||||||
@@ -589,7 +650,12 @@ const generateDynamicAABBs = (
|
|||||||
const cX = second[2] + (first[0] - second[2]) / 2;
|
const cX = second[2] + (first[0] - second[2]) / 2;
|
||||||
const cY = second[3] + (first[1] - second[3]) / 2;
|
const cY = second[3] + (first[1] - second[3]) / 2;
|
||||||
|
|
||||||
if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
|
if (
|
||||||
|
vectorCross(
|
||||||
|
vector(a[0] - endCenterX, a[1] - endCenterY),
|
||||||
|
vector(a[2] - endCenterX, a[3] - endCenterY),
|
||||||
|
) > 0
|
||||||
|
) {
|
||||||
return [
|
return [
|
||||||
[cX, first[1], first[2], first[3]],
|
[cX, first[1], first[2], first[3]],
|
||||||
[second[0], second[1], cX, second[3]],
|
[second[0], second[1], cX, second[3]],
|
||||||
@@ -615,9 +681,9 @@ const generateDynamicAABBs = (
|
|||||||
*/
|
*/
|
||||||
const calculateGrid = (
|
const calculateGrid = (
|
||||||
aabbs: Bounds[],
|
aabbs: Bounds[],
|
||||||
start: Point,
|
start: GlobalPoint,
|
||||||
startHeading: Heading,
|
startHeading: Heading,
|
||||||
end: Point,
|
end: GlobalPoint,
|
||||||
endHeading: Heading,
|
endHeading: Heading,
|
||||||
common: Bounds,
|
common: Bounds,
|
||||||
): Grid => {
|
): Grid => {
|
||||||
@@ -662,8 +728,8 @@ const calculateGrid = (
|
|||||||
closed: false,
|
closed: false,
|
||||||
visited: false,
|
visited: false,
|
||||||
parent: null,
|
parent: null,
|
||||||
addr: [col, row] as [number, number],
|
addr: [col, row] as GridAddress,
|
||||||
pos: [x, y] as Point,
|
pos: [x, y] as GlobalPoint,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -673,17 +739,17 @@ const calculateGrid = (
|
|||||||
const getDonglePosition = (
|
const getDonglePosition = (
|
||||||
bounds: Bounds,
|
bounds: Bounds,
|
||||||
heading: Heading,
|
heading: Heading,
|
||||||
point: Point,
|
p: GlobalPoint,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
switch (heading) {
|
switch (heading) {
|
||||||
case HEADING_UP:
|
case HEADING_UP:
|
||||||
return [point[0], bounds[1]];
|
return point(p[0], bounds[1]);
|
||||||
case HEADING_RIGHT:
|
case HEADING_RIGHT:
|
||||||
return [bounds[2], point[1]];
|
return point(bounds[2], p[1]);
|
||||||
case HEADING_DOWN:
|
case HEADING_DOWN:
|
||||||
return [point[0], bounds[3]];
|
return point(p[0], bounds[3]);
|
||||||
}
|
}
|
||||||
return [bounds[0], point[1]];
|
return point(bounds[0], p[1]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const estimateSegmentCount = (
|
const estimateSegmentCount = (
|
||||||
@@ -826,7 +892,7 @@ const gridNodeFromAddr = (
|
|||||||
/**
|
/**
|
||||||
* Get node for global point on canvas (if exists)
|
* Get node for global point on canvas (if exists)
|
||||||
*/
|
*/
|
||||||
const pointToGridNode = (point: Point, grid: Grid): Node | null => {
|
const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
|
||||||
for (let col = 0; col < grid.col; col++) {
|
for (let col = 0; col < grid.col; col++) {
|
||||||
for (let row = 0; row < grid.row; row++) {
|
for (let row = 0; row < grid.row; row++) {
|
||||||
const candidate = gridNodeFromAddr([col, row], grid);
|
const candidate = gridNodeFromAddr([col, row], grid);
|
||||||
@@ -865,15 +931,24 @@ const getBindableElementForId = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const normalizedArrowElementUpdate = (
|
const normalizedArrowElementUpdate = (
|
||||||
global: Point[],
|
global: GlobalPoint[],
|
||||||
externalOffsetX?: number,
|
externalOffsetX?: number,
|
||||||
externalOffsetY?: number,
|
externalOffsetY?: number,
|
||||||
) => {
|
): {
|
||||||
|
points: LocalPoint[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} => {
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
|
|
||||||
const points = global.map(
|
const points = global.map((p) =>
|
||||||
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
|
pointTranslate<GlobalPoint, LocalPoint>(
|
||||||
|
p,
|
||||||
|
vectorScale(vectorFromPoint(global[0]), -1),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -885,19 +960,22 @@ const normalizedArrowElementUpdate = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// If last and current segments have the same heading, skip the middle point
|
/// If last and current segments have the same heading, skip the middle point
|
||||||
const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
|
const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
|
||||||
points
|
points
|
||||||
.slice(2)
|
.slice(2)
|
||||||
.reduce(
|
.reduce(
|
||||||
(result, point) =>
|
(result, p) =>
|
||||||
arePointsEqual(
|
compareHeading(
|
||||||
vectorToHeading(
|
vectorToHeading(
|
||||||
pointToVector(result[result.length - 1], result[result.length - 2]),
|
vectorFromPoint(
|
||||||
|
result[result.length - 1],
|
||||||
|
result[result.length - 2],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
vectorToHeading(pointToVector(point, result[result.length - 1])),
|
vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
|
||||||
)
|
)
|
||||||
? [...result.slice(0, -1), point]
|
? [...result.slice(0, -1), p]
|
||||||
: [...result, point],
|
: [...result, p],
|
||||||
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
|
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -915,13 +993,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
|
|||||||
|
|
||||||
const getGlobalPoint = (
|
const getGlobalPoint = (
|
||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: Point,
|
initialPoint: GlobalPoint,
|
||||||
otherPoint: Point,
|
otherPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
boundElement?: ExcalidrawBindableElement | null,
|
boundElement?: ExcalidrawBindableElement | null,
|
||||||
hoveredElement?: ExcalidrawBindableElement | null,
|
hoveredElement?: ExcalidrawBindableElement | null,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): Point => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (hoveredElement) {
|
if (hoveredElement) {
|
||||||
const snapPoint = getSnapPoint(
|
const snapPoint = getSnapPoint(
|
||||||
@@ -956,36 +1034,34 @@ const getGlobalPoint = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSnapPoint = (
|
const getSnapPoint = (
|
||||||
point: Point,
|
p: GlobalPoint,
|
||||||
otherPoint: Point,
|
otherPoint: GlobalPoint,
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) =>
|
) =>
|
||||||
bindPointToSnapToElementOutline(
|
bindPointToSnapToElementOutline(
|
||||||
isRectanguloidElement(element)
|
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
|
||||||
? avoidRectangularCorner(element, point)
|
|
||||||
: point,
|
|
||||||
otherPoint,
|
otherPoint,
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getBindPointHeading = (
|
const getBindPointHeading = (
|
||||||
point: Point,
|
p: GlobalPoint,
|
||||||
otherPoint: Point,
|
otherPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: Point,
|
origPoint: GlobalPoint,
|
||||||
) =>
|
) =>
|
||||||
getHeadingForElbowArrowSnap(
|
getHeadingForElbowArrowSnap(
|
||||||
point,
|
p,
|
||||||
otherPoint,
|
otherPoint,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
hoveredElement &&
|
hoveredElement &&
|
||||||
aabbForElement(
|
aabbForElement(
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
Array(4).fill(
|
Array(4).fill(
|
||||||
distanceToBindableElement(hoveredElement, point, elementsMap),
|
distanceToBindableElement(hoveredElement, p, elementsMap),
|
||||||
) as [number, number, number, number],
|
) as [number, number, number, number],
|
||||||
),
|
),
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@@ -993,8 +1069,8 @@ const getBindPointHeading = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElements = (
|
const getHoveredElements = (
|
||||||
origStartGlobalPoint: Point,
|
origStartGlobalPoint: GlobalPoint,
|
||||||
origEndGlobalPoint: Point,
|
origEndGlobalPoint: GlobalPoint,
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
) => {
|
) => {
|
||||||
// TODO: Might be a performance bottleneck and the Map type
|
// TODO: Might be a performance bottleneck and the Map type
|
||||||
@@ -1018,3 +1094,6 @@ const getHoveredElements = (
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
|
||||||
|
a[0] === b[0] && a[1] === b[1];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
import { API } from "../tests/helpers/api";
|
import { API } from "../tests/helpers/api";
|
||||||
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
||||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||||
|
import { point } from "../../math";
|
||||||
|
|
||||||
// Unmount ReactDOM from root
|
// Unmount ReactDOM from root
|
||||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||||
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
|
|||||||
type: "line",
|
type: "line",
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 0,
|
height: 0,
|
||||||
points: [
|
points: [point(0, 0), point(100, 0)],
|
||||||
[0, 0],
|
|
||||||
[100, 0],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const textSize = 20;
|
const textSize = 20;
|
||||||
const text = API.createElement({
|
const text = API.createElement({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { CLASSES, isSafari } from "../constants";
|
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -38,7 +38,11 @@ import {
|
|||||||
actionDecreaseFontSize,
|
actionDecreaseFontSize,
|
||||||
actionIncreaseFontSize,
|
actionIncreaseFontSize,
|
||||||
} from "../actions/actionProperties";
|
} from "../actions/actionProperties";
|
||||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
import {
|
||||||
|
actionResetZoom,
|
||||||
|
actionZoomIn,
|
||||||
|
actionZoomOut,
|
||||||
|
} from "../actions/actionCanvas";
|
||||||
import type App from "../components/App";
|
import type App from "../components/App";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { parseClipboard } from "../clipboard";
|
import { parseClipboard } from "../clipboard";
|
||||||
@@ -243,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
|
||||||
@@ -379,6 +383,10 @@ export const textWysiwyg = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app.actionManager.executeAction(actionZoomOut);
|
app.actionManager.executeAction(actionZoomOut);
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
|
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
app.actionManager.executeAction(actionResetZoom);
|
||||||
|
updateWysiwygStyle();
|
||||||
} else if (actionDecreaseFontSize.keyTest(event)) {
|
} else if (actionDecreaseFontSize.keyTest(event)) {
|
||||||
app.actionManager.executeAction(actionDecreaseFontSize);
|
app.actionManager.executeAction(actionDecreaseFontSize);
|
||||||
} else if (actionIncreaseFontSize.keyTest(event)) {
|
} else if (actionIncreaseFontSize.keyTest(event)) {
|
||||||
@@ -593,6 +601,7 @@ export const textWysiwyg = ({
|
|||||||
window.removeEventListener("blur", handleSubmit);
|
window.removeEventListener("blur", handleSubmit);
|
||||||
window.removeEventListener("beforeunload", handleSubmit);
|
window.removeEventListener("beforeunload", handleSubmit);
|
||||||
unbindUpdate();
|
unbindUpdate();
|
||||||
|
unbindOnScroll();
|
||||||
|
|
||||||
editable.remove();
|
editable.remove();
|
||||||
};
|
};
|
||||||
@@ -619,10 +628,29 @@ export const textWysiwyg = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const temporarilyDisableSubmit = () => {
|
||||||
|
editable.onblur = null;
|
||||||
|
window.addEventListener("pointerup", bindBlurEvent);
|
||||||
|
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||||
|
// alt-tabbing away
|
||||||
|
window.addEventListener("blur", handleSubmit);
|
||||||
|
};
|
||||||
|
|
||||||
// prevent blur when changing properties from the menu
|
// prevent blur when changing properties from the menu
|
||||||
const onPointerDown = (event: MouseEvent) => {
|
const onPointerDown = (event: MouseEvent) => {
|
||||||
const target = event?.target;
|
const target = event?.target;
|
||||||
|
|
||||||
|
// panning canvas
|
||||||
|
if (event.button === POINTER_BUTTON.WHEEL) {
|
||||||
|
// trying to pan by clicking inside text area itself -> handle here
|
||||||
|
if (target instanceof HTMLTextAreaElement) {
|
||||||
|
event.preventDefault();
|
||||||
|
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
||||||
|
}
|
||||||
|
temporarilyDisableSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isPropertiesTrigger =
|
const isPropertiesTrigger =
|
||||||
target instanceof HTMLElement &&
|
target instanceof HTMLElement &&
|
||||||
target.classList.contains("properties-trigger");
|
target.classList.contains("properties-trigger");
|
||||||
@@ -630,17 +658,14 @@ export const textWysiwyg = ({
|
|||||||
if (
|
if (
|
||||||
((event.target instanceof HTMLElement ||
|
((event.target instanceof HTMLElement ||
|
||||||
event.target instanceof SVGElement) &&
|
event.target instanceof SVGElement) &&
|
||||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
event.target.closest(
|
||||||
|
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||||
|
) &&
|
||||||
!isWritableElement(event.target)) ||
|
!isWritableElement(event.target)) ||
|
||||||
isPropertiesTrigger
|
isPropertiesTrigger
|
||||||
) {
|
) {
|
||||||
editable.onblur = null;
|
temporarilyDisableSubmit();
|
||||||
window.addEventListener("pointerup", bindBlurEvent);
|
|
||||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
|
||||||
// alt-tabbing away
|
|
||||||
window.addEventListener("blur", handleSubmit);
|
|
||||||
} else if (
|
} else if (
|
||||||
event.target instanceof HTMLElement &&
|
|
||||||
event.target instanceof HTMLCanvasElement &&
|
event.target instanceof HTMLCanvasElement &&
|
||||||
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||||
// so without introducing crazier hacks, nothing we can do
|
// so without introducing crazier hacks, nothing we can do
|
||||||
@@ -659,7 +684,7 @@ export const textWysiwyg = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// handle updates of textElement properties of editing element
|
// handle updates of textElement properties of editing element
|
||||||
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
|
const unbindUpdate = app.scene.onUpdate(() => {
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
const isPopupOpened = !!document.activeElement?.closest(
|
const isPopupOpened = !!document.activeElement?.closest(
|
||||||
".properties-content",
|
".properties-content",
|
||||||
@@ -669,6 +694,10 @@ export const textWysiwyg = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
|
||||||
|
updateWysiwygStyle();
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let isDestroyed = false;
|
let isDestroyed = false;
|
||||||
@@ -699,10 +728,6 @@ export const textWysiwyg = ({
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
||||||
});
|
});
|
||||||
window.addEventListener("wheel", stopEvent, {
|
|
||||||
passive: false,
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
window.addEventListener("beforeunload", handleSubmit);
|
window.addEventListener("beforeunload", handleSubmit);
|
||||||
excalidrawContainer
|
excalidrawContainer
|
||||||
?.querySelector(".excalidraw-textEditorContainer")!
|
?.querySelector(".excalidraw-textEditorContainer")!
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords } from "./bounds";
|
||||||
import { rotate } from "../math";
|
|
||||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||||
import {
|
import {
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
@@ -19,6 +18,8 @@ import {
|
|||||||
isAndroid,
|
isAndroid,
|
||||||
isIOS,
|
isIOS,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import type { Radians } from "../../math";
|
||||||
|
import { point, pointRotateRads } from "../../math";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
| "n"
|
| "n"
|
||||||
@@ -91,9 +92,13 @@ const generateTransformHandle = (
|
|||||||
height: number,
|
height: number,
|
||||||
cx: number,
|
cx: number,
|
||||||
cy: number,
|
cy: number,
|
||||||
angle: number,
|
angle: Radians,
|
||||||
): TransformHandle => {
|
): TransformHandle => {
|
||||||
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
|
const [xx, yy] = pointRotateRads(
|
||||||
|
point(x + width / 2, y + height / 2),
|
||||||
|
point(cx, cy),
|
||||||
|
angle,
|
||||||
|
);
|
||||||
return [xx - width / 2, yy - height / 2, width, height];
|
return [xx - width / 2, yy - height / 2, width, height];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
|
|||||||
|
|
||||||
export const getTransformHandlesFromCoords = (
|
export const getTransformHandlesFromCoords = (
|
||||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||||
angle: number,
|
angle: Radians,
|
||||||
zoom: Zoom,
|
zoom: Zoom,
|
||||||
pointerType: PointerType,
|
pointerType: PointerType,
|
||||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ROUNDNESS } from "../constants";
|
|||||||
import type { ElementOrToolType } from "../types";
|
import type { ElementOrToolType } from "../types";
|
||||||
import type { MarkNonNullable } from "../utility-types";
|
import type { MarkNonNullable } from "../utility-types";
|
||||||
import { assertNever } from "../utils";
|
import { assertNever } from "../utils";
|
||||||
|
import type { Bounds } from "./bounds";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
@@ -189,7 +190,8 @@ export const isRectangularElement = (
|
|||||||
element.type === "iframe" ||
|
element.type === "iframe" ||
|
||||||
element.type === "embeddable" ||
|
element.type === "embeddable" ||
|
||||||
element.type === "frame" ||
|
element.type === "frame" ||
|
||||||
element.type === "magicframe")
|
element.type === "magicframe" ||
|
||||||
|
element.type === "freedraw")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -318,7 +320,19 @@ 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
|
||||||
|
export const isBounds = (box: unknown): box is Bounds =>
|
||||||
|
Array.isArray(box) &&
|
||||||
|
box.length === 4 &&
|
||||||
|
typeof box[0] === "number" &&
|
||||||
|
typeof box[1] === "number" &&
|
||||||
|
typeof box[2] === "number" &&
|
||||||
|
typeof box[3] === "number";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Point } from "../types";
|
import type { LocalPoint, Radians } from "../../math";
|
||||||
import type {
|
import type {
|
||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
opacity: number;
|
opacity: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
angle: number;
|
angle: Radians;
|
||||||
/** Random integer used to seed shape generation so that the roughjs shape
|
/** Random integer used to seed shape generation so that the roughjs shape
|
||||||
doesn't differ across renders. */
|
doesn't differ across renders. */
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
|
|||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement;
|
| ExcalidrawEllipseElement;
|
||||||
|
|
||||||
|
export type ExcalidrawRectanguloidElement =
|
||||||
|
| ExcalidrawRectangleElement
|
||||||
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawFreeDrawElement
|
||||||
|
| ExcalidrawIframeLikeElement
|
||||||
|
| ExcalidrawFrameLikeElement
|
||||||
|
| ExcalidrawEmbeddableElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||||
@@ -184,6 +193,7 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawGenericElement
|
| ExcalidrawGenericElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawLinearElement
|
| ExcalidrawLinearElement
|
||||||
|
| ExcalidrawArrowElement
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawFrameElement
|
| ExcalidrawFrameElement
|
||||||
@@ -259,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"
|
||||||
@@ -283,8 +297,8 @@ export type Arrowhead =
|
|||||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "line" | "arrow";
|
type: "line" | "arrow";
|
||||||
points: readonly Point[];
|
points: readonly LocalPoint[];
|
||||||
lastCommittedPoint: Point | null;
|
lastCommittedPoint: LocalPoint | null;
|
||||||
startBinding: PointBinding | null;
|
startBinding: PointBinding | null;
|
||||||
endBinding: PointBinding | null;
|
endBinding: PointBinding | null;
|
||||||
startArrowhead: Arrowhead | null;
|
startArrowhead: Arrowhead | null;
|
||||||
@@ -309,10 +323,10 @@ export type ExcalidrawElbowArrowElement = Merge<
|
|||||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
type: "freedraw";
|
type: "freedraw";
|
||||||
points: readonly Point[];
|
points: readonly LocalPoint[];
|
||||||
pressures: readonly number[];
|
pressures: readonly number[];
|
||||||
simulatePressure: boolean;
|
simulatePressure: boolean;
|
||||||
lastCommittedPoint: Point | null;
|
lastCommittedPoint: LocalPoint | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type FileId = string & { _brand: "FileId" };
|
export type FileId = string & { _brand: "FileId" };
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
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 loadHbSubset from "./wasm/hb-subset.loader";
|
||||||
|
|
||||||
export interface Font {
|
export interface Font {
|
||||||
urls: URL[];
|
urls: URL[];
|
||||||
fontFace: FontFace;
|
fontFace: FontFace;
|
||||||
getContent(): Promise<string>;
|
getContent(codePoints: ReadonlySet<number>): Promise<string>;
|
||||||
}
|
}
|
||||||
export const UNPKG_PROD_URL = `https://unpkg.com/${
|
export const UNPKG_FALLBACK_URL = `https://unpkg.com/${
|
||||||
import.meta.env.VITE_PKG_NAME
|
import.meta.env.VITE_PKG_NAME
|
||||||
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
|
? `${import.meta.env.VITE_PKG_NAME}@${import.meta.env.PKG_VERSION}` // should be provided by vite during package build
|
||||||
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
: "@excalidraw/excalidraw" // fallback to latest package version (i.e. for app)
|
||||||
@@ -32,21 +38,29 @@ export class ExcalidrawFont implements Font {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to fetch woff2 content, based on the registered urls.
|
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
|
||||||
* Returns last defined url in case of errors.
|
|
||||||
*
|
*
|
||||||
* Note: uses browser APIs for base64 encoding - use dataurl outside the browser environment.
|
* NOTE: assumes usage of `dataurl` outside the browser environment
|
||||||
|
*
|
||||||
|
* @returns base64 with subsetted glyphs based on the passed codepoint, last defined url otherwise
|
||||||
*/
|
*/
|
||||||
public async getContent(): Promise<string> {
|
public async getContent(codePoints: ReadonlySet<number>): Promise<string> {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const errorMessages = [];
|
const errorMessages = [];
|
||||||
|
|
||||||
while (i < this.urls.length) {
|
while (i < this.urls.length) {
|
||||||
const url = this.urls[i];
|
const url = this.urls[i];
|
||||||
|
|
||||||
|
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||||
if (url.protocol === "data:") {
|
if (url.protocol === "data:") {
|
||||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
|
||||||
return url.toString();
|
|
||||||
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer,
|
||||||
|
codePoints,
|
||||||
|
);
|
||||||
|
|
||||||
|
return base64;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,13 +71,13 @@ export class ExcalidrawFont implements Font {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const mimeType = await response.headers.get("Content-Type");
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const buffer = await response.arrayBuffer();
|
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer,
|
||||||
|
codePoints,
|
||||||
|
);
|
||||||
|
|
||||||
return `data:${mimeType};base64,${await stringToBase64(
|
return base64;
|
||||||
await toByteString(buffer),
|
|
||||||
true,
|
|
||||||
)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// response not ok, try to continue
|
// response not ok, try to continue
|
||||||
@@ -89,6 +103,48 @@ export class ExcalidrawFont implements Font {
|
|||||||
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
return this.urls.length ? this.urls[this.urls.length - 1].toString() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to subset glyphs in a font based on the used codepoints, returning the font as daturl.
|
||||||
|
*
|
||||||
|
* @param arrayBuffer font data buffer, preferrably in the woff2 format, though others should work as well
|
||||||
|
* @param codePoints codepoints used to subset the glyphs
|
||||||
|
*
|
||||||
|
* @returns font with subsetted glyphs (all glyphs in case of errors) converted into a dataurl
|
||||||
|
*/
|
||||||
|
private static async subsetGlyphsByCodePoints(
|
||||||
|
arrayBuffer: ArrayBuffer,
|
||||||
|
codePoints: ReadonlySet<number>,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// lazy loaded wasm modules to avoid multiple initializations in case of concurrent triggers
|
||||||
|
const { compress, decompress } = await loadWoff2();
|
||||||
|
const { subset } = await loadHbSubset();
|
||||||
|
|
||||||
|
const decompressedBinary = decompress(arrayBuffer).buffer;
|
||||||
|
const subsetSnft = subset(decompressedBinary, codePoints);
|
||||||
|
const compressedBinary = compress(subsetSnft.buffer);
|
||||||
|
|
||||||
|
return ExcalidrawFont.toBase64(compressedBinary.buffer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Skipped glyph subsetting", e);
|
||||||
|
// Fallback to encoding whole font in case of errors
|
||||||
|
return ExcalidrawFont.toBase64(arrayBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async toBase64(arrayBuffer: ArrayBuffer) {
|
||||||
|
let base64: string;
|
||||||
|
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
// node + server-side
|
||||||
|
base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||||
|
} else {
|
||||||
|
base64 = await stringToBase64(await toByteString(arrayBuffer), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data:font/woff2;base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
private static createUrls(uri: string): URL[] {
|
private static createUrls(uri: string): URL[] {
|
||||||
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
||||||
// no url for local fonts
|
// no url for local fonts
|
||||||
@@ -118,15 +174,14 @@ export class ExcalidrawFont implements Font {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fallback url for bundled fonts
|
// fallback url for bundled fonts
|
||||||
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
|
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
|
||||||
|
|
||||||
return urls;
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getFormat(url: URL) {
|
private static getFormat(url: URL) {
|
||||||
try {
|
try {
|
||||||
const pathname = new URL(url).pathname;
|
const parts = new URL(url).pathname.split(".");
|
||||||
const parts = pathname.split(".");
|
|
||||||
|
|
||||||
if (parts.length === 1) {
|
if (parts.length === 1) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Modified version of hb-subset bindings from "subset-font" package https://github.com/papandreou/subset-font/blob/3f711c8aa29a426c7f22655861abfb976950f527/index.js
|
||||||
|
*
|
||||||
|
* CHANGELOG:
|
||||||
|
* - removed dependency on node APIs to work inside the browser
|
||||||
|
* - removed dependency on font fontverter for brotli compression
|
||||||
|
* - removed dependencies on lodash and p-limit
|
||||||
|
* - removed options for preserveNameIds, variationAxes, noLayoutClosure (not needed for now)
|
||||||
|
* - replaced text input with codepoints
|
||||||
|
* - rewritten in typescript and with esm modules
|
||||||
|
|
||||||
|
Copyright (c) 2012, Andreas Lind Petersen
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are
|
||||||
|
met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in
|
||||||
|
the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
* Neither the name of the author nor the names of contributors may
|
||||||
|
be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||||
|
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// function HB_TAG(str) {
|
||||||
|
// return str.split("").reduce((a, ch) => {
|
||||||
|
// return (a << 8) + ch.charCodeAt(0);
|
||||||
|
// }, 0);
|
||||||
|
// }
|
||||||
|
|
||||||
|
function subset(
|
||||||
|
hbSubsetWasm: any,
|
||||||
|
heapu8: Uint8Array,
|
||||||
|
font: ArrayBuffer,
|
||||||
|
codePoints: ReadonlySet<number>,
|
||||||
|
) {
|
||||||
|
const input = hbSubsetWasm.hb_subset_input_create_or_fail();
|
||||||
|
if (input === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"hb_subset_input_create_or_fail (harfbuzz) returned zero, indicating failure",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontBuffer = hbSubsetWasm.malloc(font.byteLength);
|
||||||
|
heapu8.set(new Uint8Array(font), fontBuffer);
|
||||||
|
|
||||||
|
// Create the face
|
||||||
|
const blob = hbSubsetWasm.hb_blob_create(
|
||||||
|
fontBuffer,
|
||||||
|
font.byteLength,
|
||||||
|
2, // HB_MEMORY_MODE_WRITABLE
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const face = hbSubsetWasm.hb_face_create(blob, 0);
|
||||||
|
hbSubsetWasm.hb_blob_destroy(blob);
|
||||||
|
|
||||||
|
// Do the equivalent of --font-features=*
|
||||||
|
const layoutFeatures = hbSubsetWasm.hb_subset_input_set(
|
||||||
|
input,
|
||||||
|
6, // HB_SUBSET_SETS_LAYOUT_FEATURE_TAG
|
||||||
|
);
|
||||||
|
hbSubsetWasm.hb_set_clear(layoutFeatures);
|
||||||
|
hbSubsetWasm.hb_set_invert(layoutFeatures);
|
||||||
|
|
||||||
|
// if (preserveNameIds) {
|
||||||
|
// const inputNameIds = harfbuzzJsWasm.hb_subset_input_set(
|
||||||
|
// input,
|
||||||
|
// 4, // HB_SUBSET_SETS_NAME_ID
|
||||||
|
// );
|
||||||
|
// for (const nameId of preserveNameIds) {
|
||||||
|
// harfbuzzJsWasm.hb_set_add(inputNameIds, nameId);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (noLayoutClosure) {
|
||||||
|
// harfbuzzJsWasm.hb_subset_input_set_flags(
|
||||||
|
// input,
|
||||||
|
// harfbuzzJsWasm.hb_subset_input_get_flags(input) | 0x00000200, // HB_SUBSET_FLAGS_NO_LAYOUT_CLOSURE
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Add unicodes indices
|
||||||
|
const inputUnicodes = hbSubsetWasm.hb_subset_input_unicode_set(input);
|
||||||
|
for (const c of codePoints) {
|
||||||
|
hbSubsetWasm.hb_set_add(inputUnicodes, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (variationAxes) {
|
||||||
|
// for (const [axisName, value] of Object.entries(variationAxes)) {
|
||||||
|
// if (typeof value === "number") {
|
||||||
|
// // Simple case: Pin/instance the variation axis to a single value
|
||||||
|
// if (
|
||||||
|
// !harfbuzzJsWasm.hb_subset_input_pin_axis_location(
|
||||||
|
// input,
|
||||||
|
// face,
|
||||||
|
// HB_TAG(axisName),
|
||||||
|
// value,
|
||||||
|
// )
|
||||||
|
// ) {
|
||||||
|
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||||
|
// harfbuzzJsWasm.free(fontBuffer);
|
||||||
|
// throw new Error(
|
||||||
|
// `hb_subset_input_pin_axis_location (harfbuzz) returned zero when pinning ${axisName} to ${value}, indicating failure. Maybe the axis does not exist in the font?`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// } else if (value && typeof value === "object") {
|
||||||
|
// // Complex case: Reduce the variation space of the axis
|
||||||
|
// if (
|
||||||
|
// typeof value.min === "undefined" ||
|
||||||
|
// typeof value.max === "undefined"
|
||||||
|
// ) {
|
||||||
|
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||||
|
// harfbuzzJsWasm.free(fontBuffer);
|
||||||
|
// throw new Error(
|
||||||
|
// `${axisName}: You must provide both a min and a max value when setting the axis range`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// if (
|
||||||
|
// !harfbuzzJsWasm.hb_subset_input_set_axis_range(
|
||||||
|
// input,
|
||||||
|
// face,
|
||||||
|
// HB_TAG(axisName),
|
||||||
|
// value.min,
|
||||||
|
// value.max,
|
||||||
|
// // An explicit NaN makes harfbuzz use the existing default value, clamping to the new range if necessary
|
||||||
|
// value.default ?? NaN,
|
||||||
|
// )
|
||||||
|
// ) {
|
||||||
|
// harfbuzzJsWasm.hb_face_destroy(face);
|
||||||
|
// harfbuzzJsWasm.free(fontBuffer);
|
||||||
|
// throw new Error(
|
||||||
|
// `hb_subset_input_set_axis_range (harfbuzz) returned zero when setting the range of ${axisName} to [${value.min}; ${value.max}] and a default value of ${value.default}, indicating failure. Maybe the axis does not exist in the font?`,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
let subset;
|
||||||
|
try {
|
||||||
|
subset = hbSubsetWasm.hb_subset_or_fail(face, input);
|
||||||
|
if (subset === 0) {
|
||||||
|
hbSubsetWasm.hb_face_destroy(face);
|
||||||
|
hbSubsetWasm.free(fontBuffer);
|
||||||
|
throw new Error(
|
||||||
|
"hb_subset_or_fail (harfbuzz) returned zero, indicating failure. Maybe the input file is corrupted?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
hbSubsetWasm.hb_subset_input_destroy(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get result blob
|
||||||
|
const result = hbSubsetWasm.hb_face_reference_blob(subset);
|
||||||
|
|
||||||
|
const offset = hbSubsetWasm.hb_blob_get_data(result, 0);
|
||||||
|
const subsetByteLength = hbSubsetWasm.hb_blob_get_length(result);
|
||||||
|
if (subsetByteLength === 0) {
|
||||||
|
hbSubsetWasm.hb_blob_destroy(result);
|
||||||
|
hbSubsetWasm.hb_face_destroy(subset);
|
||||||
|
hbSubsetWasm.hb_face_destroy(face);
|
||||||
|
hbSubsetWasm.free(fontBuffer);
|
||||||
|
throw new Error(
|
||||||
|
"Failed to create subset font, maybe the input file is corrupted?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subsetFont = new Uint8Array(
|
||||||
|
heapu8.subarray(offset, offset + subsetByteLength),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
hbSubsetWasm.hb_blob_destroy(result);
|
||||||
|
hbSubsetWasm.hb_face_destroy(subset);
|
||||||
|
hbSubsetWasm.hb_face_destroy(face);
|
||||||
|
hbSubsetWasm.free(fontBuffer);
|
||||||
|
|
||||||
|
return subsetFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
subset,
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Lazy loads wasm and respective bindings for font subsetting based on the harfbuzzjs.
|
||||||
|
*/
|
||||||
|
let loadedWasm: ReturnType<typeof load> | null = null;
|
||||||
|
|
||||||
|
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
|
||||||
|
const load = (): Promise<{
|
||||||
|
subset: (
|
||||||
|
fontBuffer: ArrayBuffer,
|
||||||
|
codePoints: ReadonlySet<number>,
|
||||||
|
) => Uint8Array;
|
||||||
|
}> => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const [binary, bindings] = await Promise.all([
|
||||||
|
import("./hb-subset.wasm"),
|
||||||
|
import("./hb-subset.bindings"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
WebAssembly.instantiate(binary.default).then((module) => {
|
||||||
|
try {
|
||||||
|
const harfbuzzJsWasm = module.instance.exports;
|
||||||
|
// @ts-expect-error since `.buffer` is custom prop
|
||||||
|
const heapu8 = new Uint8Array(harfbuzzJsWasm.memory.buffer);
|
||||||
|
|
||||||
|
const hbSubset = {
|
||||||
|
subset: (
|
||||||
|
fontBuffer: ArrayBuffer,
|
||||||
|
codePoints: ReadonlySet<number>,
|
||||||
|
) => {
|
||||||
|
return bindings.default.subset(
|
||||||
|
harfbuzzJsWasm,
|
||||||
|
heapu8,
|
||||||
|
fontBuffer,
|
||||||
|
codePoints,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(hbSubset);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// lazy load the default export
|
||||||
|
export default (): ReturnType<typeof load> => {
|
||||||
|
if (!loadedWasm) {
|
||||||
|
loadedWasm = load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedWasm;
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Lazy loads wasm and respective bindings for woff2 compression and decompression.
|
||||||
|
*/
|
||||||
|
type Vector = any;
|
||||||
|
|
||||||
|
let loadedWasm: ReturnType<typeof load> | null = null;
|
||||||
|
|
||||||
|
// TODO: add support for fetching the wasm from an URL (external CDN, data URL, etc.)
|
||||||
|
const load = (): Promise<{
|
||||||
|
compress: (buffer: ArrayBuffer) => Uint8Array;
|
||||||
|
decompress: (buffer: ArrayBuffer) => Uint8Array;
|
||||||
|
}> => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const [binary, bindings] = await Promise.all([
|
||||||
|
import("./woff2.wasm"),
|
||||||
|
import("./woff2.bindings"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// initializing the module manually, so that we could pass in the wasm binary
|
||||||
|
bindings
|
||||||
|
.default({ wasmBinary: binary.default })
|
||||||
|
.then(
|
||||||
|
(module: {
|
||||||
|
woff2Enc: (buffer: ArrayBuffer, byteLength: number) => Vector;
|
||||||
|
woff2Dec: (buffer: ArrayBuffer, byteLength: number) => Vector;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// re-map from internal vector into byte array
|
||||||
|
function convertFromVecToUint8Array(vector: Vector): Uint8Array {
|
||||||
|
const arr = [];
|
||||||
|
for (let i = 0, l = vector.size(); i < l; i++) {
|
||||||
|
arr.push(vector.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-exporting only compress and decompress functions (also avoids infinite loop inside emscripten bindings)
|
||||||
|
const woff2 = {
|
||||||
|
compress: (buffer: ArrayBuffer) =>
|
||||||
|
convertFromVecToUint8Array(
|
||||||
|
module.woff2Enc(buffer, buffer.byteLength),
|
||||||
|
),
|
||||||
|
decompress: (buffer: ArrayBuffer) =>
|
||||||
|
convertFromVecToUint8Array(
|
||||||
|
module.woff2Dec(buffer, buffer.byteLength),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(woff2);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// lazy loaded default export
|
||||||
|
export default (): ReturnType<typeof load> => {
|
||||||
|
if (!loadedWasm) {
|
||||||
|
loadedWasm = load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedWasm;
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,6 @@ import type {
|
|||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { isPointWithinBounds } from "./math";
|
|
||||||
import {
|
import {
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
getContainerElement,
|
getContainerElement,
|
||||||
@@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
|
|||||||
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
|
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
|
||||||
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
||||||
import type { ReadonlySetLike } from "./utility-types";
|
import type { ReadonlySetLike } from "./utility-types";
|
||||||
|
import { isPointWithinBounds, point } from "../math";
|
||||||
|
|
||||||
// --------------------------- Frame State ------------------------------------
|
// --------------------------- Frame State ------------------------------------
|
||||||
export const bindElementsToFramesAfterDuplication = (
|
export const bindElementsToFramesAfterDuplication = (
|
||||||
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
|
|||||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||||
|
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(
|
||||||
[fx1, fy1],
|
point(fx1, fy1),
|
||||||
[cursorCoords.x, cursorCoords.y],
|
point(cursorCoords.x, cursorCoords.y),
|
||||||
[fx2, fy2],
|
point(fx2, fy2),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
const TIMEOUT = 2000;
|
const TIMEOUT = 2000;
|
||||||
|
|
||||||
@@ -15,8 +15,13 @@ export const useCopyStatus = () => {
|
|||||||
}, TIMEOUT);
|
}, TIMEOUT);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetCopyStatus = useCallback(() => {
|
||||||
|
setCopyStatus(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
copyStatus,
|
copyStatus,
|
||||||
|
resetCopyStatus,
|
||||||
onCopy,
|
onCopy,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
|||||||
},
|
},
|
||||||
files: null,
|
files: null,
|
||||||
renderEmbeddables: false,
|
renderEmbeddables: false,
|
||||||
|
skipInliningFonts: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const useLibraryItemSvg = (
|
|||||||
// When there is no svg in cache export it and save to cache
|
// When there is no svg in cache export it and save to cache
|
||||||
(async () => {
|
(async () => {
|
||||||
const exportedSvg = await exportLibraryItemToSvg(elements);
|
const exportedSvg = await exportLibraryItemToSvg(elements);
|
||||||
|
// TODO: should likely be removed for custom fonts
|
||||||
exportedSvg.querySelector(".style-fonts")?.remove();
|
exportedSvg.querySelector(".style-fonts")?.remove();
|
||||||
|
|
||||||
if (exportedSvg) {
|
if (exportedSvg) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user