Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c8adfa1a9 | |||
| a977dd1bf5 | |||
| 3fe1883f3f | |||
| a80cb5896a | |||
| 6dfa18414a | |||
| 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!
|
||||
# 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
|
||||
# debugging Service Workers.
|
||||
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
||||
@@ -8,3 +8,4 @@ public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
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
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ exportToCanvas({<br/>
|
||||
getDimensions,<br/>
|
||||
files,<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>
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|
||||
+41
-22
@@ -1547,7 +1547,7 @@
|
||||
"@docusaurus/theme-search-algolia" "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"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce"
|
||||
integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==
|
||||
@@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7:
|
||||
balanced-match "^1.0.0"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
@@ -4011,6 +4018,13 @@ fill-range@^7.0.1:
|
||||
dependencies:
|
||||
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:
|
||||
version "1.2.0"
|
||||
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==
|
||||
|
||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
|
||||
integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
|
||||
dependencies:
|
||||
braces "^3.0.2"
|
||||
braces "^3.0.3"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
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"
|
||||
text-table "^0.2.0"
|
||||
|
||||
react-dom@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23"
|
||||
integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
|
||||
react-dom@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
|
||||
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
scheduler "^0.20.2"
|
||||
scheduler "^0.23.0"
|
||||
|
||||
react-error-overlay@^6.0.11:
|
||||
version "6.0.11"
|
||||
@@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@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:
|
||||
version "5.1.1"
|
||||
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-latest "^1.2.1"
|
||||
|
||||
react@^17.0.2:
|
||||
version "17.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
|
||||
integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
|
||||
react@18.2.0:
|
||||
version "18.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
|
||||
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
readable-stream@^2.0.1:
|
||||
version "2.3.7"
|
||||
@@ -6664,13 +6684,12 @@ sax@^1.2.4:
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
||||
scheduler@^0.20.2:
|
||||
version "0.20.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
|
||||
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
|
||||
scheduler@^0.23.0:
|
||||
version "0.23.2"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3"
|
||||
integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
schema-utils@2.7.0:
|
||||
version "2.7.0"
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ import type {
|
||||
} from "@excalidraw/excalidraw/dist/excalidraw/element/types";
|
||||
import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
|
||||
|
||||
import "./App.scss";
|
||||
import "./ExampleApp.scss";
|
||||
|
||||
type Comment = {
|
||||
x: number;
|
||||
@@ -73,7 +73,7 @@ export interface AppProps {
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}
|
||||
|
||||
export default function App({
|
||||
export default function ExampleApp({
|
||||
appTitle,
|
||||
useCustom,
|
||||
customArgs,
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import App from "../../components/App";
|
||||
import App from "../../components/ExampleApp";
|
||||
|
||||
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 { createRoot } from "react-dom/client";
|
||||
|
||||
|
||||
+43
-1
@@ -120,6 +120,11 @@ import {
|
||||
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
|
||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||
import { useAppLangCode } from "./app-language/language-state";
|
||||
import DebugCanvas, {
|
||||
debugRenderer,
|
||||
isVisualDebuggerEnabled,
|
||||
loadSavedDebugState,
|
||||
} from "./components/DebugCanvas";
|
||||
import { AIComponents } from "./components/AI";
|
||||
|
||||
polyfill();
|
||||
@@ -337,6 +342,8 @@ const ExcalidrawWrapper = () => {
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
}
|
||||
|
||||
const debugCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
@@ -362,6 +369,23 @@ const ExcalidrawWrapper = () => {
|
||||
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(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
@@ -622,6 +646,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>(
|
||||
@@ -820,6 +854,7 @@ const ExcalidrawWrapper = () => {
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
theme={appTheme}
|
||||
setTheme={(theme) => setAppTheme(theme)}
|
||||
refresh={() => forceRefresh((prev) => !prev)}
|
||||
/>
|
||||
<AppWelcomeScreen
|
||||
onCollabDialogOpen={onCollabDialogOpen}
|
||||
@@ -845,7 +880,7 @@ const ExcalidrawWrapper = () => {
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||
|
||||
<TTDDialogTrigger />
|
||||
@@ -1077,6 +1112,13 @@ const ExcalidrawWrapper = () => {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{isVisualDebuggerEnabled() && excalidrawAPI && (
|
||||
<DebugCanvas
|
||||
appState={excalidrawAPI.getAppState()}
|
||||
scale={window.devicePixelRatio}
|
||||
ref={debugCanvasRef}
|
||||
/>
|
||||
)}
|
||||
</Excalidraw>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ export const STORAGE_KEYS = {
|
||||
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
|
||||
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
|
||||
LOCAL_STORAGE_THEME: "excalidraw-theme",
|
||||
LOCAL_STORAGE_DEBUG: "excalidraw-debug",
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
|
||||
@@ -3,23 +3,27 @@ import { Footer } from "../../packages/excalidraw/index";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||
|
||||
export const AppFooter = React.memo(() => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
});
|
||||
export const AppFooter = React.memo(
|
||||
({ onChange }: { onChange: () => void }) => {
|
||||
return (
|
||||
<Footer>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: ".5rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,13 @@ import React from "react";
|
||||
import {
|
||||
loginIcon,
|
||||
ExcalLogo,
|
||||
eyeIcon,
|
||||
} from "../../packages/excalidraw/components/icons";
|
||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||
import { MainMenu } from "../../packages/excalidraw/index";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
import { LanguageList } from "../app-language/LanguageList";
|
||||
import { saveDebugState } from "./DebugCanvas";
|
||||
|
||||
export const AppMainMenu: React.FC<{
|
||||
onCollabDialogOpen: () => any;
|
||||
@@ -14,6 +16,7 @@ export const AppMainMenu: React.FC<{
|
||||
isCollabEnabled: boolean;
|
||||
theme: Theme | "system";
|
||||
setTheme: (theme: Theme | "system") => void;
|
||||
refresh: () => void;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
@@ -28,6 +31,7 @@ export const AppMainMenu: React.FC<{
|
||||
/>
|
||||
)}
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
@@ -50,6 +54,23 @@ export const AppMainMenu: React.FC<{
|
||||
>
|
||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||
</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.DefaultItems.ToggleTheme
|
||||
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;
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
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 { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
|
||||
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
if (
|
||||
_appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||
_appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||
) {
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -128,40 +128,8 @@
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = window.origin;
|
||||
</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 -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<!-- Register Assistant as the UI font, before the scene inits -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
|
||||
@@ -48,6 +48,8 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
// don't auto-inline small assets (i.e. fonts hosted on CDN)
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [
|
||||
woff2BrowserPlugin(),
|
||||
@@ -73,8 +75,8 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
workbox: {
|
||||
// Don't push fonts and locales to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js"],
|
||||
// Don't push fonts, locales and wasm to app precache
|
||||
globIgnores: ["fonts.css", "**/locales/**", "service-worker.js", "**/*.wasm-*.js"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: new RegExp("/.+.(ttf|woff2|otf)"),
|
||||
@@ -108,6 +110,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: {
|
||||
|
||||
+9
-7
@@ -6,6 +6,7 @@
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
"packages/utils",
|
||||
"packages/math",
|
||||
"examples/excalidraw",
|
||||
"examples/excalidraw/*"
|
||||
],
|
||||
@@ -20,8 +21,8 @@
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
"@vitejs/plugin-react": "3.1.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vitest/ui": "0.32.2",
|
||||
"@vitest/coverage-v8": "2.0.5",
|
||||
"@vitest/ui": "2.0.5",
|
||||
"chai": "4.3.6",
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
@@ -36,12 +37,12 @@
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.6.1",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
"vite-plugin-pwa": "0.17.4",
|
||||
"vite-plugin-svgr": "2.4.0",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-canvas-mock": "0.3.2"
|
||||
"vite-plugin-svgr": "4.2.0",
|
||||
"vitest": "2.0.5",
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 20.x.x"
|
||||
@@ -82,6 +83,7 @@
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
"@types/react": "18.2.0",
|
||||
"strip-ansi": "6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
### Features
|
||||
|
||||
- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
||||
@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import type { AppState } from "../types";
|
||||
import type { AppState, Offsets } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import type { SceneBounds } from "../element/bounds";
|
||||
import { setCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { clamp } from "../math";
|
||||
import { clamp, roundToStep } from "../../math";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
const adjustedZoomValue =
|
||||
smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
const zoomAdjustedToSteps =
|
||||
Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
|
||||
|
||||
return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
|
||||
return Math.min(adjustedZoomValue, 1);
|
||||
};
|
||||
|
||||
export const zoomToFitBounds = ({
|
||||
bounds,
|
||||
appState,
|
||||
canvasOffsets,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 1,
|
||||
minZoom = -Infinity,
|
||||
maxZoom = Infinity,
|
||||
}: {
|
||||
bounds: SceneBounds;
|
||||
canvasOffsets?: Offsets;
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
const canvasOffsetLeft = canvasOffsets?.left ?? 0;
|
||||
const canvasOffsetTop = canvasOffsets?.top ?? 0;
|
||||
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) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
adjustedZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * clamp(viewportZoomFactor, 0.1, 1);
|
||||
|
||||
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;
|
||||
effectiveCanvasWidth / commonBoundsWidth,
|
||||
effectiveCanvasHeight / commonBoundsHeight,
|
||||
) * viewportZoomFactor;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
adjustedZoomValue = zoomValueToFitBoundsOnViewport(
|
||||
bounds,
|
||||
{
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
width: effectiveCanvasWidth,
|
||||
height: effectiveCanvasHeight,
|
||||
},
|
||||
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 {
|
||||
appState: {
|
||||
...appState,
|
||||
scrollX,
|
||||
scrollY,
|
||||
scrollX: centerScroll.scrollX,
|
||||
scrollY: centerScroll.scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
|
||||
};
|
||||
|
||||
export const zoomToFit = ({
|
||||
canvasOffsets,
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
}: {
|
||||
canvasOffsets?: Offsets;
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
return zoomToFitBounds({
|
||||
canvasOffsets,
|
||||
bounds: commonBounds,
|
||||
appState,
|
||||
fitToViewport,
|
||||
viewportZoomFactor,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: true,
|
||||
viewportZoomFactor: 0.7,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
|
||||
icon: zoomAreaIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) =>
|
||||
perform: (elements, appState, _, app) =>
|
||||
zoomToFit({
|
||||
targetElements: elements,
|
||||
appState: {
|
||||
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
|
||||
userToFollow: null,
|
||||
},
|
||||
fitToViewport: false,
|
||||
canvasOffsets: app.getEditorUIOffsets(),
|
||||
}),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
|
||||
@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
|
||||
perform: (elements, appState, formData, app) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
const ret = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
appState,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
return {
|
||||
elements,
|
||||
appState: newAppState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: ret.appState,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { done } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { isPathALoop } from "../math";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { point } from "../../math";
|
||||
import { isPathALoop } from "../shapes";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
@@ -112,10 +113,10 @@ export const actionFinalize = register({
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((point, index) =>
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? ([firstPoint[0], firstPoint[1]] as const)
|
||||
: point,
|
||||
? point(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -216,6 +217,7 @@ export const actionFinalize = register({
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
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 { getNonDeletedElements } from "../element";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedSceneElementsMap,
|
||||
@@ -18,7 +20,13 @@ import {
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||
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({
|
||||
name: "flipHorizontal",
|
||||
@@ -109,7 +117,23 @@ const flipElements = (
|
||||
flipDirection: "horizontal" | "vertical",
|
||||
app: AppClassProperties,
|
||||
): 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(
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
@@ -115,6 +115,8 @@ import {
|
||||
} from "../element/binding";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point, vector } from "../../math";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@@ -850,7 +852,7 @@ export const actionChangeFontFamily = register({
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawElement | null
|
||||
>();
|
||||
let uniqueGlyphs = new Set<string>();
|
||||
let uniqueChars = new Set<string>();
|
||||
let skipFontFaceCheck = false;
|
||||
|
||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||
@@ -898,8 +900,8 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
uniqueGlyphs = new Set([
|
||||
...uniqueGlyphs,
|
||||
uniqueChars = new Set([
|
||||
...uniqueChars,
|
||||
...Array.from(newElement.originalText),
|
||||
]);
|
||||
}
|
||||
@@ -919,12 +921,9 @@ export const actionChangeFontFamily = register({
|
||||
const fontString = `10px ${getFontFamilyString({
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||
const chars = Array.from(uniqueChars.values()).join();
|
||||
|
||||
if (
|
||||
skipFontFaceCheck ||
|
||||
window.document.fonts.check(fontString, glyphs)
|
||||
) {
|
||||
if (skipFontFaceCheck || window.document.fonts.check(fontString, chars)) {
|
||||
// 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) {
|
||||
// trigger synchronous redraw
|
||||
@@ -936,8 +935,8 @@ export const actionChangeFontFamily = register({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||
// otherwise try to load all font faces for the given chars and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, chars).then((fontFaces) => {
|
||||
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)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
@@ -1651,10 +1650,10 @@ export const actionChangeArrowType = register({
|
||||
newElement,
|
||||
elementsMap,
|
||||
[finalStartPoint, finalEndPoint].map(
|
||||
(point) =>
|
||||
[point[0] - newElement.x, point[1] - newElement.y] as Point,
|
||||
(p): LocalPoint =>
|
||||
point(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
[0, 0],
|
||||
vector(0, 0),
|
||||
{
|
||||
...(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;
|
||||
|
||||
@@ -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 { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||
|
||||
@@ -51,7 +51,8 @@ export type ShortcutName =
|
||||
>
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
|
||||
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
||||
@@ -137,7 +137,8 @@ export type ActionName =
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
| "elementStats"
|
||||
| "searchMenu";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -191,7 +192,8 @@ export interface Action {
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "search_menu";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// place here categories that you want to track. We want to track just a
|
||||
// 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 = (
|
||||
category: string,
|
||||
|
||||
@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
objectsSnapModeEnabled: { browser: true, export: false, server: false },
|
||||
userToFollow: { browser: false, export: false, server: false },
|
||||
followedBy: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Radians } from "../math";
|
||||
import { point } from "../math";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
@@ -203,7 +205,7 @@ const chartXLabels = (
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
angle: 5.87,
|
||||
angle: 5.87 as Radians,
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
@@ -258,10 +260,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@@ -272,10 +271,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
points: [point(0, 0), point(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@@ -288,10 +284,7 @@ const chartLines = (
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@@ -448,10 +441,7 @@ const chartTypeLine = (
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
points: [point(0, 0), point(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { CLASSES } from "../constants";
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
@@ -426,7 +427,7 @@ export const ZoomActions = ({
|
||||
renderAction: ActionManager["renderAction"];
|
||||
zoom: Zoom;
|
||||
}) => (
|
||||
<Stack.Col gap={1} className="zoom-actions">
|
||||
<Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}>
|
||||
<Stack.Row align="center">
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
|
||||
@@ -185,6 +185,7 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -210,12 +211,6 @@ import {
|
||||
isElementCompletelyInViewport,
|
||||
isElementInViewport,
|
||||
} from "../element/sizeHelpers";
|
||||
import {
|
||||
distance2d,
|
||||
getCornerRadius,
|
||||
getGridPoint,
|
||||
isPathALoop,
|
||||
} from "../math";
|
||||
import {
|
||||
calculateScrollCenter,
|
||||
getElementsWithinSelection,
|
||||
@@ -230,7 +225,13 @@ import type {
|
||||
ScrollBars,
|
||||
} from "../scene/types";
|
||||
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 { isPointInShape } from "../../utils/collision";
|
||||
import type {
|
||||
@@ -259,6 +260,7 @@ import type {
|
||||
ElementsPendingErasure,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -286,6 +288,7 @@ import {
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@@ -386,6 +389,7 @@ import {
|
||||
getReferenceSnapPoints,
|
||||
SnapCache,
|
||||
isGridModeEnabled,
|
||||
getGridPoint,
|
||||
} from "../snapping";
|
||||
import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||
@@ -433,12 +437,15 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
||||
import {
|
||||
FlowChartCreator,
|
||||
FlowChartNavigator,
|
||||
getLinkDirectionFromKey,
|
||||
} 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 AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -545,6 +552,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public scene: Scene;
|
||||
public fonts: Fonts;
|
||||
public renderer: Renderer;
|
||||
public visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
private resizeObserver: ResizeObserver | undefined;
|
||||
private nearestScrollableContainer: HTMLElement | Document | undefined;
|
||||
public library: AppClassProperties["library"];
|
||||
@@ -552,7 +560,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public id: string;
|
||||
private store: Store;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
public excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
id: string;
|
||||
};
|
||||
@@ -679,6 +687,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
this.visibleElements = [];
|
||||
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
@@ -1477,6 +1486,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElementId: this.state.newElement?.id,
|
||||
pendingImageElementId: this.state.pendingImageElementId,
|
||||
});
|
||||
this.visibleElements = visibleElements;
|
||||
|
||||
const allElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
@@ -2292,6 +2302,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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
|
||||
// fire (looking at you Safari), so on init we manually load all
|
||||
// fonts and rerender scene text elements once done. This also
|
||||
@@ -2357,6 +2370,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
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() {
|
||||
this.unmounted = false;
|
||||
this.excalidrawContainerValue.container =
|
||||
@@ -2515,7 +2538,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
addEventListener(
|
||||
this.excalidrawContainerRef.current,
|
||||
EVENT.WHEEL,
|
||||
this.onWheel,
|
||||
this.handleWheel,
|
||||
{ passive: 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.UNLOAD, this.onUnload, false),
|
||||
addEventListener(window, EVENT.BLUR, this.onBlur, false),
|
||||
addEventListener(
|
||||
this.excalidrawContainerRef.current,
|
||||
EVENT.WHEEL,
|
||||
this.handleWheel,
|
||||
),
|
||||
addEventListener(
|
||||
this.excalidrawContainerRef.current,
|
||||
EVENT.DRAG_OVER,
|
||||
@@ -3083,7 +3111,45 @@ class App extends React.Component<AppProps, AppState> {
|
||||
retainSeed?: 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 elementsCenterX = distance(minX, maxX) / 2;
|
||||
@@ -3207,6 +3273,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(newElements, {
|
||||
fitToContent: true,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3519,7 +3586,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?:
|
||||
opts?: (
|
||||
| {
|
||||
fitToContent?: boolean;
|
||||
fitToViewport?: never;
|
||||
@@ -3536,7 +3603,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewportZoomFactor?: number;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
},
|
||||
}
|
||||
) & {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
canvasOffsets?: Offsets;
|
||||
},
|
||||
) => {
|
||||
this.cancelInProgressAnimation?.();
|
||||
|
||||
@@ -3549,10 +3621,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (opts?.fitToContent || opts?.fitToViewport) {
|
||||
const { appState } = zoomToFit({
|
||||
canvasOffsets: opts.canvasOffsets,
|
||||
targetElements,
|
||||
appState: this.state,
|
||||
fitToViewport: !!opts?.fitToViewport,
|
||||
viewportZoomFactor: opts?.viewportZoomFactor,
|
||||
minZoom: opts?.minZoom,
|
||||
maxZoom: opts?.maxZoom,
|
||||
});
|
||||
zoom = appState.zoom;
|
||||
scrollX = appState.scrollX;
|
||||
@@ -3666,15 +3741,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.files = { ...this.files, ...Object.fromEntries(filesMap) };
|
||||
|
||||
this.scene.getNonDeletedElements().forEach((element) => {
|
||||
if (
|
||||
isInitializedImageElement(element) &&
|
||||
filesMap.has(element.fileId)
|
||||
) {
|
||||
this.imageCache.delete(element.fileId);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
this.clearImageShapeCache(Object.fromEntries(filesMap));
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
this.addNewImagesToImageCache();
|
||||
@@ -3788,40 +3855,55 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private getEditorUIOffsets = (): {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
} => {
|
||||
public getEditorUIOffsets = (): Offsets => {
|
||||
const toolbarBottom =
|
||||
this.excalidrawContainerRef?.current
|
||||
(this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-toolbar")
|
||||
?.getBoundingClientRect()?.bottom ?? 0;
|
||||
const sidebarWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".default-sidebar")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
);
|
||||
const propertiesPanelWidth = Math.max(
|
||||
this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect()?.width ?? 0,
|
||||
0,
|
||||
);
|
||||
?.getBoundingClientRect()?.bottom ?? 0) - this.state.offsetTop;
|
||||
const sidebarRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".sidebar")
|
||||
?.getBoundingClientRect();
|
||||
const propertiesPanelRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect();
|
||||
const PADDING = 16;
|
||||
|
||||
const adjustRectValueForOffset = (
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
) => (value ?? fallback + this.state.offsetLeft) - this.state.offsetLeft;
|
||||
|
||||
return getLanguage().rtl
|
||||
? {
|
||||
top: toolbarBottom,
|
||||
right: propertiesPanelWidth,
|
||||
bottom: 0,
|
||||
left: sidebarWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right:
|
||||
Math.max(
|
||||
this.state.width -
|
||||
adjustRectValueForOffset(
|
||||
propertiesPanelRect?.left,
|
||||
this.state.width,
|
||||
),
|
||||
0,
|
||||
) + PADDING,
|
||||
bottom: PADDING,
|
||||
left:
|
||||
Math.max(adjustRectValueForOffset(sidebarRect?.right, 0), 0) +
|
||||
PADDING,
|
||||
}
|
||||
: {
|
||||
top: toolbarBottom,
|
||||
right: sidebarWidth,
|
||||
bottom: 0,
|
||||
left: propertiesPanelWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right: Math.max(
|
||||
this.state.width -
|
||||
adjustRectValueForOffset(sidebarRect?.left, this.state.width) +
|
||||
PADDING,
|
||||
0,
|
||||
),
|
||||
bottom: PADDING,
|
||||
left:
|
||||
Math.max(
|
||||
adjustRectValueForOffset(propertiesPanelRect?.right, 0),
|
||||
0,
|
||||
) + PADDING,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3906,7 +3988,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
fitToContent: true,
|
||||
viewportZoomFactor: 0.8,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3962,6 +4044,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(nextNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4318,13 +4401,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) => {
|
||||
if (event.key === KEYS.SPACE) {
|
||||
if (this.state.viewModeEnabled) {
|
||||
@@ -4401,6 +4477,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(firstNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4846,7 +4923,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.getElementHitThreshold(),
|
||||
);
|
||||
|
||||
return isPointInShape([x, y], selectionShape);
|
||||
return isPointInShape(point(x, y), selectionShape);
|
||||
}
|
||||
|
||||
// take bound text element into consideration for hit collision as well
|
||||
@@ -5037,7 +5114,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
lineHeight,
|
||||
angle: container?.angle ?? 0,
|
||||
angle: container?.angle ?? (0 as Radians),
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
|
||||
@@ -5205,7 +5282,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
[scenePointer.x, scenePointer.y],
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
this.device.editor.isMobile,
|
||||
)
|
||||
);
|
||||
@@ -5216,11 +5293,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
isTouchScreen: boolean,
|
||||
) => {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
const draggedDistance = pointDistance(
|
||||
point(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
),
|
||||
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
|
||||
);
|
||||
if (
|
||||
!this.hitLinkElement ||
|
||||
@@ -5239,7 +5317,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
||||
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
@@ -5250,7 +5328,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
||||
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
@@ -5499,17 +5577,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// if we haven't yet created a temp point and we're beyond commit-zone
|
||||
// threshold, add a point
|
||||
if (
|
||||
distance2d(
|
||||
scenePointerX - rx,
|
||||
scenePointerY - ry,
|
||||
lastPoint[0],
|
||||
lastPoint[1],
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
lastPoint,
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
mutateElement(
|
||||
multiElement,
|
||||
{
|
||||
points: [...points, [scenePointerX - rx, scenePointerY - ry]],
|
||||
points: [
|
||||
...points,
|
||||
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -5521,11 +5600,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else if (
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
distance2d(
|
||||
scenePointerX - rx,
|
||||
scenePointerY - ry,
|
||||
lastCommittedPoint[0],
|
||||
lastCommittedPoint[1],
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
|
||||
@@ -5572,10 +5649,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
[
|
||||
...points.slice(0, -1),
|
||||
[
|
||||
point<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
],
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -5591,10 +5668,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
[
|
||||
point<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
false,
|
||||
@@ -5819,17 +5896,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
const distance = distance2d(
|
||||
pointerDownState.lastCoords.x,
|
||||
pointerDownState.lastCoords.y,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
const distance = pointDistance(
|
||||
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
const threshold = this.getElementHitThreshold();
|
||||
const point = { ...pointerDownState.lastCoords };
|
||||
const p = { ...pointerDownState.lastCoords };
|
||||
let samplingInterval = 0;
|
||||
while (samplingInterval <= distance) {
|
||||
const hitElements = this.getElementsAtPosition(point.x, point.y);
|
||||
const hitElements = this.getElementsAtPosition(p.x, p.y);
|
||||
processElements(hitElements);
|
||||
|
||||
// Exit since we reached current point
|
||||
@@ -5841,12 +5916,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
samplingInterval = Math.min(samplingInterval + threshold, distance);
|
||||
|
||||
const distanceRatio = samplingInterval / distance;
|
||||
const nextX =
|
||||
(1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
|
||||
const nextY =
|
||||
(1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
|
||||
point.x = nextX;
|
||||
point.y = nextY;
|
||||
const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
|
||||
const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
|
||||
p.x = nextX;
|
||||
p.y = nextY;
|
||||
}
|
||||
|
||||
pointerDownState.lastCoords.x = scenePointer.x;
|
||||
@@ -5972,6 +6045,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
|
||||
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,
|
||||
// and an contextMenu action may depend on selection state, we must
|
||||
// close the contextMenu before we update the selection on pointerDown
|
||||
@@ -6327,7 +6410,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
[scenePointer.x, scenePointer.y],
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
)
|
||||
) {
|
||||
this.handleEmbeddableCenterClick(this.hitLinkElement);
|
||||
@@ -6384,8 +6467,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
// Returns whether the event is a panning
|
||||
private handleCanvasPanUsingWheelOrSpaceDrag = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
public handleCanvasPanUsingWheelOrSpaceDrag = (
|
||||
event: React.PointerEvent<HTMLElement> | MouseEvent,
|
||||
): boolean => {
|
||||
if (
|
||||
!(
|
||||
@@ -6394,13 +6477,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||
isHandToolActive(this.state) ||
|
||||
this.state.viewModeEnabled)
|
||||
) ||
|
||||
this.state.editingTextElement
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
const isLinux =
|
||||
@@ -7007,7 +7101,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
simulatePressure,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
points: [[0, 0]],
|
||||
points: [point<LocalPoint>(0, 0)],
|
||||
pressures: simulatePressure ? [] : [event.pressure],
|
||||
});
|
||||
|
||||
@@ -7215,11 +7309,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
multiElement.points.length > 1 &&
|
||||
lastCommittedPoint &&
|
||||
distance2d(
|
||||
pointerDownState.origin.x - rx,
|
||||
pointerDownState.origin.y - ry,
|
||||
lastCommittedPoint[0],
|
||||
lastCommittedPoint[1],
|
||||
pointDistance(
|
||||
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -7320,7 +7412,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
});
|
||||
mutateElement(element, {
|
||||
points: [...element.points, [0, 0]],
|
||||
points: [...element.points, point<LocalPoint>(0, 0)],
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
@@ -7572,11 +7664,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.activeTool.type === "line")
|
||||
) {
|
||||
if (
|
||||
distance2d(
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
pointDistance(
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
point(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
) < DRAGGING_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
@@ -7925,7 +8015,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, [dx, dy]],
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
},
|
||||
false,
|
||||
@@ -7954,7 +8044,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, [dx, dy]],
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -7962,8 +8052,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElbowArrow(
|
||||
newElement,
|
||||
elementsMap,
|
||||
[...points.slice(0, -1), [dx, dy]],
|
||||
[0, 0],
|
||||
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
vector(0, 0),
|
||||
undefined,
|
||||
{
|
||||
isDragging: true,
|
||||
@@ -7974,7 +8064,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), [dx, dy]],
|
||||
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -8283,9 +8373,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: [...newElement.pressures, childEvent.pressure];
|
||||
|
||||
mutateElement(newElement, {
|
||||
points: [...points, [dx, dy]],
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
lastCommittedPoint: [dx, dy],
|
||||
lastCommittedPoint: point<LocalPoint>(dx, dy),
|
||||
});
|
||||
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -8332,7 +8422,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(newElement, {
|
||||
points: [
|
||||
...newElement.points,
|
||||
[pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
|
||||
point<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
],
|
||||
});
|
||||
this.setState({
|
||||
@@ -8642,11 +8735,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (isEraserActive(this.state) && pointerStart && pointerEnd) {
|
||||
this.eraserTrail.endPath();
|
||||
|
||||
const draggedDistance = distance2d(
|
||||
pointerStart.clientX,
|
||||
pointerStart.clientY,
|
||||
pointerEnd.clientX,
|
||||
pointerEnd.clientY,
|
||||
const draggedDistance = pointDistance(
|
||||
point(pointerStart.clientX, pointerStart.clientY),
|
||||
point(pointerEnd.clientX, pointerEnd.clientY),
|
||||
);
|
||||
|
||||
if (draggedDistance === 0) {
|
||||
@@ -9472,7 +9563,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// NOTE wheel, touchstart, touchend events must be registered outside
|
||||
// of react because react binds them them passively (so we can't prevent
|
||||
// default on them)
|
||||
this.interactiveCanvas.addEventListener(EVENT.WHEEL, this.handleWheel);
|
||||
this.interactiveCanvas.addEventListener(
|
||||
EVENT.TOUCH_START,
|
||||
this.onTouchStart,
|
||||
@@ -9480,10 +9570,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
|
||||
// -----------------------------------------------------------------------
|
||||
} else {
|
||||
this.interactiveCanvas?.removeEventListener(
|
||||
EVENT.WHEEL,
|
||||
this.handleWheel,
|
||||
);
|
||||
this.interactiveCanvas?.removeEventListener(
|
||||
EVENT.TOUCH_START,
|
||||
this.onTouchStart,
|
||||
@@ -10078,7 +10164,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(
|
||||
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();
|
||||
|
||||
if (isPanning) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({
|
||||
return (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "208px" }}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
|
||||
@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
|
||||
import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { actionClearCanvas, actionLink } from "../../actions";
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLink,
|
||||
actionToggleSearchMenu,
|
||||
} from "../../actions";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
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"),
|
||||
keywords: ["color", "outline"],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 { useUIAppState } from "../context/ui-appState";
|
||||
import { t } from "../i18n";
|
||||
import type { MarkOptional, Merge } from "../utility-types";
|
||||
import { composeEventHandlers } from "../utils";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import "../components/dropdownMenu/DropdownMenu.scss";
|
||||
import { SearchMenu } from "./SearchMenu";
|
||||
import { LibraryIcon, searchIcon } from "./icons";
|
||||
|
||||
const DefaultSidebarTrigger = withInternalFallback(
|
||||
"DefaultSidebarTrigger",
|
||||
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
|
||||
);
|
||||
DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
|
||||
|
||||
const DefaultTabTriggers = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
return (
|
||||
<DefaultSidebarTabTriggersTunnel.In>
|
||||
<Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
|
||||
{children}
|
||||
</DefaultSidebarTabTriggersTunnel.In>
|
||||
);
|
||||
};
|
||||
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
|
||||
|
||||
const { DefaultSidebarTabTriggersTunnel } = useTunnels();
|
||||
|
||||
const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
{...rest}
|
||||
name="default"
|
||||
key="default"
|
||||
className={clsx("default-sidebar", className)}
|
||||
docked={docked ?? appState.defaultSidebarDockedPreference}
|
||||
docked={
|
||||
isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
|
||||
}
|
||||
onDock={
|
||||
// `onDock=false` disables docking.
|
||||
// if `docked` passed, but no onDock passed, disable manual docking.
|
||||
onDock === false || (!onDock && docked != null)
|
||||
isForceDocked || onDock === false || (!onDock && docked != null)
|
||||
? undefined
|
||||
: // compose to allow the host app to listen on default behavior
|
||||
composeEventHandlers(onDock, (docked) => {
|
||||
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
|
||||
>
|
||||
<Sidebar.Tabs>
|
||||
<Sidebar.Header>
|
||||
{rest.__fallback && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--color-primary)",
|
||||
fontSize: "1.2em",
|
||||
fontWeight: "bold",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
paddingRight: "1em",
|
||||
}}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
</div>
|
||||
)}
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
<Sidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
|
||||
{searchIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
|
||||
{LibraryIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<DefaultSidebarTabTriggersTunnel.Out />
|
||||
</Sidebar.TabTriggers>
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
|
||||
<LibraryMenu />
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
|
||||
<SearchMenu />
|
||||
</Sidebar.Tab>
|
||||
{children}
|
||||
</Sidebar.Tabs>
|
||||
</Sidebar>
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
@keyframes successStatusAnimation {
|
||||
0% {
|
||||
transform: scale(0.35);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
--text-color: transparent;
|
||||
@@ -22,9 +36,10 @@
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 1rem;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
|
||||
animation: successStatusAnimation 0.5s cubic-bezier(0.3, 1, 0.6, 1);
|
||||
}
|
||||
|
||||
&.ExcButton--status-loading,
|
||||
|
||||
@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("search.title")}
|
||||
shortcuts={[getShortcutFromShortcutName("searchMenu")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("commandPalette.title")}
|
||||
shortcuts={
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
|
||||
import "./HintViewer.scss";
|
||||
import { isNodeInFlowchart } from "../element/flowchart";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
@@ -30,6 +31,14 @@ const getHints = ({
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,20 @@ const ImageExportModal = ({
|
||||
const previewRef = useRef<HTMLDivElement>(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(
|
||||
elementsSnapshot,
|
||||
@@ -108,6 +121,7 @@ const ImageExportModal = ({
|
||||
if (!maxWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportToCanvas({
|
||||
elements: exportedElements,
|
||||
appState: {
|
||||
|
||||
@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
|
||||
import { UIAppStateContext } from "../context/ui-appState";
|
||||
import { DefaultSidebar } from "./DefaultSidebar";
|
||||
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import Scene from "../scene/Scene";
|
||||
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
appState: UIAppState;
|
||||
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
@@ -133,6 +133,7 @@ const SingleLibraryItem = ({
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
skipInliningFonts: true,
|
||||
});
|
||||
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 { isArrowElement, isElbowArrow } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import { angleIcon } from "../icons";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import type { Degrees } from "../../../math";
|
||||
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
||||
|
||||
interface AngleProps {
|
||||
element: ExcalidrawElement;
|
||||
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
}
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
const nextAngle = degreesToRadians(nextValue as Degrees);
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
}
|
||||
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||
Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
|
||||
const changeInDegrees = Math.round(accumulatedChange);
|
||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||
if (shouldChangeByStepSize) {
|
||||
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
nextAngleInDegrees =
|
||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||
|
||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
||||
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
|
||||
@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { isInGroup } from "../../groups";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import { angleIcon } from "../icons";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
import type { Degrees } from "../../../math";
|
||||
import { degreesToRadians, radiansToDegrees } from "../../../math";
|
||||
|
||||
interface MultiAngleProps {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
|
||||
);
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
const nextAngle = degreesToRadians(nextValue as Degrees);
|
||||
|
||||
for (const element of editableLatestIndividualElements) {
|
||||
if (!element) {
|
||||
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
|
||||
}
|
||||
const originalElement = editableOriginalIndividualElements[i];
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
||||
Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
|
||||
const changeInDegrees = Math.round(accumulatedChange);
|
||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||
if (shouldChangeByStepSize) {
|
||||
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
|
||||
nextAngleInDegrees =
|
||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||
|
||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||
const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
|
||||
|
||||
mutateElement(
|
||||
latestElement,
|
||||
@@ -109,7 +110,7 @@ const MultiAngle = ({
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
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";
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ import type {
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState, Point } from "../../types";
|
||||
import type { AppState } from "../../types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
import { point, type GlobalPoint } from "../../../math";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
@@ -104,7 +105,7 @@ const resizeGroup = (
|
||||
nextHeight: number,
|
||||
initialHeight: number,
|
||||
aspectRatio: number,
|
||||
anchor: Point,
|
||||
anchor: GlobalPoint,
|
||||
property: MultiDimensionProps["property"],
|
||||
latestElements: ExcalidrawElement[],
|
||||
originalElements: ExcalidrawElement[],
|
||||
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
[x1, y1],
|
||||
point(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
[x1, y1],
|
||||
point(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
@@ -14,6 +13,7 @@ import { useMemo } from "react";
|
||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
@@ -43,11 +43,9 @@ const moveElements = (
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -98,11 +96,9 @@ const moveGroupTo = (
|
||||
latestElement.y + latestElement.height / 2,
|
||||
];
|
||||
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
latestElement.x,
|
||||
latestElement.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(latestElement.x, latestElement.y),
|
||||
point(cx, cy),
|
||||
latestElement.angle,
|
||||
);
|
||||
|
||||
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -246,7 +240,11 @@ const MultiPosition = ({
|
||||
const [el] = elementsInUnit;
|
||||
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;
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, moveElement } from "./utils";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -94,11 +92,9 @@ const Position = ({
|
||||
scene,
|
||||
appState,
|
||||
}: PositionProps) => {
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
element.x,
|
||||
element.y,
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(element.x, element.y),
|
||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||
element.angle,
|
||||
);
|
||||
const value =
|
||||
|
||||
@@ -19,12 +19,13 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import { degreeToRadian, rotate } from "../../math";
|
||||
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||
import { getCommonBounds, isTextElement } from "../../element";
|
||||
import { API } from "../../tests/helpers/api";
|
||||
import { actionGroup } from "../../actions";
|
||||
import { isInGroup } from "../../groups";
|
||||
import type { Degrees } from "../../../math";
|
||||
import { degreesToRadians, point, pointRotateRads } from "../../../math";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -46,7 +47,9 @@ const testInputProperty = (
|
||||
expect(input.value).toBe(initialValue.toString());
|
||||
UI.updateInput(input, String(nextValue));
|
||||
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)) {
|
||||
expect(element[property]).toBe(Number(nextValue));
|
||||
} else if (property !== "fontSize") {
|
||||
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
|
||||
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
|
||||
let [newTopLeftX, newTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
let [newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
|
||||
|
||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||
|
||||
[newTopLeftX, newTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
[newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
let [currentTopLeftX, currentTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
[currentTopLeftX, currentTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
|
||||
|
||||
UI.updateInput(angle, "40");
|
||||
|
||||
const angleInRadian = degreeToRadian(40);
|
||||
const angleInRadian = degreesToRadians(40 as Degrees);
|
||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
||||
expect(frame.angle).toBe(0);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
updateBoundElements,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
getElementsInGroup,
|
||||
isInGroup,
|
||||
} from "../../groups";
|
||||
import { rotate } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import { getFontString } from "../../utils";
|
||||
@@ -229,23 +230,19 @@ export const moveElement = (
|
||||
originalElement.x + originalElement.width / 2,
|
||||
originalElement.y + originalElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
originalElement.x,
|
||||
originalElement.y,
|
||||
cx,
|
||||
cy,
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(originalElement.x, originalElement.y),
|
||||
point(cx, cy),
|
||||
originalElement.angle,
|
||||
);
|
||||
|
||||
const changeInX = newTopLeftX - topLeftX;
|
||||
const changeInY = newTopLeftY - topLeftY;
|
||||
|
||||
const [x, y] = rotate(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
cx + changeInX,
|
||||
cy + changeInY,
|
||||
-originalElement.angle,
|
||||
const [x, y] = pointRotateRads(
|
||||
point(newTopLeftX, newTopLeftY),
|
||||
point(cx + changeInX, cy + changeInY),
|
||||
-originalElement.angle as Radians,
|
||||
);
|
||||
|
||||
mutateElement(
|
||||
|
||||
@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
|
||||
import { ArrowRightIcon } from "../icons";
|
||||
|
||||
import "./TTDDialog.scss";
|
||||
import { isFiniteNumber } from "../../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
|
||||
import { isFiniteNumber } from "../../../math";
|
||||
|
||||
const MIN_PROMPT_LENGTH = 3;
|
||||
const MAX_PROMPT_LENGTH = 1000;
|
||||
|
||||
@@ -3,16 +3,29 @@
|
||||
.excalidraw {
|
||||
--ExcTextField--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--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--border-hover: var(--color-brand-hover);
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
|
||||
.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 {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
@@ -37,7 +50,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
height: 3rem;
|
||||
|
||||
@@ -45,6 +57,8 @@
|
||||
border: 1px solid var(--ExcTextField--border);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
padding: 0 0.75rem;
|
||||
|
||||
&:not(&--readonly) {
|
||||
&:hover {
|
||||
border-color: var(--ExcTextField--border-hover);
|
||||
@@ -80,10 +94,6 @@
|
||||
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--ExcTextField--placeholder);
|
||||
}
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: initial;
|
||||
@@ -105,5 +115,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--hasIcon .ExcTextField__input {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ type TextFieldProps = {
|
||||
fullWidth?: boolean;
|
||||
selectOnRender?: boolean;
|
||||
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
} & ({ value: string } | { defaultValue: string });
|
||||
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
isRedacted = false,
|
||||
icon,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectOnRender) {
|
||||
// focusing first is needed because vitest/jsdom
|
||||
innerRef.current?.focus();
|
||||
innerRef.current?.select();
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
className={clsx("ExcTextField", className, {
|
||||
"ExcTextField--fullWidth": fullWidth,
|
||||
"ExcTextField--hasIcon": !!icon,
|
||||
})}
|
||||
onClick={() => {
|
||||
innerRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="ExcTextField__label">{label}</div>
|
||||
{icon}
|
||||
{label && <div className="ExcTextField__label">{label}</div>}
|
||||
<div
|
||||
className={clsx("ExcTextField__input", {
|
||||
"ExcTextField__input--readonly": readonly,
|
||||
|
||||
@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingTextElement: appState.editingTextElement,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
|
||||
import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
|
||||
import {
|
||||
sceneCoordsToViewportCoords,
|
||||
viewportCoordsToSceneCoords,
|
||||
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
|
||||
import { useAppProps, useExcalidrawAppState } from "../App";
|
||||
import { isEmbeddableElement } from "../../element/typeChecks";
|
||||
import { getLinkHandleFromCoords } from "./helpers";
|
||||
import { point, type GlobalPoint } from "../../../math";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -176,10 +177,12 @@ export const Hyperlink = ({
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
]) as boolean;
|
||||
const shouldHide = shouldHideLinkPopup(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
point(event.clientX, event.clientY),
|
||||
) as boolean;
|
||||
if (shouldHide) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setAppState({ showHyperlinkPopup: false });
|
||||
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
[clientX, clientY]: Point,
|
||||
[clientX, clientY]: GlobalPoint,
|
||||
): Boolean => {
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
{ clientX, clientY },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GlobalPoint, Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import type { Bounds } from "../../element/bounds";
|
||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||
@@ -6,9 +8,8 @@ import type {
|
||||
ElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
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");
|
||||
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 = (
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
angle: number,
|
||||
angle: Radians,
|
||||
appState: Pick<UIAppState, "zoom">,
|
||||
): Bounds => {
|
||||
const size = DEFAULT_LINK_SIZE;
|
||||
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
|
||||
const x = x2 + dashedLineMargin - centeringOffset;
|
||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||
|
||||
const [rotatedX, rotatedY] = rotate(
|
||||
x + linkWidth / 2,
|
||||
y + linkHeight / 2,
|
||||
centerX,
|
||||
centerY,
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(x + linkWidth / 2, y + linkHeight / 2),
|
||||
point(centerX, centerY),
|
||||
angle,
|
||||
);
|
||||
return [
|
||||
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
[x, y]: GlobalPoint,
|
||||
) => {
|
||||
const threshold = 4 / appState.zoom.value;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
[x, y]: GlobalPoint,
|
||||
isMobile: boolean,
|
||||
) => {
|
||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||
@@ -86,5 +85,5 @@ export const isPointHittingLink = (
|
||||
) {
|
||||
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>,
|
||||
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,
|
||||
MoonIcon,
|
||||
save,
|
||||
searchIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
usersIcon,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
actionLoadScene,
|
||||
actionSaveToActiveFile,
|
||||
actionShortcuts,
|
||||
actionToggleSearchMenu,
|
||||
actionToggleTheme,
|
||||
} from "../../actions";
|
||||
import clsx from "clsx";
|
||||
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
|
||||
import { THEME } from "../../constants";
|
||||
import type { Theme } from "../../element/types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
|
||||
import "./DefaultItems.scss";
|
||||
|
||||
export const LoadScene = () => {
|
||||
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
|
||||
};
|
||||
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 = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -112,6 +112,8 @@ export const ENV = {
|
||||
|
||||
export const CLASSES = {
|
||||
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 CANVAS_SEARCH_TAB = "search";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
|
||||
@@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * {
|
||||
.App-menu__left {
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
width: 200px;
|
||||
width: 12.5rem;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@@ -144,9 +144,9 @@
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
--color-surface-high: hsl(244, 100%, 97%);
|
||||
--color-surface-mid: hsl(240 25% 96%);
|
||||
--color-surface-low: hsl(240 25% 94%);
|
||||
--color-surface-high: #f1f0ff;
|
||||
--color-surface-mid: #f2f2f7;
|
||||
--color-surface-low: #ececf4;
|
||||
--color-surface-lowest: #ffffff;
|
||||
--color-on-surface: #1b1b1f;
|
||||
--color-brand-hover: #5753d0;
|
||||
|
||||
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "#d8f5a2",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id47",
|
||||
"type": "arrow",
|
||||
},
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id48",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id46",
|
||||
"id": "id48",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id47",
|
||||
"elementId": "id49",
|
||||
"fixedPoint": null,
|
||||
"focus": -0.08139534883720931,
|
||||
"gap": 1,
|
||||
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id45",
|
||||
"id": "id47",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id50",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id48",
|
||||
"id": "id50",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id49",
|
||||
"id": "id51",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
"containerId": "id50",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id38",
|
||||
"id": "id40",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id40",
|
||||
"elementId": "id42",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id39",
|
||||
"elementId": "id41",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id37",
|
||||
"containerId": "id39",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"id": "id39",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id37",
|
||||
"id": "id39",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id42",
|
||||
"id": "id44",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"elbowed": false,
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id44",
|
||||
"elementId": "id46",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"seed": Any<Number>,
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id43",
|
||||
"elementId": "id45",
|
||||
"fixedPoint": null,
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
"containerId": "id43",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 5,
|
||||
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id43",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id41",
|
||||
"id": "id43",
|
||||
"type": "arrow",
|
||||
},
|
||||
],
|
||||
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id54",
|
||||
"id": "id56",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id55",
|
||||
"id": "id57",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id56",
|
||||
"id": "id58",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id57",
|
||||
"id": "id59",
|
||||
"type": "text",
|
||||
},
|
||||
{
|
||||
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id58",
|
||||
"id": "id60",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
"id": "id59",
|
||||
"id": "id61",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
|
||||
: 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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FixedPointBinding,
|
||||
FontFamilyValues,
|
||||
OrderedExcalidrawElement,
|
||||
PointBinding,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
@@ -40,11 +42,7 @@ import {
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import {
|
||||
getUpdatedTimestamp,
|
||||
isFiniteNumber,
|
||||
updateActiveTool,
|
||||
} from "../utils";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { MarkOptional, Mutable } from "../utility-types";
|
||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||
@@ -58,6 +56,8 @@ import {
|
||||
getNormalizedGridStep,
|
||||
getNormalizedZoom,
|
||||
} from "../scene";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { isFiniteNumber, point } from "../../math";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -103,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
|
||||
const repairBinding = (
|
||||
element: ExcalidrawLinearElement,
|
||||
binding: PointBinding | null,
|
||||
): PointBinding | null => {
|
||||
binding: PointBinding | FixedPointBinding | null,
|
||||
): PointBinding | FixedPointBinding | null => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,9 +112,11 @@ const repairBinding = (
|
||||
return {
|
||||
...binding,
|
||||
focus: binding.focus || 0,
|
||||
fixedPoint: isElbowArrow(element)
|
||||
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||
: null,
|
||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
||||
? {
|
||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -152,7 +154,7 @@ const restoreElementWithProperties = <
|
||||
roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
|
||||
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,
|
||||
y: extra.y ?? element.y ?? 0,
|
||||
strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
@@ -266,10 +268,7 @@ const restoreElement = (
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [
|
||||
[0, 0],
|
||||
[element.width, element.height],
|
||||
]
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
@@ -293,14 +292,11 @@ const restoreElement = (
|
||||
});
|
||||
case "arrow": {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
let x = element.x;
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
let x: number | undefined = element.x;
|
||||
let y: number | undefined = element.y;
|
||||
let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [
|
||||
[0, 0],
|
||||
[element.width, element.height],
|
||||
]
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { vi } from "vitest";
|
||||
import type { ExcalidrawElementSkeleton } from "./transform";
|
||||
import { convertToExcalidrawElements } from "./transform";
|
||||
import type { ExcalidrawArrowElement } from "../element/types";
|
||||
import { point } from "../../math";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -308,28 +309,32 @@ describe("Test Transform", () => {
|
||||
});
|
||||
|
||||
describe("Test Frames", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
];
|
||||
|
||||
it("should transform frames and update frame ids when regenerated", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
@@ -351,28 +356,9 @@ describe("Test Transform", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should consider max of calculated and frame dimensions when provided", () => {
|
||||
it("should consider user defined frame dimensions over calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
@@ -387,7 +373,27 @@ describe("Test Transform", () => {
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.width).toBe(800);
|
||||
expect(frame.height).toBe(126);
|
||||
expect(frame.height).toBe(100);
|
||||
});
|
||||
|
||||
it("should consider user defined frame coordinates calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
x: 100,
|
||||
y: 300,
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.x).toBe(100);
|
||||
expect(frame.y).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -911,10 +917,7 @@ describe("Test Transform", () => {
|
||||
x: 111.262,
|
||||
y: 57,
|
||||
strokeWidth: 2,
|
||||
points: [
|
||||
[0, 0],
|
||||
[272.985, 0],
|
||||
],
|
||||
points: [point(0, 0), point(272.985, 0)],
|
||||
label: {
|
||||
text: "How are you?",
|
||||
fontSize: 20,
|
||||
@@ -937,7 +940,7 @@ describe("Test Transform", () => {
|
||||
x: 77.017,
|
||||
y: 79,
|
||||
strokeWidth: 2,
|
||||
points: [[0, 0]],
|
||||
points: [point(0, 0)],
|
||||
label: {
|
||||
text: "Friendship",
|
||||
fontSize: 20,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
@@ -53,6 +54,7 @@ import { randomId } from "../random";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { isArrowElement } from "../element/typeChecks";
|
||||
import { point, type LocalPoint } from "../../math";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
@@ -417,7 +419,7 @@ const bindLinearElementToElement = (
|
||||
const endPointIndex = linearElement.points.length - 1;
|
||||
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
|
||||
if (
|
||||
@@ -535,10 +537,7 @@ export const convertToExcalidrawElements = (
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
points: [point(0, 0), point(width, height)],
|
||||
...element,
|
||||
});
|
||||
|
||||
@@ -551,10 +550,7 @@ export const convertToExcalidrawElements = (
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[width, height],
|
||||
],
|
||||
points: [point(0, 0), point(width, height)],
|
||||
...element,
|
||||
type: "arrow",
|
||||
});
|
||||
@@ -722,7 +718,7 @@ export const convertToExcalidrawElements = (
|
||||
}
|
||||
|
||||
// Once all the excalidraw elements are created, we can add frames since we
|
||||
// need to calculate coordinates and dimensions of frame which is possibe after all
|
||||
// need to calculate coordinates and dimensions of frame which is possible after all
|
||||
// frame children are processed.
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
if (element.type !== "frame" && element.type !== "magicframe") {
|
||||
@@ -769,10 +765,26 @@ export const convertToExcalidrawElements = (
|
||||
maxX = maxX + PADDING;
|
||||
maxY = maxY + PADDING;
|
||||
|
||||
// Take the max of calculated and provided frame dimensions, whichever is higher
|
||||
const width = Math.max(frame?.width, maxX - minX);
|
||||
const height = Math.max(frame?.height, maxY - minY);
|
||||
Object.assign(frame, { x: minX, y: minY, width, height });
|
||||
const frameX = frame?.x || minX;
|
||||
const frameY = frame?.y || minY;
|
||||
const frameWidth = frame?.width || maxX - minX;
|
||||
const frameHeight = frame?.height || maxY - minY;
|
||||
|
||||
Object.assign(frame, {
|
||||
x: frameX,
|
||||
y: frameY,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
if (
|
||||
isDevEnv() &&
|
||||
element.children.length &&
|
||||
(frame?.x || frame?.y || frame?.width || frame?.height)
|
||||
) {
|
||||
console.info(
|
||||
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elementStore.getElements();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as GA from "../ga";
|
||||
import * as GAPoint from "../gapoints";
|
||||
import * as GADirection from "../gadirections";
|
||||
import * as GALine from "../galines";
|
||||
import * as GATransform from "../gatransforms";
|
||||
import * as GA from "../../math/ga/ga";
|
||||
import * as GAPoint from "../../math/ga/gapoints";
|
||||
import * as GADirection from "../../math/ga/gadirections";
|
||||
import * as GALine from "../../math/ga/galines";
|
||||
import * as GATransform from "../../math/ga/gatransforms";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
@@ -26,11 +25,12 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPoint,
|
||||
SceneElementsMap,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import type { AppState, Point } from "../types";
|
||||
import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
|
||||
import type { AppState } from "../types";
|
||||
import { isPointOnShape } from "../../utils/collision";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import {
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isRectangularElement,
|
||||
@@ -51,17 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getElementShape } from "../shapes";
|
||||
import {
|
||||
aabbForElement,
|
||||
clamp,
|
||||
distanceSq2d,
|
||||
getCenterForBounds,
|
||||
getCenterForElement,
|
||||
pointInsideBounds,
|
||||
pointToVector,
|
||||
rotatePoint,
|
||||
} from "../math";
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||
import {
|
||||
compareHeading,
|
||||
HEADING_DOWN,
|
||||
@@ -72,7 +63,18 @@ import {
|
||||
vectorToHeading,
|
||||
type 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 =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
@@ -649,7 +651,7 @@ export const updateBoundElements = (
|
||||
update,
|
||||
): update is NonNullable<{
|
||||
index: number;
|
||||
point: Point;
|
||||
point: LocalPoint;
|
||||
isDragging?: boolean;
|
||||
}> => update !== null,
|
||||
);
|
||||
@@ -695,14 +697,14 @@ const getSimultaneouslyUpdatedElementIds = (
|
||||
};
|
||||
|
||||
export const getHeadingForElbowArrowSnap = (
|
||||
point: Readonly<Point>,
|
||||
otherPoint: Readonly<Point>,
|
||||
p: Readonly<GlobalPoint>,
|
||||
otherPoint: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||
aabb: Bounds | undefined | null,
|
||||
elementsMap: ElementsMap,
|
||||
origPoint: Point,
|
||||
origPoint: GlobalPoint,
|
||||
): Heading => {
|
||||
const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
|
||||
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||
|
||||
if (!bindableElement || !aabb) {
|
||||
return otherPointHeading;
|
||||
@@ -716,17 +718,23 @@ export const getHeadingForElbowArrowSnap = (
|
||||
|
||||
if (!distance) {
|
||||
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;
|
||||
};
|
||||
|
||||
const getDistanceForBinding = (
|
||||
point: Readonly<Point>,
|
||||
point: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
@@ -745,89 +753,87 @@ const getDistanceForBinding = (
|
||||
};
|
||||
|
||||
export const bindPointToSnapToElementOutline = (
|
||||
point: Readonly<Point>,
|
||||
otherPoint: Readonly<Point>,
|
||||
p: Readonly<GlobalPoint>,
|
||||
otherPoint: Readonly<GlobalPoint>,
|
||||
bindableElement: ExcalidrawBindableElement | undefined,
|
||||
elementsMap: ElementsMap,
|
||||
): Point => {
|
||||
): GlobalPoint => {
|
||||
const aabb = bindableElement && aabbForElement(bindableElement);
|
||||
|
||||
if (bindableElement && aabb) {
|
||||
// TODO: Dirty hacks until tangents are properly calculated
|
||||
const heading = headingForPointFromElement(bindableElement, aabb, point);
|
||||
const heading = headingForPointFromElement(bindableElement, aabb, p);
|
||||
const intersections = [
|
||||
...intersectElementWithLine(
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
[point[0], point[1] - 2 * bindableElement.height],
|
||||
[point[0], point[1] + 2 * bindableElement.height],
|
||||
point(p[0], p[1] - 2 * bindableElement.height),
|
||||
point(p[0], p[1] + 2 * bindableElement.height),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
),
|
||||
...intersectElementWithLine(
|
||||
) ?? []),
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
[point[0] - 2 * bindableElement.width, point[1]],
|
||||
[point[0] + 2 * bindableElement.width, point[1]],
|
||||
point(p[0] - 2 * bindableElement.width, p[1]),
|
||||
point(p[0] + 2 * bindableElement.width, p[1]),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
),
|
||||
) ?? []),
|
||||
];
|
||||
|
||||
const isVertical =
|
||||
compareHeading(heading, HEADING_LEFT) ||
|
||||
compareHeading(heading, HEADING_RIGHT);
|
||||
const dist = Math.abs(
|
||||
distanceToBindableElement(bindableElement, point, elementsMap),
|
||||
distanceToBindableElement(bindableElement, p, elementsMap),
|
||||
);
|
||||
const isInner = isVertical
|
||||
? dist < bindableElement.width * -0.1
|
||||
: dist < bindableElement.height * -0.1;
|
||||
|
||||
intersections.sort(
|
||||
(a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
|
||||
);
|
||||
intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
|
||||
|
||||
return isInner
|
||||
? headingToMidBindPoint(otherPoint, bindableElement, aabb)
|
||||
: intersections.filter((i) =>
|
||||
isVertical
|
||||
? Math.abs(point[1] - i[1]) < 0.1
|
||||
: Math.abs(point[0] - i[0]) < 0.1,
|
||||
)[0] ?? point;
|
||||
? Math.abs(p[1] - i[1]) < 0.1
|
||||
: Math.abs(p[0] - i[0]) < 0.1,
|
||||
)[0] ?? p;
|
||||
}
|
||||
|
||||
return point;
|
||||
return p;
|
||||
};
|
||||
|
||||
const headingToMidBindPoint = (
|
||||
point: Point,
|
||||
p: GlobalPoint,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
aabb: Bounds,
|
||||
): Point => {
|
||||
): GlobalPoint => {
|
||||
const center = getCenterForBounds(aabb);
|
||||
const heading = vectorToHeading(pointToVector(point, center));
|
||||
const heading = vectorToHeading(vectorFromPoint(p, center));
|
||||
|
||||
switch (true) {
|
||||
case compareHeading(heading, HEADING_UP):
|
||||
return rotatePoint(
|
||||
[(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_RIGHT):
|
||||
return rotatePoint(
|
||||
[aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
|
||||
return pointRotateRads(
|
||||
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_DOWN):
|
||||
return rotatePoint(
|
||||
[(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
default:
|
||||
return rotatePoint(
|
||||
[aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
|
||||
return pointRotateRads(
|
||||
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
@@ -836,22 +842,25 @@ const headingToMidBindPoint = (
|
||||
|
||||
export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: Point,
|
||||
): Point => {
|
||||
const center = getCenterForElement(element);
|
||||
const nonRotatedPoint = rotatePoint(p, center, -element.angle);
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = point<GlobalPoint>(
|
||||
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) {
|
||||
// Top left
|
||||
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||
return rotatePoint(
|
||||
[element.x - FIXED_BINDING_DISTANCE, element.y],
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return rotatePoint(
|
||||
[element.x, element.y - FIXED_BINDING_DISTANCE],
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -861,14 +870,14 @@ export const avoidRectangularCorner = (
|
||||
) {
|
||||
// Bottom left
|
||||
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||
return rotatePoint(
|
||||
[element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return rotatePoint(
|
||||
[element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
|
||||
return pointRotateRads(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -881,20 +890,20 @@ export const avoidRectangularCorner = (
|
||||
nonRotatedPoint[0] - element.x <
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return rotatePoint(
|
||||
[
|
||||
return pointRotateRads(
|
||||
point(
|
||||
element.x + element.width,
|
||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||
],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return rotatePoint(
|
||||
[
|
||||
return pointRotateRads(
|
||||
point(
|
||||
element.x + element.width + FIXED_BINDING_DISTANCE,
|
||||
element.y + element.height,
|
||||
],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -907,14 +916,14 @@ export const avoidRectangularCorner = (
|
||||
nonRotatedPoint[0] - element.x <
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return rotatePoint(
|
||||
[element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return rotatePoint(
|
||||
[element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -925,12 +934,12 @@ export const avoidRectangularCorner = (
|
||||
|
||||
export const snapToMid = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: Point,
|
||||
p: GlobalPoint,
|
||||
tolerance: number = 0.05,
|
||||
): Point => {
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
|
||||
const nonRotated = rotatePoint(p, center, -angle);
|
||||
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||
|
||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||
// above and below certain px distance
|
||||
@@ -943,22 +952,30 @@ export const snapToMid = (
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
) {
|
||||
// LEFT
|
||||
return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
|
||||
return pointRotateRads(
|
||||
point(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[1] <= y + height / 2 &&
|
||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
) {
|
||||
// TOP
|
||||
return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
|
||||
return pointRotateRads(
|
||||
point(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
} else if (
|
||||
nonRotated[0] >= x + width / 2 &&
|
||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
||||
nonRotated[1] < center[1] + verticalThrehsold
|
||||
) {
|
||||
// RIGHT
|
||||
return rotatePoint(
|
||||
[x + width + FIXED_BINDING_DISTANCE, center[1]],
|
||||
return pointRotateRads(
|
||||
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -968,8 +985,8 @@ export const snapToMid = (
|
||||
nonRotated[0] < center[0] + horizontalThrehsold
|
||||
) {
|
||||
// DOWN
|
||||
return rotatePoint(
|
||||
[center[0], y + height + FIXED_BINDING_DISTANCE],
|
||||
return pointRotateRads(
|
||||
point(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -984,7 +1001,7 @@ const updateBoundPoint = (
|
||||
binding: PointBinding | null | undefined,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): Point | null => {
|
||||
): LocalPoint | null => {
|
||||
if (
|
||||
binding == null ||
|
||||
// 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 edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||
|
||||
if (isElbowArrow(linearElement)) {
|
||||
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
|
||||
const fixedPoint =
|
||||
normalizeFixedPoint(binding.fixedPoint) ??
|
||||
calculateFixedPointForElbowArrowBinding(
|
||||
@@ -1006,15 +1023,15 @@ const updateBoundPoint = (
|
||||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = [
|
||||
const globalMidPoint = point<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
] as Point;
|
||||
const global = [
|
||||
);
|
||||
const global = point<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
] as Point;
|
||||
const rotatedGlobal = rotatePoint(
|
||||
);
|
||||
const rotatedGlobal = pointRotateRads(
|
||||
global,
|
||||
globalMidPoint,
|
||||
bindableElement.angle,
|
||||
@@ -1040,7 +1057,7 @@ const updateBoundPoint = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
let newEdgePoint: Point;
|
||||
let newEdgePoint: GlobalPoint;
|
||||
|
||||
// The linear element was not originally pointing inside the bound shape,
|
||||
// we can point directly at the focus point
|
||||
@@ -1054,7 +1071,7 @@ const updateBoundPoint = (
|
||||
binding.gap,
|
||||
elementsMap,
|
||||
);
|
||||
if (intersections.length === 0) {
|
||||
if (!intersections || intersections.length === 0) {
|
||||
// This should never happen, since focusPoint should always be
|
||||
// inside the element, but just in case, bail out
|
||||
newEdgePoint = focusPointAbsolute;
|
||||
@@ -1101,15 +1118,15 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = [
|
||||
const globalMidPoint = point(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
] as Point;
|
||||
const nonRotatedSnappedGlobalPoint = rotatePoint(
|
||||
);
|
||||
const nonRotatedSnappedGlobalPoint = pointRotateRads(
|
||||
snappedPoint,
|
||||
globalMidPoint,
|
||||
-hoveredElement.angle,
|
||||
) as Point;
|
||||
-hoveredElement.angle as Radians,
|
||||
);
|
||||
|
||||
return {
|
||||
fixedPoint: normalizeFixedPoint([
|
||||
@@ -1320,8 +1337,9 @@ export const bindingBorderTest = (
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape([x, y], shape, threshold) ||
|
||||
(fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
|
||||
isPointOnShape(point(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(point(x, y), aabbForElement(element)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1339,7 +1357,7 @@ export const maxBindingGap = (
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
point: Point,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
@@ -1359,19 +1377,13 @@ export const distanceToBindableElement = (
|
||||
};
|
||||
|
||||
const distanceToRectangle = (
|
||||
element:
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement,
|
||||
point: Point,
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
element,
|
||||
point,
|
||||
p,
|
||||
elementsMap,
|
||||
);
|
||||
return Math.max(
|
||||
@@ -1382,7 +1394,7 @@ const distanceToRectangle = (
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
@@ -1396,7 +1408,7 @@ const distanceToDiamond = (
|
||||
|
||||
const distanceToEllipse = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: Point,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
|
||||
@@ -1405,7 +1417,7 @@ const distanceToEllipse = (
|
||||
|
||||
const ellipseParamsForTest = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
point: Point,
|
||||
point: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): [GA.Point, GA.Line] => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(
|
||||
@@ -1467,7 +1479,7 @@ const ellipseParamsForTest = (
|
||||
// so we only need to perform hit tests for the positive quadrant.
|
||||
const pointRelativeToElement = (
|
||||
element: ExcalidrawElement,
|
||||
pointTuple: Point,
|
||||
pointTuple: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
@@ -1516,9 +1528,9 @@ const coordsCenter = (
|
||||
const determineFocusDistance = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// Point on the line, in absolute coordinates
|
||||
a: Point,
|
||||
a: GlobalPoint,
|
||||
// Another point on the line, in absolute coordinates (closer to element)
|
||||
b: Point,
|
||||
b: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): number => {
|
||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||
@@ -1559,13 +1571,13 @@ const determineFocusPoint = (
|
||||
// The oriented, relative distance from the center of `element` of the
|
||||
// returned focusPoint
|
||||
focus: number,
|
||||
adjecentPoint: Point,
|
||||
adjecentPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): Point => {
|
||||
): GlobalPoint => {
|
||||
if (focus === 0) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
return GAPoint.toTuple(center);
|
||||
return pointFromPair(GAPoint.toTuple(center));
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||
const adjecentPointRel = GATransform.apply(
|
||||
@@ -1589,7 +1601,9 @@ const determineFocusPoint = (
|
||||
point = findFocusPointForEllipse(element, focus, adjecentPointRel);
|
||||
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`
|
||||
@@ -1597,15 +1611,15 @@ const determineFocusPoint = (
|
||||
const intersectElementWithLine = (
|
||||
element: ExcalidrawBindableElement,
|
||||
// Point on the line, in absolute coordinates
|
||||
a: Point,
|
||||
a: GlobalPoint,
|
||||
// Another point on the line, in absolute coordinates
|
||||
b: Point,
|
||||
b: GlobalPoint,
|
||||
// If given, the element is inflated by this value
|
||||
gap: number = 0,
|
||||
elementsMap: ElementsMap,
|
||||
): Point[] => {
|
||||
): GlobalPoint[] | undefined => {
|
||||
if (isRectangularElement(element)) {
|
||||
return segmentIntersectRectangleElement(element, [a, b], gap);
|
||||
return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
|
||||
}
|
||||
|
||||
const relateToCenter = relativizationToElementCenter(element, elementsMap);
|
||||
@@ -1619,8 +1633,14 @@ const intersectElementWithLine = (
|
||||
aRel,
|
||||
gap,
|
||||
);
|
||||
return intersections.map((point) =>
|
||||
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||
return intersections.map(
|
||||
(point) =>
|
||||
pointFromPair(
|
||||
GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
|
||||
),
|
||||
// pointFromArray(
|
||||
// ,
|
||||
// ),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2173,12 +2193,18 @@ export class BindableElement {
|
||||
export const getGlobalFixedPointForBindableElement = (
|
||||
fixedPointRatio: [number, number],
|
||||
element: ExcalidrawBindableElement,
|
||||
) => {
|
||||
): GlobalPoint => {
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
return rotatePoint(
|
||||
[element.x + element.width * fixedX, element.y + element.height * fixedY],
|
||||
getCenterForElement(element),
|
||||
return pointRotateRads(
|
||||
point(
|
||||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
point<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
element.angle,
|
||||
);
|
||||
};
|
||||
@@ -2186,7 +2212,7 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
const getGlobalFixedPoints = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
): [GlobalPoint, GlobalPoint] => {
|
||||
const startElement =
|
||||
arrow.startBinding &&
|
||||
(elementsMap.get(arrow.startBinding.elementId) as
|
||||
@@ -2197,23 +2223,26 @@ const getGlobalFixedPoints = (
|
||||
(elementsMap.get(arrow.endBinding.elementId) as
|
||||
| ExcalidrawBindableElement
|
||||
| undefined);
|
||||
const startPoint: Point =
|
||||
const startPoint =
|
||||
startElement && arrow.startBinding
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
startElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
|
||||
const endPoint: Point =
|
||||
: point<GlobalPoint>(
|
||||
arrow.x + arrow.points[0][0],
|
||||
arrow.y + arrow.points[0][1],
|
||||
);
|
||||
const endPoint =
|
||||
endElement && arrow.endBinding
|
||||
? getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
endElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: [
|
||||
: point<GlobalPoint>(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
arrow.y + arrow.points[arrow.points.length - 1][1],
|
||||
];
|
||||
);
|
||||
|
||||
return [startPoint, endPoint];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
[0, 0] as [number, number],
|
||||
[67.33984375, 92.48828125] as [number, number],
|
||||
[-102.7890625, 52.15625] as [number, number],
|
||||
point<LocalPoint>(0, 0),
|
||||
point<LocalPoint>(67.33984375, 92.48828125),
|
||||
point<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
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 { AppState, Point } from "../types";
|
||||
import type { AppState } from "../types";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
import {
|
||||
isArrowElement,
|
||||
@@ -22,9 +22,24 @@ import {
|
||||
import { rescalePoints } from "../points";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import type { Mutable } from "../utility-types";
|
||||
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 = {
|
||||
x: number;
|
||||
@@ -97,7 +112,11 @@ export class ElementBounds {
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
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)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(cx, y1),
|
||||
point(cx, cy),
|
||||
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 minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
@@ -128,10 +163,26 @@ export class ElementBounds {
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(x1, y1),
|
||||
point(cx, cy),
|
||||
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 minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
|
||||
? getContainerElement(element, elementsMap)
|
||||
: null;
|
||||
if (isArrowElement(container)) {
|
||||
const coords = LinearElementEditor.getBoundTextElementPosition(
|
||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
element as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
return [
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.x + element.width,
|
||||
coords.y + element.height,
|
||||
coords.x + element.width / 2,
|
||||
coords.y + element.height / 2,
|
||||
x,
|
||||
y,
|
||||
x + element.width,
|
||||
y + element.height,
|
||||
x + element.width / 2,
|
||||
y + element.height / 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [Point, Point][] => {
|
||||
): LineSegment<GlobalPoint>[] => {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center: Point = [cx, cy];
|
||||
const center: GlobalPoint = point(cx, cy);
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: [Point, Point][] = [];
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push([
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointRotateRads(
|
||||
point(
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
),
|
||||
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++;
|
||||
}
|
||||
|
||||
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
|
||||
[cx, y2],
|
||||
[x1, cy],
|
||||
[x2, cy],
|
||||
] as Point[]
|
||||
).map((point) => rotatePoint(point, center, element.angle));
|
||||
] as GlobalPoint[]
|
||||
).map((point) => pointRotateRads(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
lineSegment(n, w),
|
||||
lineSegment(n, e),
|
||||
lineSegment(s, w),
|
||||
lineSegment(s, e),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
[nw, ne],
|
||||
[sw, se],
|
||||
[nw, sw],
|
||||
[ne, se],
|
||||
[nw, e],
|
||||
[sw, e],
|
||||
[ne, w],
|
||||
[se, w],
|
||||
lineSegment(nw, ne),
|
||||
lineSegment(sw, se),
|
||||
lineSegment(nw, sw),
|
||||
lineSegment(ne, se),
|
||||
lineSegment(nw, e),
|
||||
lineSegment(sw, e),
|
||||
lineSegment(ne, w),
|
||||
lineSegment(se, w),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -386,10 +439,10 @@ const solveQuadratic = (
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
p3: GlobalPoint,
|
||||
): Bounds => {
|
||||
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
|
||||
|
||||
export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
transformXY?: (p: GlobalPoint) => GlobalPoint,
|
||||
): Bounds => {
|
||||
let currentP: Point = [0, 0];
|
||||
let currentP: GlobalPoint = point(0, 0);
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
// move, bcurveTo, lineTo, and curveTo
|
||||
if (op === "move") {
|
||||
// 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
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
const _p1 = [data[0], data[1]] as Point;
|
||||
const _p2 = [data[2], data[3]] as Point;
|
||||
const _p3 = [data[4], data[5]] as Point;
|
||||
const _p1 = point<GlobalPoint>(data[0], data[1]);
|
||||
const _p2 = point<GlobalPoint>(data[2], data[3]);
|
||||
const _p3 = point<GlobalPoint>(data[4], data[5]);
|
||||
|
||||
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||
const p1 = transformXY ? transformXY(_p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(_p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(_p3) : _p3;
|
||||
|
||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||
const p0 = transformXY ? transformXY(currentP) : currentP;
|
||||
currentP = _p3;
|
||||
|
||||
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
};
|
||||
|
||||
/** @returns number in degrees */
|
||||
export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
|
||||
export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
|
||||
switch (arrowhead) {
|
||||
case "bar":
|
||||
return 90;
|
||||
return 90 as Degrees;
|
||||
case "arrow":
|
||||
return 20;
|
||||
return 20 as Degrees;
|
||||
default:
|
||||
return 25;
|
||||
return 25 as Degrees;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
|
||||
const index = position === "start" ? 1 : ops.length - 1;
|
||||
|
||||
const data = ops[index].data;
|
||||
const p3 = [data[4], data[5]] as Point;
|
||||
const p2 = [data[2], data[3]] as Point;
|
||||
const p1 = [data[0], data[1]] as Point;
|
||||
|
||||
invariant(data.length === 6, "Op data length is not 6");
|
||||
|
||||
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.
|
||||
// It is typically the last point of the previous
|
||||
// curve; it can also be the position of moveTo operation.
|
||||
const prevOp = ops[index - 1];
|
||||
let p0: Point = [0, 0];
|
||||
let p0 = point(0, 0);
|
||||
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") {
|
||||
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
|
||||
@@ -610,8 +670,16 @@ export const getArrowheadPoints = (
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
// Return points
|
||||
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
|
||||
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
|
||||
const [x3, y3] = pointRotateRads(
|
||||
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") {
|
||||
// point opposite to the arrowhead point
|
||||
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
|
||||
if (position === "start") {
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = rotate(
|
||||
x2 + minSize * 2,
|
||||
y2,
|
||||
x2,
|
||||
y2,
|
||||
Math.atan2(py - y2, px - x2),
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 + minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
|
||||
? element.points[element.points.length - 2]
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = rotate(
|
||||
x2 - minSize * 2,
|
||||
y2,
|
||||
x2,
|
||||
y2,
|
||||
Math.atan2(y2 - py, x2 - px),
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 - minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
|
||||
return "linearPath";
|
||||
})();
|
||||
|
||||
return generator[method](element.points as Mutable<Point>[], options);
|
||||
return generator[method](
|
||||
element.points as Mutable<LocalPoint>[] as RoughPoint[],
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const getLinearElementRotatedBounds = (
|
||||
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
|
||||
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = rotate(
|
||||
element.x + pointX,
|
||||
element.y + pointY,
|
||||
cx,
|
||||
cy,
|
||||
const [x, y] = pointRotateRads(
|
||||
point(element.x + pointX, element.y + pointY),
|
||||
point(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = (x: number, y: number) =>
|
||||
rotate(element.x + x, element.y + y, cx, cy, element.angle);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
pointRotateRads<GlobalPoint>(
|
||||
point(element.x + x, element.y + y),
|
||||
point(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
||||
if (boundTextElement) {
|
||||
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
|
||||
const elementsMap = arrayToMap(elements);
|
||||
elements.forEach((element) => {
|
||||
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) {
|
||||
minDistance = distance;
|
||||
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
|
||||
-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 {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectangleElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementBounds } from "./bounds";
|
||||
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 { isPointInShape, isPointOnShape } from "../../utils/collision";
|
||||
import { isTransparent } from "../utils";
|
||||
@@ -18,7 +15,9 @@ import {
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} 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) => {
|
||||
if (element.type === "arrow") {
|
||||
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs = {
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = ({
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs) => {
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
|
||||
: isPointOnShape([x, y], shape, threshold);
|
||||
isPointInShape(point(x, y), shape) ||
|
||||
isPointOnShape(point(x, y), shape, threshold)
|
||||
: isPointOnShape(point(x, y), shape, threshold);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape([x, y], {
|
||||
hit = isPointInShape(point(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon,
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += 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 = (
|
||||
hitArgs: HitTestArgs,
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
@@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = (
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape | null,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape([x, y], textShape);
|
||||
return !!textShape && isPointInShape(point(x, y), textShape);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
PointerDownState,
|
||||
} from "../types";
|
||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||
import { getGridPoint } from "../math";
|
||||
import type Scene from "../scene/Scene";
|
||||
import {
|
||||
isArrowElement,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { getFontString } from "../utils";
|
||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||
import { getGridPoint } from "../snapping";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
@@ -35,7 +35,6 @@ export const dragSelectedElements = (
|
||||
) => {
|
||||
if (
|
||||
_selectedElements.length === 1 &&
|
||||
isArrowElement(_selectedElements[0]) &&
|
||||
isElbowArrow(_selectedElements[0]) &&
|
||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||
) {
|
||||
@@ -43,13 +42,7 @@ export const dragSelectedElements = (
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter(
|
||||
(el) =>
|
||||
!(
|
||||
isArrowElement(el) &&
|
||||
isElbowArrow(el) &&
|
||||
el.startBinding &&
|
||||
el.endBinding
|
||||
),
|
||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
||||
);
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
|
||||
@@ -45,6 +45,12 @@ const RE_GENERIC_EMBED =
|
||||
const RE_GIPHY =
|
||||
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
|
||||
|
||||
const RE_REDDIT =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
|
||||
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -59,6 +65,7 @@ const ALLOWED_DOMAINS = new Set([
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
@@ -71,6 +78,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
@@ -218,6 +226,24 @@ export const getEmbedLink = (
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = sanitizeHTMLAttribute(
|
||||
@@ -361,6 +387,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const redditMatch = str.match(RE_REDDIT_EMBED);
|
||||
if (redditMatch && redditMatch.length === 2) {
|
||||
return redditMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "../math";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
@@ -20,7 +19,7 @@ import type {
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { KEYS } from "../keys";
|
||||
import type { AppState, PendingExcalidrawElements, Point } from "../types";
|
||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||
import {
|
||||
@@ -30,6 +29,8 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
import { point, type LocalPoint } from "../../math";
|
||||
import { aabbForElement } from "../shapes";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
@@ -81,13 +82,14 @@ const getNodeRelatives = (
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint: Point =
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
|
||||
const edgePoint = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
]);
|
||||
] as Readonly<LocalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
@@ -419,10 +421,7 @@ const createBindingArrow = (
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
points: [
|
||||
[0, 0],
|
||||
[endX, endY],
|
||||
],
|
||||
points: [point(0, 0), point(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { lineAngle } from "../../utils/geometry/geometry";
|
||||
import type { Point, Vector } from "../../utils/geometry/shape";
|
||||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import {
|
||||
getCenterForBounds,
|
||||
PointInTriangle,
|
||||
rotatePoint,
|
||||
scalePointFromOrigin,
|
||||
} from "../math";
|
||||
import type { Bounds } from "./bounds";
|
||||
point,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
} from "../../math";
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
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 type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
||||
|
||||
export const headingForDiamond = (a: Point, b: Point) => {
|
||||
const angle = lineAngle([a, b]);
|
||||
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
) => {
|
||||
const angle = radiansToDegrees(
|
||||
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
|
||||
);
|
||||
if (angle >= 315 || angle < 45) {
|
||||
return HEADING_UP;
|
||||
} 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
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
export const headingForPointFromElement = (
|
||||
export const headingForPointFromElement = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
point: Readonly<Point>,
|
||||
p: Readonly<LocalPoint | GlobalPoint>,
|
||||
): Heading => {
|
||||
const SEARCH_CONE_MULTIPLIER = 2;
|
||||
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
if (point[0] < element.x) {
|
||||
if (p[0] < element.x) {
|
||||
return HEADING_LEFT;
|
||||
} else if (point[1] < element.y) {
|
||||
} else if (p[1] < element.y) {
|
||||
return HEADING_UP;
|
||||
} else if (point[0] > element.x + element.width) {
|
||||
} else if (p[0] > element.x + element.width) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (point[1] > element.y + element.height) {
|
||||
} else if (p[1] > element.y + element.height) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
|
||||
const top = rotatePoint(
|
||||
scalePointFromOrigin(
|
||||
[element.x + element.width / 2, element.y],
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const right = rotatePoint(
|
||||
scalePointFromOrigin(
|
||||
[element.x + element.width, element.y + element.height / 2],
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const bottom = rotatePoint(
|
||||
scalePointFromOrigin(
|
||||
[element.x + element.width / 2, element.y + element.height],
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const left = rotatePoint(
|
||||
scalePointFromOrigin(
|
||||
[element.x, element.y + element.height / 2],
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
|
||||
element.angle,
|
||||
);
|
||||
|
||||
if (PointInTriangle(point, top, right, midPoint)) {
|
||||
if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
|
||||
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);
|
||||
} else if (PointInTriangle(point, bottom, left, midPoint)) {
|
||||
} else if (
|
||||
triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
|
||||
return headingForDiamond(left, top);
|
||||
}
|
||||
|
||||
const topLeft = scalePointFromOrigin(
|
||||
[aabb[0], aabb[1]],
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
);
|
||||
const topRight = scalePointFromOrigin(
|
||||
[aabb[2], aabb[1]],
|
||||
) as Point;
|
||||
const topRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
);
|
||||
const bottomLeft = scalePointFromOrigin(
|
||||
[aabb[0], aabb[3]],
|
||||
) as Point;
|
||||
const bottomLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
);
|
||||
const bottomRight = scalePointFromOrigin(
|
||||
[aabb[2], aabb[3]],
|
||||
) as Point;
|
||||
const bottomRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
);
|
||||
) as Point;
|
||||
|
||||
return PointInTriangle(point, topLeft, topRight, midPoint)
|
||||
return triangleIncludesPoint(
|
||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_UP
|
||||
: PointInTriangle(point, topRight, bottomRight, midPoint)
|
||||
: triangleIncludesPoint(
|
||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_RIGHT
|
||||
: PointInTriangle(point, bottomRight, bottomLeft, midPoint)
|
||||
: triangleIncludesPoint(
|
||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_DOWN
|
||||
: 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,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
import {
|
||||
distance2d,
|
||||
rotate,
|
||||
isPathALoop,
|
||||
getGridPoint,
|
||||
rotatePoint,
|
||||
centerPoint,
|
||||
getControlPointsForBezierCurve,
|
||||
getBezierXY,
|
||||
getBezierCurveLength,
|
||||
mapIntervalToBezierT,
|
||||
arePointsEqual,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import type { Bounds } from "./bounds";
|
||||
import {
|
||||
@@ -32,7 +19,6 @@ import {
|
||||
getMinMaxXYFromCurvePathOps,
|
||||
} from "./bounds";
|
||||
import type {
|
||||
Point,
|
||||
AppState,
|
||||
PointerCoords,
|
||||
InteractiveCanvasAppState,
|
||||
@@ -46,7 +32,7 @@ import {
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
} from "./binding";
|
||||
import { toBrandedType, tupleToCoors } from "../utils";
|
||||
import { invariant, toBrandedType, tupleToCoors } from "../utils";
|
||||
import {
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
|
||||
import type { Store } from "../store";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
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: {
|
||||
version: number | null;
|
||||
points: (Point | null)[];
|
||||
points: (GlobalPoint | null)[];
|
||||
zoom: number | null;
|
||||
} = { version: null, points: [], zoom: null };
|
||||
export class LinearElementEditor {
|
||||
@@ -80,7 +85,7 @@ export class LinearElementEditor {
|
||||
lastClickedIsEndPoint: boolean;
|
||||
origin: Readonly<{ x: number; y: number }> | null;
|
||||
segmentMidpoint: {
|
||||
value: Point | null;
|
||||
value: GlobalPoint | null;
|
||||
index: number | null;
|
||||
added: boolean;
|
||||
};
|
||||
@@ -88,7 +93,7 @@ export class LinearElementEditor {
|
||||
|
||||
/** whether you're dragging a point */
|
||||
public readonly isDragging: boolean;
|
||||
public readonly lastUncommittedPoint: Point | null;
|
||||
public readonly lastUncommittedPoint: LocalPoint | null;
|
||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public readonly startBindingElement:
|
||||
| ExcalidrawBindableElement
|
||||
@@ -96,13 +101,14 @@ export class LinearElementEditor {
|
||||
| "keep";
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: Point | null;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
this.elementId = element.id as string & {
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -126,6 +132,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -280,7 +287,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
referencePoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
point(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
@@ -289,7 +296,10 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||
point: point(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
isDragging: selectedIndex === lastClickedPoint,
|
||||
},
|
||||
],
|
||||
@@ -310,7 +320,7 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition =
|
||||
const newPointPosition: LocalPoint =
|
||||
pointIndex === lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -319,10 +329,10 @@ export class LinearElementEditor {
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: ([
|
||||
: point(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
] as const);
|
||||
);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
@@ -515,7 +525,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
let index = 0;
|
||||
const midpoints: (Point | null)[] = [];
|
||||
const midpoints: (GlobalPoint | null)[] = [];
|
||||
while (index < points.length - 1) {
|
||||
if (
|
||||
LinearElementEditor.isSegmentTooShort(
|
||||
@@ -549,7 +559,7 @@ export class LinearElementEditor {
|
||||
scenePointer: { x: number; y: number },
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
): GlobalPoint | null => {
|
||||
const { elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
@@ -579,11 +589,12 @@ export class LinearElementEditor {
|
||||
const existingSegmentMidpointHitCoords =
|
||||
linearElementEditor.segmentMidPointHoveredCoords;
|
||||
if (existingSegmentMidpointHitCoords) {
|
||||
const distance = distance2d(
|
||||
existingSegmentMidpointHitCoords[0],
|
||||
existingSegmentMidpointHitCoords[1],
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
const distance = pointDistance(
|
||||
point(
|
||||
existingSegmentMidpointHitCoords[0],
|
||||
existingSegmentMidpointHitCoords[1],
|
||||
),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return existingSegmentMidpointHitCoords;
|
||||
@@ -594,11 +605,9 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
const distance = distance2d(
|
||||
midPoints[index]![0],
|
||||
midPoints[index]![1],
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
const distance = pointDistance(
|
||||
point(midPoints[index]![0], midPoints[index]![1]),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return midPoints[index];
|
||||
@@ -612,15 +621,13 @@ export class LinearElementEditor {
|
||||
|
||||
static isSegmentTooShort(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: Point,
|
||||
endPoint: Point,
|
||||
startPoint: GlobalPoint | LocalPoint,
|
||||
endPoint: GlobalPoint | LocalPoint,
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let distance = distance2d(
|
||||
startPoint[0],
|
||||
startPoint[1],
|
||||
endPoint[0],
|
||||
endPoint[1],
|
||||
let distance = pointDistance(
|
||||
point(startPoint[0], startPoint[1]),
|
||||
point(endPoint[0], endPoint[1]),
|
||||
);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
@@ -631,12 +638,12 @@ export class LinearElementEditor {
|
||||
|
||||
static getSegmentMidPoint(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
startPoint: Point,
|
||||
endPoint: Point,
|
||||
startPoint: GlobalPoint,
|
||||
endPoint: GlobalPoint,
|
||||
endPointIndex: number,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
let segmentMidPoint = centerPoint(startPoint, endPoint);
|
||||
): GlobalPoint {
|
||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
const controlPoints = getControlPointsForBezierCurve(
|
||||
element,
|
||||
@@ -649,16 +656,15 @@ export class LinearElementEditor {
|
||||
0.5,
|
||||
);
|
||||
|
||||
const [tx, ty] = getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
[tx, ty],
|
||||
getBezierXY(
|
||||
controlPoints[0],
|
||||
controlPoints[1],
|
||||
controlPoints[2],
|
||||
controlPoints[3],
|
||||
t,
|
||||
),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
@@ -670,7 +676,7 @@ export class LinearElementEditor {
|
||||
static getSegmentMidPointIndex(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
appState: AppState,
|
||||
midPoint: Point,
|
||||
midPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
const element = LinearElementEditor.getElement(
|
||||
@@ -822,11 +828,12 @@ export class LinearElementEditor {
|
||||
const cy = (y1 + y2) / 2;
|
||||
const targetPoint =
|
||||
clickedPointIndex > -1 &&
|
||||
rotate(
|
||||
element.x + element.points[clickedPointIndex][0],
|
||||
element.y + element.points[clickedPointIndex][1],
|
||||
cx,
|
||||
cy,
|
||||
pointRotateRads(
|
||||
point(
|
||||
element.x + element.points[clickedPointIndex][0],
|
||||
element.y + element.points[clickedPointIndex][1],
|
||||
),
|
||||
point(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -865,14 +872,17 @@ export class LinearElementEditor {
|
||||
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) {
|
||||
return true;
|
||||
}
|
||||
if (!point1 || !point2) {
|
||||
return false;
|
||||
}
|
||||
return arePointsEqual(point1, point2);
|
||||
return pointsEqual(point1, point2);
|
||||
}
|
||||
|
||||
static handlePointerMove(
|
||||
@@ -909,7 +919,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
}
|
||||
|
||||
let newPoint: Point;
|
||||
let newPoint: LocalPoint;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const lastCommittedPoint = points[points.length - 2];
|
||||
@@ -918,14 +928,14 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
lastCommittedPoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
point(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
newPoint = [
|
||||
newPoint = point(
|
||||
width + lastCommittedPoint[0],
|
||||
height + lastCommittedPoint[1],
|
||||
];
|
||||
);
|
||||
} else {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
@@ -965,30 +975,36 @@ export class LinearElementEditor {
|
||||
/** scene coords */
|
||||
static getPointGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
point: Point,
|
||||
p: LocalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
): GlobalPoint {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
let { x, y } = element;
|
||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return [x, y] as const;
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
point(x + p[0], y + p[1]),
|
||||
point(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
|
||||
/** scene coords */
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
): Point[] {
|
||||
): GlobalPoint[] {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return element.points.map((point) => {
|
||||
let { x, y } = element;
|
||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return [x, y] as const;
|
||||
return element.points.map((p) => {
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
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
|
||||
elementsMap: ElementsMap,
|
||||
): Point {
|
||||
): GlobalPoint {
|
||||
const index =
|
||||
indexMaybeFromEnd < 0
|
||||
? element.points.length + indexMaybeFromEnd
|
||||
@@ -1005,35 +1021,36 @@ export class LinearElementEditor {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
const point = element.points[index];
|
||||
const p = element.points[index];
|
||||
const { x, y } = element;
|
||||
return point
|
||||
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
||||
: rotate(x, y, cx, cy, element.angle);
|
||||
|
||||
return p
|
||||
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
|
||||
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
absoluteCoords: Point,
|
||||
absoluteCoords: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
): Point {
|
||||
): LocalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
// 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 cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [x, y] = rotate(
|
||||
absoluteCoords[0],
|
||||
absoluteCoords[1],
|
||||
cx,
|
||||
cy,
|
||||
-element.angle,
|
||||
const [x, y] = pointRotateRads(
|
||||
point(absoluteCoords[0], absoluteCoords[1]),
|
||||
point(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
return [x - element.x, y - element.y];
|
||||
return point(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
@@ -1052,9 +1069,9 @@ export class LinearElementEditor {
|
||||
// points on the left, thus should take precedence when clicking, if they
|
||||
// overlap
|
||||
while (--idx > -1) {
|
||||
const point = pointHandles[idx];
|
||||
const p = pointHandles[idx];
|
||||
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
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
@@ -1070,20 +1087,18 @@ export class LinearElementEditor {
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
gridSize: NullableGridSize,
|
||||
): Point {
|
||||
): LocalPoint {
|
||||
const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [rotatedX, rotatedY] = rotate(
|
||||
pointerOnGrid[0],
|
||||
pointerOnGrid[1],
|
||||
cx,
|
||||
cy,
|
||||
-element.angle,
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerOnGrid[0], pointerOnGrid[1]),
|
||||
point(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
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
|
||||
* for the potential normalization.
|
||||
*/
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement) {
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const { points } = element;
|
||||
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
return {
|
||||
points: points.map((point) => {
|
||||
return [point[0] - offsetX, point[1] - offsetY] as const;
|
||||
points: points.map((p) => {
|
||||
return point(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
@@ -1116,17 +1135,23 @@ export class LinearElementEditor {
|
||||
static duplicateSelectedPoints(
|
||||
appState: AppState,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
) {
|
||||
if (!appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
"Not currently editing a linear element",
|
||||
);
|
||||
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (!element || selectedPointsIndices === null) {
|
||||
return false;
|
||||
}
|
||||
invariant(
|
||||
element,
|
||||
"The linear element does not exist in the provided Scene",
|
||||
);
|
||||
invariant(
|
||||
selectedPointsIndices != null,
|
||||
"There are no selected points to duplicate",
|
||||
);
|
||||
|
||||
const { points } = element;
|
||||
|
||||
@@ -1134,9 +1159,9 @@ export class LinearElementEditor {
|
||||
|
||||
let pointAddedToEnd = false;
|
||||
let indexCursor = -1;
|
||||
const nextPoints = points.reduce((acc: Point[], point, index) => {
|
||||
const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
|
||||
++indexCursor;
|
||||
acc.push(point);
|
||||
acc.push(p);
|
||||
|
||||
const isSelected = selectedPointsIndices.includes(index);
|
||||
if (isSelected) {
|
||||
@@ -1147,8 +1172,8 @@ export class LinearElementEditor {
|
||||
}
|
||||
acc.push(
|
||||
nextPoint
|
||||
? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
|
||||
: [point[0], point[1]],
|
||||
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||
: point(p[0], p[1]),
|
||||
);
|
||||
|
||||
nextSelectedIndices.push(indexCursor + 1);
|
||||
@@ -1169,7 +1194,7 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: [lastPoint[0] + 30, lastPoint[1] + 30],
|
||||
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
@@ -1177,12 +1202,10 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedPointsIndices: nextSelectedIndices,
|
||||
},
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
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)) {
|
||||
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;
|
||||
@@ -1229,7 +1252,7 @@ export class LinearElementEditor {
|
||||
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { point: Point }[],
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
) {
|
||||
const offsetX = 0;
|
||||
@@ -1247,7 +1270,7 @@ export class LinearElementEditor {
|
||||
|
||||
static movePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
targetPoints: { index: number; point: Point; isDragging?: boolean }[],
|
||||
targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
otherUpdates?: {
|
||||
startBinding?: PointBinding | null;
|
||||
@@ -1277,11 +1300,11 @@ export class LinearElementEditor {
|
||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
const nextPoints = points.map((point, idx) => {
|
||||
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
||||
const nextPoints: LocalPoint[] = points.map((p, idx) => {
|
||||
const selectedPointData = targetPoints.find((t) => t.index === idx);
|
||||
if (selectedPointData) {
|
||||
if (selectedPointData.index === 0) {
|
||||
return point;
|
||||
return p;
|
||||
}
|
||||
|
||||
const deltaX =
|
||||
@@ -1289,14 +1312,9 @@ export class LinearElementEditor {
|
||||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return [
|
||||
point[0] + deltaX - offsetX,
|
||||
point[1] + deltaY - offsetY,
|
||||
] as const;
|
||||
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
}
|
||||
return offsetX || offsetY
|
||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||
: point;
|
||||
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
});
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
@@ -1349,11 +1367,9 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
const origin = linearElementEditor.pointerDownState.origin!;
|
||||
const dist = distance2d(
|
||||
origin.x,
|
||||
origin.y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
const dist = pointDistance(
|
||||
point(origin.x, origin.y),
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
@@ -1418,7 +1434,7 @@ export class LinearElementEditor {
|
||||
|
||||
private static _updatePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
nextPoints: readonly Point[],
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
@@ -1461,9 +1477,11 @@ export class LinearElementEditor {
|
||||
element,
|
||||
mergedElementsMap,
|
||||
nextPoints,
|
||||
[offsetX, offsetY],
|
||||
vector(offsetX, offsetY),
|
||||
bindings,
|
||||
options,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
@@ -1474,7 +1492,11 @@ export class LinearElementEditor {
|
||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
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, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
@@ -1487,8 +1509,8 @@ export class LinearElementEditor {
|
||||
private static _getShiftLockedDelta(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
referencePoint: Point,
|
||||
scenePointer: Point,
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
gridSize: NullableGridSize,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
@@ -1517,7 +1539,11 @@ export class LinearElementEditor {
|
||||
gridY,
|
||||
);
|
||||
|
||||
return rotatePoint([width, height], [0, 0], -element.angle);
|
||||
return pointRotateRads(
|
||||
point(width, height),
|
||||
point(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
static getBoundTextElementPosition = (
|
||||
@@ -1548,7 +1574,7 @@ export class LinearElementEditor {
|
||||
|
||||
let midSegmentMidpoint = editorMidPointsCache.points[index];
|
||||
if (element.points.length === 2) {
|
||||
midSegmentMidpoint = centerPoint(points[0], points[1]);
|
||||
midSegmentMidpoint = pointCenter(points[0], points[1]);
|
||||
}
|
||||
if (
|
||||
!midSegmentMidpoint ||
|
||||
@@ -1585,37 +1611,38 @@ export class LinearElementEditor {
|
||||
);
|
||||
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
||||
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
||||
const centerPoint = point(cx, cy);
|
||||
|
||||
const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
|
||||
const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
|
||||
|
||||
const counterRotateBoundTextTopLeft = rotatePoint(
|
||||
[boundTextX1, boundTextY1],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
const topLeftRotatedPoint = pointRotateRads(
|
||||
point(x1, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
const counterRotateBoundTextTopRight = rotatePoint(
|
||||
[boundTextX2, boundTextY1],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
const topRightRotatedPoint = pointRotateRads(
|
||||
point(x2, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
const counterRotateBoundTextBottomLeft = rotatePoint(
|
||||
[boundTextX1, boundTextY2],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
const counterRotateBoundTextTopLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomRight = rotatePoint(
|
||||
[boundTextX2, boundTextY2],
|
||||
|
||||
[cx, cy],
|
||||
|
||||
-element.angle,
|
||||
const counterRotateBoundTextTopRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
|
||||
import Scene from "../scene/Scene";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import type { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import type { Mutable } from "../utility-types";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
@@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
let didChangePoints = false;
|
||||
let index = prevPoints.length;
|
||||
while (--index) {
|
||||
const prevPoint: Point = prevPoints[index];
|
||||
const nextPoint: Point = nextPoints[index];
|
||||
const prevPoint = prevPoints[index];
|
||||
const nextPoint = nextPoints[index];
|
||||
if (
|
||||
prevPoint[0] !== nextPoint[0] ||
|
||||
prevPoint[1] !== nextPoint[1]
|
||||
|
||||
@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
],
|
||||
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
@@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import type { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
measureText,
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
} from "../constants";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import type { Radians } from "../../math";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
||||
width = 0,
|
||||
height = 0,
|
||||
angle = 0,
|
||||
angle = 0 as Radians,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
index = null,
|
||||
@@ -223,7 +223,6 @@ export const newTextElement = (
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): 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 = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import { rotate, centerPoint, rotatePoint } from "../math";
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -11,6 +9,7 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
@@ -38,7 +37,7 @@ import type {
|
||||
MaybeTransformHandleType,
|
||||
TransformHandleDirection,
|
||||
} from "./transformHandles";
|
||||
import type { Point, PointerDownState } from "../types";
|
||||
import type { PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineWidth,
|
||||
@@ -55,16 +54,15 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
import { mutateElbowArrow } from "./routing";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
if (angle < 0) {
|
||||
return angle + 2 * Math.PI;
|
||||
}
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return angle - 2 * Math.PI;
|
||||
}
|
||||
return angle;
|
||||
};
|
||||
import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
point,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "../../math";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
@@ -158,16 +156,17 @@ const rotateSingleElement = (
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle: number;
|
||||
let angle: Radians;
|
||||
if (isFrameLikeElement(element)) {
|
||||
angle = 0;
|
||||
angle = 0 as Radians;
|
||||
} 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) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
|
||||
angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
angle = normalizeRadians(angle as Radians);
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
@@ -240,12 +239,10 @@ const resizeSingleTextElement = (
|
||||
elementsMap,
|
||||
);
|
||||
// rotation pointer with reverse angle
|
||||
const [rotatedX, rotatedY] = rotate(
|
||||
pointerX,
|
||||
pointerY,
|
||||
cx,
|
||||
cy,
|
||||
-element.angle,
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
point(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
let scaleX = 0;
|
||||
let scaleY = 0;
|
||||
@@ -279,20 +276,26 @@ const resizeSingleTextElement = (
|
||||
const startBottomRight = [x2, y2];
|
||||
const startCenter = [cx, cy];
|
||||
|
||||
let newTopLeft = [x1, y1] as [number, number];
|
||||
let newTopLeft = point<GlobalPoint>(x1, y1);
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = [
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
startBottomRight[0] - Math.abs(nextWidth),
|
||||
startBottomRight[1] - Math.abs(nextHeight),
|
||||
];
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "ne") {
|
||||
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") {
|
||||
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)) {
|
||||
@@ -308,13 +311,17 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
|
||||
const angle = element.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
|
||||
const newCenter: Point = [
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
|
||||
const newCenter = point<GlobalPoint>(
|
||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
const [nextX, nextY] = newTopLeft;
|
||||
|
||||
mutateElement(element, {
|
||||
@@ -334,14 +341,14 @@ const resizeSingleTextElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||
const startTopLeft = point<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = point<GlobalPoint>(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const rotatedPointer = rotatePoint(
|
||||
[pointerX, pointerY],
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
|
||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
||||
@@ -407,13 +414,21 @@ const resizeSingleTextElement = (
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
pointFromPair(newTopLeft),
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
@@ -446,15 +461,15 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||
const startTopLeft = point(x1, y1);
|
||||
const startBottomRight = point(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
// Calculate new dimensions based on cursor position
|
||||
const rotatedPointer = rotatePoint(
|
||||
[pointerX, pointerY],
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
|
||||
// Get bounds corners rendered on screen
|
||||
@@ -628,13 +643,21 @@ export const resizeSingleElement = (
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
pointFromPair(newTopLeft),
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, 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
|
||||
// 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 anchorsMap: Record<TransformHandleDirection, Point> = {
|
||||
ne: [minX, maxY],
|
||||
se: [minX, minY],
|
||||
sw: [maxX, minY],
|
||||
nw: [maxX, maxY],
|
||||
e: [minX, minY + height / 2],
|
||||
w: [maxX, minY + height / 2],
|
||||
n: [minX + width / 2, maxY],
|
||||
s: [minX + width / 2, minY],
|
||||
const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
|
||||
ne: point(minX, maxY),
|
||||
se: point(minX, minY),
|
||||
sw: point(maxX, minY),
|
||||
nw: point(maxX, maxY),
|
||||
e: point(minX, minY + height / 2),
|
||||
w: point(maxX, minY + height / 2),
|
||||
n: point(minX + width / 2, maxY),
|
||||
s: point(minX + width / 2, minY),
|
||||
};
|
||||
|
||||
// anchor point must be on the opposite side of the dragged selection handle
|
||||
// or be the center of the selection if shouldResizeFromCenter
|
||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||
? [midX, midY]
|
||||
const [anchorX, anchorY] = shouldResizeFromCenter
|
||||
? point(midX, midY)
|
||||
: anchorsMap[direction];
|
||||
|
||||
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||
@@ -887,6 +910,8 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
startBinding?: ExcalidrawArrowElement["startBinding"];
|
||||
endBinding?: ExcalidrawArrowElement["endBinding"];
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
@@ -898,7 +923,9 @@ export const resizeMultipleElements = (
|
||||
|
||||
const width = orig.width * scaleX;
|
||||
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 offsetX = orig.x - anchorX;
|
||||
@@ -969,19 +996,6 @@ export const resizeMultipleElements = (
|
||||
|
||||
mutateElement(element, update, false);
|
||||
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
mutateElbowArrow(
|
||||
element,
|
||||
elementsMap,
|
||||
element.points,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
informMutation: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
updateBoundElements(element, elementsMap, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
oldSize: { width: oldWidth, height: oldHeight },
|
||||
@@ -1029,15 +1043,13 @@ const rotateMultipleElements = (
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
const [rotatedCX, rotatedCY] = pointRotateRads(
|
||||
point(cx, cy),
|
||||
point(centerX, centerY),
|
||||
(centerAngle + origAngle - element.angle) as Radians,
|
||||
);
|
||||
|
||||
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||
if (isElbowArrow(element)) {
|
||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||
mutateElbowArrow(element, elementsMap, points);
|
||||
} else {
|
||||
@@ -1046,7 +1058,7 @@ const rotateMultipleElements = (
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -1063,7 +1075,7 @@ const rotateMultipleElements = (
|
||||
{
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -1086,25 +1098,43 @@ export const getResizeOffsetXY = (
|
||||
: getCommonBounds(selectedElements);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
|
||||
[x, y] = rotate(x, y, cx, cy, -angle);
|
||||
const angle = (
|
||||
selectedElements.length === 1 ? selectedElements[0].angle : 0
|
||||
) as Radians;
|
||||
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
|
||||
switch (transformHandleType) {
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return rotate(x - x1, y - y1, 0, 0, angle);
|
||||
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
|
||||
case "ne":
|
||||
return rotate(x - x2, y - y1, 0, 0, angle);
|
||||
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
|
||||
case "sw":
|
||||
return rotate(x - x1, y - y2, 0, 0, angle);
|
||||
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
|
||||
case "se":
|
||||
return rotate(x - x2, y - y2, 0, 0, angle);
|
||||
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
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 type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "../../math";
|
||||
|
||||
const isInsideTransformHandle = (
|
||||
transformHandle: TransformHandle,
|
||||
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
|
||||
y >= transformHandle[1] &&
|
||||
y <= transformHandle[1] + transformHandle[3];
|
||||
|
||||
export const resizeTest = (
|
||||
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
@@ -91,15 +92,17 @@ export const resizeTest = (
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
[x1 - SPACING, y1 - SPACING],
|
||||
[x2 + SPACING, y2 + SPACING],
|
||||
[cx, cy],
|
||||
angleToDegrees(element.angle),
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
};
|
||||
|
||||
export const getTransformHandleTypeFromCoords = (
|
||||
export const getTransformHandleTypeFromCoords = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
@@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0,
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
@@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
|
||||
const sides = getSelectionBorders(
|
||||
[x1 - SPACING, y1 - SPACING],
|
||||
[x2 + SPACING, y2 + SPACING],
|
||||
[cx, cy],
|
||||
angleToDegrees(0),
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
0 as Radians,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
};
|
||||
|
||||
const getSelectionBorders = (
|
||||
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||
[x1, y1]: Point,
|
||||
[x2, y2]: Point,
|
||||
center: Point,
|
||||
angleInDegrees: number,
|
||||
angle: Radians,
|
||||
) => {
|
||||
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
|
||||
const topRight = pointRotate([x2, y1], angleInDegrees, center);
|
||||
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
|
||||
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
|
||||
const topLeft = pointRotateRads(point(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(point(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(point(x2, y2), center, angle);
|
||||
|
||||
return {
|
||||
n: [topLeft, topRight],
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import { point } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||
[-45 - arrow.x, -100.1 - arrow.y],
|
||||
[45 - arrow.x, 99.9 - arrow.y],
|
||||
point(-45 - arrow.x, -100.1 - arrow.y),
|
||||
point(45 - arrow.x, 99.9 - arrow.y),
|
||||
]);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [
|
||||
[0, 0],
|
||||
[90, 200],
|
||||
],
|
||||
points: [point(0, 0), point(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
mutateElbowArrow(arrow, elementsMap, [
|
||||
[0, 0],
|
||||
[90, 200],
|
||||
]);
|
||||
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@@ -99,7 +94,16 @@ describe("elbow arrow routing", () => {
|
||||
|
||||
describe("elbow arrow ui", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
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 () => {
|
||||
@@ -135,8 +139,8 @@ describe("elbow arrow ui", () => {
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 200],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
@@ -168,14 +172,6 @@ describe("elbow arrow ui", () => {
|
||||
h.state,
|
||||
)[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);
|
||||
|
||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||
@@ -187,8 +183,8 @@ describe("elbow arrow ui", () => {
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 90],
|
||||
[25, 90],
|
||||
[25, 165],
|
||||
[35, 90], // Note that coordinates are rounded above!
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { cross } from "../../utils/geometry/geometry";
|
||||
import BinaryHeap from "../binaryheap";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
aabbForElement,
|
||||
arePointsEqual,
|
||||
pointInsideBounds,
|
||||
pointToVector,
|
||||
scalePointFromOrigin,
|
||||
scaleVector,
|
||||
translatePoint,
|
||||
} from "../math";
|
||||
point,
|
||||
pointScaleFromOrigin,
|
||||
pointTranslate,
|
||||
vector,
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Vector,
|
||||
} from "../../math";
|
||||
import BinaryHeap from "../binaryheap";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import type { Point } from "../types";
|
||||
import { aabbForElement, pointInsideBounds } from "../shapes";
|
||||
import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
@@ -25,22 +28,26 @@ import {
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
vectorToHeading,
|
||||
} from "./heading";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
||||
import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
FixedPointBinding,
|
||||
NonDeletedSceneElementsMap,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
import type { ElementsMap, ExcalidrawBindableElement } from "./types";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
|
||||
type Node = {
|
||||
f: number;
|
||||
g: number;
|
||||
@@ -48,8 +55,8 @@ type Node = {
|
||||
closed: boolean;
|
||||
visited: boolean;
|
||||
parent: Node | null;
|
||||
pos: Point;
|
||||
addr: [number, number];
|
||||
pos: GlobalPoint;
|
||||
addr: GridAddress;
|
||||
};
|
||||
|
||||
type Grid = {
|
||||
@@ -63,26 +70,64 @@ const BASE_PADDING = 40;
|
||||
export const mutateElbowArrow = (
|
||||
arrow: ExcalidrawElbowArrowElement,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
nextPoints: readonly Point[],
|
||||
offset?: Point,
|
||||
otherUpdates?: {
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offset?: Vector,
|
||||
otherUpdates?: Omit<
|
||||
ElementUpdate<ExcalidrawElbowArrowElement>,
|
||||
"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?: {
|
||||
isDragging?: boolean;
|
||||
disableBinding?: boolean;
|
||||
informMutation?: boolean;
|
||||
},
|
||||
) => {
|
||||
const origStartGlobalPoint = translatePoint(nextPoints[0], [
|
||||
arrow.x + (offset ? offset[0] : 0),
|
||||
arrow.y + (offset ? offset[1] : 0),
|
||||
]);
|
||||
const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
|
||||
arrow.x + (offset ? offset[0] : 0),
|
||||
arrow.y + (offset ? offset[1] : 0),
|
||||
]);
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||
pointTranslate<LocalPoint, GlobalPoint>(
|
||||
nextPoints[0],
|
||||
vector(arrow.x, arrow.y),
|
||||
),
|
||||
offset,
|
||||
);
|
||||
const origEndGlobalPoint: GlobalPoint = pointTranslate(
|
||||
pointTranslate<LocalPoint, GlobalPoint>(
|
||||
nextPoints[nextPoints.length - 1],
|
||||
vector(arrow.x, arrow.y),
|
||||
),
|
||||
offset,
|
||||
);
|
||||
|
||||
const startElement =
|
||||
arrow.startBinding &&
|
||||
@@ -222,6 +267,8 @@ export const mutateElbowArrow = (
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||
);
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
@@ -275,22 +322,17 @@ export const mutateElbowArrow = (
|
||||
);
|
||||
|
||||
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);
|
||||
endDongle && points.push(endGlobalPoint);
|
||||
|
||||
mutateElement(
|
||||
arrow,
|
||||
{
|
||||
...otherUpdates,
|
||||
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
|
||||
angle: 0,
|
||||
},
|
||||
options?.informMutation,
|
||||
);
|
||||
} else {
|
||||
console.error("Elbow arrow cannot find a route");
|
||||
return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const offsetFromHeading = (
|
||||
@@ -363,7 +405,7 @@ const astar = (
|
||||
}
|
||||
|
||||
// Intersect
|
||||
const neighborHalfPoint = scalePointFromOrigin(
|
||||
const neighborHalfPoint = pointScaleFromOrigin(
|
||||
neighbor.pos,
|
||||
current.pos,
|
||||
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.
|
||||
const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
|
||||
const previousDirection = current.parent
|
||||
? vectorToHeading(pointToVector(current.pos, current.parent.pos))
|
||||
? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
|
||||
: startHeading;
|
||||
|
||||
// Do not allow going in reverse
|
||||
const reverseHeading = scaleVector(previousDirection, -1);
|
||||
const reverseHeading = flipHeading(previousDirection);
|
||||
const neighborIsReverseRoute =
|
||||
arePointsEqual(reverseHeading, neighborHeading) ||
|
||||
(arePointsEqual(start.addr, neighbor.addr) &&
|
||||
arePointsEqual(neighborHeading, startHeading)) ||
|
||||
(arePointsEqual(end.addr, neighbor.addr) &&
|
||||
arePointsEqual(neighborHeading, endHeading));
|
||||
compareHeading(reverseHeading, neighborHeading) ||
|
||||
(gridAddressesEqual(start.addr, neighbor.addr) &&
|
||||
compareHeading(neighborHeading, startHeading)) ||
|
||||
(gridAddressesEqual(end.addr, neighbor.addr) &&
|
||||
compareHeading(neighborHeading, endHeading));
|
||||
if (neighborIsReverseRoute) {
|
||||
continue;
|
||||
}
|
||||
@@ -444,7 +486,7 @@ const pathTo = (start: Node, node: Node) => {
|
||||
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]);
|
||||
|
||||
/**
|
||||
@@ -459,7 +501,11 @@ const generateDynamicAABBs = (
|
||||
startDifference?: [number, number, number, number],
|
||||
endDifference?: [number, number, number, number],
|
||||
disableSideHack?: boolean,
|
||||
startElementBounds?: Bounds | null,
|
||||
endElementBounds?: Bounds | null,
|
||||
): Bounds[] => {
|
||||
const startEl = startElementBounds ?? a;
|
||||
const endEl = endElementBounds ?? b;
|
||||
const [startUp, startRight, startDown, startLeft] = startDifference ?? [
|
||||
0, 0, 0, 0,
|
||||
];
|
||||
@@ -468,29 +514,29 @@ const generateDynamicAABBs = (
|
||||
const first = [
|
||||
a[0] > b[2]
|
||||
? a[1] > b[3] || a[3] < b[1]
|
||||
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft)
|
||||
: (a[0] + b[2]) / 2
|
||||
? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
|
||||
: (startEl[0] + endEl[2]) / 2
|
||||
: a[0] > b[0]
|
||||
? a[0] - startLeft
|
||||
: common[0] - startLeft,
|
||||
a[1] > b[3]
|
||||
? a[0] > b[2] || a[2] < b[0]
|
||||
? Math.min((a[1] + b[3]) / 2, a[1] - startUp)
|
||||
: (a[1] + b[3]) / 2
|
||||
? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
|
||||
: (startEl[1] + endEl[3]) / 2
|
||||
: a[1] > b[1]
|
||||
? a[1] - startUp
|
||||
: common[1] - startUp,
|
||||
a[2] < b[0]
|
||||
? a[1] > b[3] || a[3] < b[1]
|
||||
? Math.max((a[2] + b[0]) / 2, a[2] + startRight)
|
||||
: (a[2] + b[0]) / 2
|
||||
? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
|
||||
: (startEl[2] + endEl[0]) / 2
|
||||
: a[2] < b[2]
|
||||
? a[2] + startRight
|
||||
: common[2] + startRight,
|
||||
a[3] < b[1]
|
||||
? a[0] > b[2] || a[2] < b[0]
|
||||
? Math.max((a[3] + b[1]) / 2, a[3] + startDown)
|
||||
: (a[3] + b[1]) / 2
|
||||
? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
|
||||
: (startEl[3] + endEl[1]) / 2
|
||||
: a[3] < b[3]
|
||||
? a[3] + startDown
|
||||
: common[3] + startDown,
|
||||
@@ -498,29 +544,29 @@ const generateDynamicAABBs = (
|
||||
const second = [
|
||||
b[0] > a[2]
|
||||
? b[1] > a[3] || b[3] < a[1]
|
||||
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft)
|
||||
: (b[0] + a[2]) / 2
|
||||
? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
|
||||
: (endEl[0] + startEl[2]) / 2
|
||||
: b[0] > a[0]
|
||||
? b[0] - endLeft
|
||||
: common[0] - endLeft,
|
||||
b[1] > a[3]
|
||||
? b[0] > a[2] || b[2] < a[0]
|
||||
? Math.min((b[1] + a[3]) / 2, b[1] - endUp)
|
||||
: (b[1] + a[3]) / 2
|
||||
? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
|
||||
: (endEl[1] + startEl[3]) / 2
|
||||
: b[1] > a[1]
|
||||
? b[1] - endUp
|
||||
: common[1] - endUp,
|
||||
b[2] < a[0]
|
||||
? b[1] > a[3] || b[3] < a[1]
|
||||
? Math.max((b[2] + a[0]) / 2, b[2] + endRight)
|
||||
: (b[2] + a[0]) / 2
|
||||
? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
|
||||
: (endEl[2] + startEl[0]) / 2
|
||||
: b[2] < a[2]
|
||||
? b[2] + endRight
|
||||
: common[2] + endRight,
|
||||
b[3] < a[1]
|
||||
? b[0] > a[2] || b[2] < a[0]
|
||||
? Math.max((b[3] + a[1]) / 2, b[3] + endDown)
|
||||
: (b[3] + a[1]) / 2
|
||||
? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
|
||||
: (endEl[3] + startEl[1]) / 2
|
||||
: b[3] < a[3]
|
||||
? b[3] + endDown
|
||||
: common[3] + endDown,
|
||||
@@ -541,7 +587,12 @@ const generateDynamicAABBs = (
|
||||
const cX = first[2] + (second[0] - first[2]) / 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 [
|
||||
[first[0], first[1], cX, first[3]],
|
||||
[cX, second[1], second[2], second[3]],
|
||||
@@ -557,7 +608,12 @@ const generateDynamicAABBs = (
|
||||
const cX = first[2] + (second[0] - first[2]) / 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 [
|
||||
[first[0], first[1], first[2], cY],
|
||||
[second[0], cY, second[2], second[3]],
|
||||
@@ -573,7 +629,12 @@ const generateDynamicAABBs = (
|
||||
const cX = second[2] + (first[0] - second[2]) / 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 [
|
||||
[cX, first[1], first[2], first[3]],
|
||||
[second[0], second[1], cX, second[3]],
|
||||
@@ -589,7 +650,12 @@ const generateDynamicAABBs = (
|
||||
const cX = second[2] + (first[0] - second[2]) / 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 [
|
||||
[cX, first[1], first[2], first[3]],
|
||||
[second[0], second[1], cX, second[3]],
|
||||
@@ -615,9 +681,9 @@ const generateDynamicAABBs = (
|
||||
*/
|
||||
const calculateGrid = (
|
||||
aabbs: Bounds[],
|
||||
start: Point,
|
||||
start: GlobalPoint,
|
||||
startHeading: Heading,
|
||||
end: Point,
|
||||
end: GlobalPoint,
|
||||
endHeading: Heading,
|
||||
common: Bounds,
|
||||
): Grid => {
|
||||
@@ -662,8 +728,8 @@ const calculateGrid = (
|
||||
closed: false,
|
||||
visited: false,
|
||||
parent: null,
|
||||
addr: [col, row] as [number, number],
|
||||
pos: [x, y] as Point,
|
||||
addr: [col, row] as GridAddress,
|
||||
pos: [x, y] as GlobalPoint,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -673,17 +739,17 @@ const calculateGrid = (
|
||||
const getDonglePosition = (
|
||||
bounds: Bounds,
|
||||
heading: Heading,
|
||||
point: Point,
|
||||
): Point => {
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
switch (heading) {
|
||||
case HEADING_UP:
|
||||
return [point[0], bounds[1]];
|
||||
return point(p[0], bounds[1]);
|
||||
case HEADING_RIGHT:
|
||||
return [bounds[2], point[1]];
|
||||
return point(bounds[2], p[1]);
|
||||
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 = (
|
||||
@@ -826,7 +892,7 @@ const gridNodeFromAddr = (
|
||||
/**
|
||||
* 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 row = 0; row < grid.row; row++) {
|
||||
const candidate = gridNodeFromAddr([col, row], grid);
|
||||
@@ -865,15 +931,24 @@ const getBindableElementForId = (
|
||||
};
|
||||
|
||||
const normalizedArrowElementUpdate = (
|
||||
global: Point[],
|
||||
global: GlobalPoint[],
|
||||
externalOffsetX?: number,
|
||||
externalOffsetY?: number,
|
||||
) => {
|
||||
): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
const offsetX = global[0][0];
|
||||
const offsetY = global[0][1];
|
||||
|
||||
const points = global.map(
|
||||
(point) => [point[0] - offsetX, point[1] - offsetY] as const,
|
||||
const points = global.map((p) =>
|
||||
pointTranslate<GlobalPoint, LocalPoint>(
|
||||
p,
|
||||
vectorScale(vectorFromPoint(global[0]), -1),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -885,19 +960,22 @@ const normalizedArrowElementUpdate = (
|
||||
};
|
||||
|
||||
/// If last and current segments have the same heading, skip the middle point
|
||||
const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
|
||||
const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
|
||||
points
|
||||
.slice(2)
|
||||
.reduce(
|
||||
(result, point) =>
|
||||
arePointsEqual(
|
||||
(result, p) =>
|
||||
compareHeading(
|
||||
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, point],
|
||||
? [...result.slice(0, -1), p]
|
||||
: [...result, p],
|
||||
[points[0] ?? [0, 0], points[1] ?? [1, 0]],
|
||||
);
|
||||
|
||||
@@ -915,13 +993,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
|
||||
|
||||
const getGlobalPoint = (
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: Point,
|
||||
otherPoint: Point,
|
||||
initialPoint: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
boundElement?: ExcalidrawBindableElement | null,
|
||||
hoveredElement?: ExcalidrawBindableElement | null,
|
||||
isDragging?: boolean,
|
||||
): Point => {
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (hoveredElement) {
|
||||
const snapPoint = getSnapPoint(
|
||||
@@ -956,36 +1034,34 @@ const getGlobalPoint = (
|
||||
};
|
||||
|
||||
const getSnapPoint = (
|
||||
point: Point,
|
||||
otherPoint: Point,
|
||||
p: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
bindPointToSnapToElementOutline(
|
||||
isRectanguloidElement(element)
|
||||
? avoidRectangularCorner(element, point)
|
||||
: point,
|
||||
isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
|
||||
otherPoint,
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const getBindPointHeading = (
|
||||
point: Point,
|
||||
otherPoint: Point,
|
||||
p: GlobalPoint,
|
||||
otherPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: Point,
|
||||
origPoint: GlobalPoint,
|
||||
) =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
point,
|
||||
p,
|
||||
otherPoint,
|
||||
hoveredElement,
|
||||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
Array(4).fill(
|
||||
distanceToBindableElement(hoveredElement, point, elementsMap),
|
||||
distanceToBindableElement(hoveredElement, p, elementsMap),
|
||||
) as [number, number, number, number],
|
||||
),
|
||||
elementsMap,
|
||||
@@ -993,8 +1069,8 @@ const getBindPointHeading = (
|
||||
);
|
||||
|
||||
const getHoveredElements = (
|
||||
origStartGlobalPoint: Point,
|
||||
origEndGlobalPoint: Point,
|
||||
origStartGlobalPoint: GlobalPoint,
|
||||
origEndGlobalPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
) => {
|
||||
// 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 { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
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 { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
padding?: Partial<{
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}>,
|
||||
padding?: Offsets,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
|
||||
@@ -284,16 +284,17 @@ export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
text = text
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(text, fontSize, lineHeight);
|
||||
const width = getTextWidth(text, font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font, forceAdvanceWidth);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { getOriginalContainerHeightFromCache } from "./containerCache";
|
||||
import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
|
||||
import { point } from "../../math";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
],
|
||||
points: [point(0, 0), point(100, 0)],
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -38,7 +38,11 @@ import {
|
||||
actionDecreaseFontSize,
|
||||
actionIncreaseFontSize,
|
||||
} from "../actions/actionProperties";
|
||||
import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas";
|
||||
import {
|
||||
actionResetZoom,
|
||||
actionZoomIn,
|
||||
actionZoomOut,
|
||||
} from "../actions/actionCanvas";
|
||||
import type App from "../components/App";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
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)
|
||||
const padding = !isSafari
|
||||
? Math.ceil(updatedTextElement.fontSize / 2)
|
||||
? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
@@ -379,6 +383,10 @@ export const textWysiwyg = ({
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionZoomOut);
|
||||
updateWysiwygStyle();
|
||||
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionResetZoom);
|
||||
updateWysiwygStyle();
|
||||
} else if (actionDecreaseFontSize.keyTest(event)) {
|
||||
app.actionManager.executeAction(actionDecreaseFontSize);
|
||||
} else if (actionIncreaseFontSize.keyTest(event)) {
|
||||
@@ -593,6 +601,7 @@ export const textWysiwyg = ({
|
||||
window.removeEventListener("blur", handleSubmit);
|
||||
window.removeEventListener("beforeunload", handleSubmit);
|
||||
unbindUpdate();
|
||||
unbindOnScroll();
|
||||
|
||||
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
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
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 =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
@@ -630,17 +658,14 @@ export const textWysiwyg = ({
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
event.target.closest(
|
||||
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||
) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isPropertiesTrigger
|
||||
) {
|
||||
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);
|
||||
temporarilyDisableSubmit();
|
||||
} else if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target instanceof HTMLCanvasElement &&
|
||||
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||
// so without introducing crazier hacks, nothing we can do
|
||||
@@ -659,7 +684,7 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
|
||||
const unbindUpdate = app.scene.onUpdate(() => {
|
||||
updateWysiwygStyle();
|
||||
const isPopupOpened = !!document.activeElement?.closest(
|
||||
".properties-content",
|
||||
@@ -669,6 +694,10 @@ export const textWysiwyg = ({
|
||||
}
|
||||
});
|
||||
|
||||
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
|
||||
updateWysiwygStyle();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let isDestroyed = false;
|
||||
@@ -699,10 +728,6 @@ export const textWysiwyg = ({
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
||||
});
|
||||
window.addEventListener("wheel", stopEvent, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener("beforeunload", handleSubmit);
|
||||
excalidrawContainer
|
||||
?.querySelector(".excalidraw-textEditorContainer")!
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import {
|
||||
isElbowArrow,
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
isAndroid,
|
||||
isIOS,
|
||||
} from "../constants";
|
||||
import type { Radians } from "../../math";
|
||||
import { point, pointRotateRads } from "../../math";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
@@ -91,9 +92,13 @@ const generateTransformHandle = (
|
||||
height: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
angle: Radians,
|
||||
): 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];
|
||||
};
|
||||
|
||||
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
|
||||
|
||||
export const getTransformHandlesFromCoords = (
|
||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||
angle: number,
|
||||
angle: Radians,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ROUNDNESS } from "../constants";
|
||||
import type { ElementOrToolType } from "../types";
|
||||
import type { MarkNonNullable } from "../utility-types";
|
||||
import { assertNever } from "../utils";
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -189,7 +190,8 @@ export const isRectangularElement = (
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe")
|
||||
element.type === "magicframe" ||
|
||||
element.type === "freedraw")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -318,7 +320,19 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding,
|
||||
binding: PointBinding | 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 {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
angle: Radians;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
@@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||
@@ -184,6 +193,7 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
@@ -259,15 +269,19 @@ export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: 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 =
|
||||
| "arrow"
|
||||
@@ -283,8 +297,8 @@ export type Arrowhead =
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly Point[];
|
||||
lastCommittedPoint: Point | null;
|
||||
points: readonly LocalPoint[];
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
@@ -309,10 +323,10 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
points: readonly Point[];
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: Point | null;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
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 loadWoff2 from "./wasm/woff2.loader";
|
||||
import loadHbSubset from "./wasm/hb-subset.loader";
|
||||
|
||||
export interface Font {
|
||||
urls: URL[];
|
||||
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.PKG_VERSION}` // should be provided by vite during package build
|
||||
: "@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.
|
||||
* Returns last defined url in case of errors.
|
||||
* Tries to fetch woff2 content, based on the registered urls (from first to last, treated as fallbacks).
|
||||
*
|
||||
* 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;
|
||||
const errorMessages = [];
|
||||
|
||||
while (i < this.urls.length) {
|
||||
const url = this.urls[i];
|
||||
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
// it's dataurl, the font is inlined as base64, no need to fetch
|
||||
return url.toString();
|
||||
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
codePoints,
|
||||
);
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -57,13 +71,13 @@ export class ExcalidrawFont implements Font {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const mimeType = await response.headers.get("Content-Type");
|
||||
const buffer = await response.arrayBuffer();
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
codePoints,
|
||||
);
|
||||
|
||||
return `data:${mimeType};base64,${await stringToBase64(
|
||||
await toByteString(buffer),
|
||||
true,
|
||||
)}`;
|
||||
return base64;
|
||||
}
|
||||
|
||||
// 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() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
if (uri.startsWith(LOCAL_FONT_PROTOCOL)) {
|
||||
// no url for local fonts
|
||||
@@ -118,15 +174,14 @@ export class ExcalidrawFont implements Font {
|
||||
}
|
||||
|
||||
// fallback url for bundled fonts
|
||||
urls.push(new URL(assetUrl, UNPKG_PROD_URL));
|
||||
urls.push(new URL(assetUrl, UNPKG_FALLBACK_URL));
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static getFormat(url: URL) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const parts = pathname.split(".");
|
||||
const parts = new URL(url).pathname.split(".");
|
||||
|
||||
if (parts.length === 1) {
|
||||
return "";
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -24,14 +24,14 @@ import Cascadia from "./assets/CascadiaCode-Regular.woff2";
|
||||
import ComicShanns from "./assets/ComicShanns-Regular.woff2";
|
||||
import LiberationSans from "./assets/LiberationSans-Regular.woff2";
|
||||
|
||||
import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
|
||||
import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
|
||||
import LilitaLatin from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
|
||||
import LilitaLatinExt from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
|
||||
|
||||
import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
|
||||
import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
|
||||
import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
|
||||
import NunitoLatin from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
|
||||
import NunitoLatinExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilic from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
|
||||
import NunitoCyrilicExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
|
||||
import NunitoVietnamese from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
|
||||
|
||||
export class Fonts {
|
||||
// it's ok to track fonts across multiple instances only once, so let's use
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user