Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9954e5f3c2 | |||
| 0cc70d07c7 | |||
| 359a2f23ae | |||
| 467a4a2a6a | |||
| a7b55da49e | |||
| 4f82c32861 | |||
| 8701a5f843 | |||
| c498247e0f | |||
| 81e1d61d42 | |||
| 741c8dec31 | |||
| da0481683a | |||
| 58307a96dc | |||
| 1a0755cd56 | |||
| c742b4847f | |||
| bcb50188b8 | |||
| bdb1ec387d | |||
| b0375fe5db | |||
| 940099f85d | |||
| e30fd9960d | |||
| 80ff1562b8 | |||
| 3f00762a77 | |||
| 7b012b1cad | |||
| a02c4cb140 | |||
| 3a01122093 | |||
| 47ee8a0094 | |||
| bccd2bf30d | |||
| a977dd1bf5 | |||
| 50e4a0b37d | |||
| 9e80c9e3dd | |||
| afdf38e47c | |||
| 06a7a51baa | |||
| 3fe1883f3f | |||
| a80cb5896a | |||
| 6dfa18414a | |||
| 064bede0c5 | |||
| 997fec6c75 | |||
| 71ed96eabb | |||
| f4bebaaa50 | |||
| 8ca4cf3260 | |||
| f3f0ab7c83 | |||
| 44a1c8d857 | |||
| e0a22edfbd | |||
| c07f5a0c80 | |||
| 508f16dc04 | |||
| c1b310c56b | |||
| d4900e8f19 | |||
| caf2db934c | |||
| 60e3801691 | |||
| 80f3b75d42 | |||
| dc812bee19 | |||
| 01e83cc9a5 | |||
| 813f9b702e | |||
| fd39712ba6 | |||
| b46ca0192b | |||
| 72b7c937b1 | |||
| d107215564 | |||
| 6959a363f0 | |||
| 3d92a3bf1f | |||
| 5a11c70714 | |||
| 6ff56c36e3 | |||
| 51ea184938 | |||
| 21fff26d31 |
@@ -8,3 +8,4 @@ public/workbox
|
||||
packages/excalidraw/types
|
||||
examples/**/public
|
||||
dev-dist
|
||||
coverage
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -649,7 +649,12 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
// Render the debug scene if the debug canvas is available
|
||||
if (debugCanvasRef.current && excalidrawAPI) {
|
||||
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
|
||||
/>
|
||||
)}
|
||||
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
|
||||
@@ -68,12 +68,17 @@ 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,
|
||||
@@ -138,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
|
||||
};
|
||||
|
||||
export const debugRenderer = throttleRAF(
|
||||
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
|
||||
_debugRenderer(canvas, appState, scale);
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -130,15 +130,6 @@
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
<!-- 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(),
|
||||
|
||||
+8
-7
@@ -21,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",
|
||||
@@ -37,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"
|
||||
@@ -83,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 &&
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { register } from "./register";
|
||||
import { cropIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { isImageElement } from "../element/typeChecks";
|
||||
import type { ExcalidrawImageElement } from "../element/types";
|
||||
|
||||
export const actionToggleCropEditor = register({
|
||||
name: "cropEditor",
|
||||
label: "helpDialog.cropStart",
|
||||
icon: cropIcon,
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
keywords: ["image", "crop"],
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawImageElement;
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
isCropping: false,
|
||||
croppingElementId: selectedElement.id,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.croppingElementId &&
|
||||
selectedElements.length === 1 &&
|
||||
isImageElement(selectedElements[0])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const label = t("helpDialog.cropStart");
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={cropIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={() => updateData(null)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import type { AppState } from "../types";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { StoreAction } from "../store";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { isPathALoop } from "../shapes";
|
||||
|
||||
export const actionFinalize = register({
|
||||
@@ -115,7 +115,7 @@ export const actionFinalize = register({
|
||||
mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? point(firstPoint[0], firstPoint[1])
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
@@ -217,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 { pointFrom } 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: [
|
||||
pointFrom(0, 0),
|
||||
pointFrom(0, -35),
|
||||
pointFrom(-90.9, -35),
|
||||
pointFrom(-90.9, 204.9),
|
||||
pointFrom(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;
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ import {
|
||||
import { mutateElbowArrow } from "../element/routing";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point, vector } from "../../math";
|
||||
import { pointFrom, vector } from "../../math";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
@@ -1651,7 +1651,7 @@ export const actionChangeArrowType = register({
|
||||
elementsMap,
|
||||
[finalStartPoint, finalEndPoint].map(
|
||||
(p): LocalPoint =>
|
||||
point(p[0] - newElement.x, p[1] - newElement.y),
|
||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||
),
|
||||
vector(0, 0),
|
||||
{
|
||||
@@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
startBinding: newElement.startBinding
|
||||
? { ...newElement.startBinding, fixedPoint: null }
|
||||
: null,
|
||||
endBinding: newElement.endBinding
|
||||
? { ...newElement.endBinding, fixedPoint: null }
|
||||
: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
return newElement;
|
||||
|
||||
@@ -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,7 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "./actionLink";
|
||||
export { actionToggleElementLock } from "./actionElementLock";
|
||||
export { actionToggleLinearEditor } from "./actionLinearEditor";
|
||||
|
||||
export { actionToggleSearchMenu } from "./actionToggleSearchMenu";
|
||||
|
||||
export { actionToggleCropEditor } from "./actionCropEditor";
|
||||
|
||||
@@ -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,9 @@ export type ActionName =
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
| "elementStats"
|
||||
| "searchMenu"
|
||||
| "cropEditor";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -191,7 +193,8 @@ export interface Action {
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "search_menu";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
|
||||
@@ -116,6 +116,9 @@ export const getDefaultAppState = (): Omit<
|
||||
objectsSnapModeEnabled: false,
|
||||
userToFollow: null,
|
||||
followedBy: new Set(),
|
||||
isCropping: false,
|
||||
croppingElementId: null,
|
||||
searchMatches: [],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,6 +239,9 @@ 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 },
|
||||
isCropping: { browser: false, export: false, server: false },
|
||||
croppingElementId: { browser: false, export: false, server: false },
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -17,13 +17,16 @@ import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isBoundToContainer,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./element/types";
|
||||
@@ -626,6 +629,18 @@ export class AppStateChange implements Change<AppState> {
|
||||
);
|
||||
|
||||
break;
|
||||
case "croppingElementId": {
|
||||
const croppingElementId = nextAppState[key];
|
||||
const element =
|
||||
croppingElementId && nextElements.get(croppingElementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "editingGroupId":
|
||||
const editingGroupId = nextAppState[key];
|
||||
|
||||
@@ -756,6 +771,7 @@ export class AppStateChange implements Change<AppState> {
|
||||
selectedElementIds,
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
croppingElementId,
|
||||
...standaloneProps
|
||||
} = delta as ObservedAppState;
|
||||
|
||||
@@ -779,7 +795,10 @@ export class AppStateChange implements Change<AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
type ElementPartial = Omit<ElementUpdate<OrderedExcalidrawElement>, "seed">;
|
||||
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit<
|
||||
ElementUpdate<Ordered<T>>,
|
||||
"seed"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Elements change is a low level primitive to capture a change between two sets of elements.
|
||||
@@ -1216,6 +1235,18 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
||||
});
|
||||
}
|
||||
|
||||
if (isImageElement(element)) {
|
||||
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
|
||||
// we want to override `crop` only if modified so that we don't reset
|
||||
// when undoing/redoing unrelated change
|
||||
if (_delta.deleted.crop || _delta.inserted.crop) {
|
||||
Object.assign(directlyApplicablePartial, {
|
||||
// apply change verbatim
|
||||
crop: _delta.inserted.crop ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!flags.containsVisibleDifference) {
|
||||
// strip away fractional as even if it would be different, it doesn't have to result in visible change
|
||||
const { index, ...rest } = directlyApplicablePartial;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Radians } from "../math";
|
||||
import { point } from "../math";
|
||||
import { pointFrom } from "../math";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
DEFAULT_CHART_COLOR_INDEX,
|
||||
@@ -260,7 +260,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
width: chartWidth,
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@@ -271,7 +271,7 @@ const chartLines = (
|
||||
x,
|
||||
y,
|
||||
height: chartHeight,
|
||||
points: [point(0, 0), point(0, -chartHeight)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@@ -284,7 +284,7 @@ const chartLines = (
|
||||
strokeStyle: "dotted",
|
||||
width: chartWidth,
|
||||
opacity: GRID_OPACITY,
|
||||
points: [point(0, 0), point(chartWidth, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@@ -441,7 +441,7 @@ const chartTypeLine = (
|
||||
height: cy,
|
||||
strokeStyle: "dotted",
|
||||
opacity: GRID_OPACITY,
|
||||
points: [point(0, 0), point(0, cy)],
|
||||
points: [pointFrom(0, 0), pointFrom(0, cy)],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { trackEvent } from "../analytics";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isElbowArrow,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
@@ -127,6 +128,11 @@ export const SelectedShapeActions = ({
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
const showCropEditorAction =
|
||||
!appState.croppingElementId &&
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
<div>
|
||||
@@ -245,6 +251,7 @@ export const SelectedShapeActions = ({
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showCropEditorAction && renderAction("cropEditor")}
|
||||
{showLineEditorAction && renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
actionToggleElementLock,
|
||||
actionToggleLinearEditor,
|
||||
actionToggleObjectsSnapMode,
|
||||
actionToggleCropEditor,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
@@ -185,6 +186,7 @@ import type {
|
||||
MagicGenerationData,
|
||||
ExcalidrawNonSelectionElement,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
@@ -259,6 +261,7 @@ import type {
|
||||
ElementsPendingErasure,
|
||||
GenerateDiagramToCode,
|
||||
NullableGridSize,
|
||||
Offsets,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
@@ -286,6 +289,7 @@ import {
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
@@ -434,14 +438,27 @@ 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";
|
||||
import {
|
||||
clamp,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
vector,
|
||||
pointRotateRads,
|
||||
vectorScale,
|
||||
vectorFromPoint,
|
||||
vectorSubtract,
|
||||
vectorDot,
|
||||
vectorNormalize,
|
||||
} from "../../math";
|
||||
import { cropElement } from "../element/cropElement";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -548,6 +565,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"];
|
||||
@@ -555,7 +573,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;
|
||||
};
|
||||
@@ -584,6 +602,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
|
||||
null;
|
||||
lastPointerMoveEvent: PointerEvent | null = null;
|
||||
lastPointerMoveCoords: { x: number; y: number } | null = null;
|
||||
lastViewportPosition = { x: 0, y: 0 };
|
||||
|
||||
animationFrameHandler = new AnimationFrameHandler();
|
||||
@@ -682,6 +701,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();
|
||||
@@ -1480,6 +1500,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();
|
||||
|
||||
@@ -2295,6 +2316,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
|
||||
@@ -2360,6 +2384,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 =
|
||||
@@ -3091,7 +3125,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;
|
||||
@@ -3215,6 +3287,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(newElements, {
|
||||
fitToContent: true,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3527,7 +3600,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||
opts?:
|
||||
opts?: (
|
||||
| {
|
||||
fitToContent?: boolean;
|
||||
fitToViewport?: never;
|
||||
@@ -3544,7 +3617,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewportZoomFactor?: number;
|
||||
animate?: boolean;
|
||||
duration?: number;
|
||||
},
|
||||
}
|
||||
) & {
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
canvasOffsets?: Offsets;
|
||||
},
|
||||
) => {
|
||||
this.cancelInProgressAnimation?.();
|
||||
|
||||
@@ -3557,10 +3635,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;
|
||||
@@ -3674,15 +3755,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();
|
||||
@@ -3796,40 +3869,42 @@ 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
|
||||
?.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,
|
||||
);
|
||||
const sidebarRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".sidebar")
|
||||
?.getBoundingClientRect();
|
||||
const propertiesPanelRect = this.excalidrawContainerRef?.current
|
||||
?.querySelector(".App-menu__left")
|
||||
?.getBoundingClientRect();
|
||||
|
||||
const PADDING = 16;
|
||||
|
||||
return getLanguage().rtl
|
||||
? {
|
||||
top: toolbarBottom,
|
||||
right: propertiesPanelWidth,
|
||||
bottom: 0,
|
||||
left: sidebarWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right:
|
||||
Math.max(
|
||||
this.state.width -
|
||||
(propertiesPanelRect?.left ?? this.state.width),
|
||||
0,
|
||||
) + PADDING,
|
||||
bottom: PADDING,
|
||||
left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
|
||||
}
|
||||
: {
|
||||
top: toolbarBottom,
|
||||
right: sidebarWidth,
|
||||
bottom: 0,
|
||||
left: propertiesPanelWidth,
|
||||
top: toolbarBottom + PADDING,
|
||||
right: Math.max(
|
||||
this.state.width -
|
||||
(sidebarRect?.left ?? this.state.width) +
|
||||
PADDING,
|
||||
0,
|
||||
),
|
||||
bottom: PADDING,
|
||||
left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3862,6 +3937,28 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (!isInputLike(event.target)) {
|
||||
if (
|
||||
(event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
this.state.croppingElementId
|
||||
) {
|
||||
this.finishImageCropping();
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
isImageElement(selectedElements[0]) &&
|
||||
event.key === KEYS.ENTER
|
||||
) {
|
||||
this.startImageCropping(selectedElements[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === KEYS.ESCAPE &&
|
||||
this.flowChartCreator.isCreatingChart
|
||||
@@ -3914,7 +4011,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
fitToContent: true,
|
||||
viewportZoomFactor: 0.8,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3970,6 +4067,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(nextNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4402,6 +4500,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scrollToContent(firstNode, {
|
||||
animate: true,
|
||||
duration: 300,
|
||||
canvasOffsets: this.getEditorUIOffsets(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4844,10 +4943,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const selectionShape = getSelectionBoxShape(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.getElementHitThreshold(),
|
||||
isImageElement(element) ? 0 : this.getElementHitThreshold(),
|
||||
);
|
||||
|
||||
return isPointInShape(point(x, y), selectionShape);
|
||||
return isPointInShape(pointFrom(x, y), selectionShape);
|
||||
}
|
||||
|
||||
// take bound text element into consideration for hit collision as well
|
||||
@@ -5073,6 +5172,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private startImageCropping = (image: ExcalidrawImageElement) => {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
croppingElementId: image.id,
|
||||
});
|
||||
};
|
||||
|
||||
private finishImageCropping = () => {
|
||||
if (this.state.croppingElementId) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
croppingElementId: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleCanvasDoubleClick = (
|
||||
event: React.MouseEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
@@ -5104,6 +5219,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
this.startImageCropping(selectedElements[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
resetCursor(this.interactiveCanvas);
|
||||
|
||||
let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
@@ -5206,7 +5326,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
this.device.editor.isMobile,
|
||||
)
|
||||
);
|
||||
@@ -5218,11 +5338,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isTouchScreen: boolean,
|
||||
) => {
|
||||
const draggedDistance = pointDistance(
|
||||
point(
|
||||
pointFrom(
|
||||
this.lastPointerDownEvent!.clientX,
|
||||
this.lastPointerDownEvent!.clientY,
|
||||
),
|
||||
point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
|
||||
pointFrom(
|
||||
this.lastPointerUpEvent!.clientX,
|
||||
this.lastPointerUpEvent!.clientY,
|
||||
),
|
||||
);
|
||||
if (
|
||||
!this.hitLinkElement ||
|
||||
@@ -5241,7 +5364,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
point(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||
pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
||||
@@ -5252,7 +5375,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
elementsMap,
|
||||
this.state,
|
||||
point(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||
pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
|
||||
this.device.editor.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
@@ -5502,7 +5625,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// threshold, add a point
|
||||
if (
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastPoint,
|
||||
) >= LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@@ -5511,7 +5634,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points,
|
||||
point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
|
||||
],
|
||||
},
|
||||
false,
|
||||
@@ -5525,7 +5648,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
points.length > 2 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
point(scenePointerX - rx, scenePointerY - ry),
|
||||
pointFrom(scenePointerX - rx, scenePointerY - ry),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@@ -5573,7 +5696,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
[
|
||||
...points.slice(0, -1),
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
@@ -5592,7 +5715,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
),
|
||||
@@ -5821,8 +5944,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
const distance = pointDistance(
|
||||
point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
const threshold = this.getElementHitThreshold();
|
||||
const p = { ...pointerDownState.lastCoords };
|
||||
@@ -5969,6 +6092,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
|
||||
@@ -6324,7 +6457,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.hitLinkElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
this.state,
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
)
|
||||
) {
|
||||
this.handleEmbeddableCenterClick(this.hitLinkElement);
|
||||
@@ -6397,8 +6530,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
isPanning = true;
|
||||
|
||||
// due to event.preventDefault below, container wouldn't get focus
|
||||
// automatically
|
||||
this.focusContainer();
|
||||
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
if (!this.state.editingTextElement) {
|
||||
// preventing defualt while text editing messes with cursor/focus
|
||||
// necessary to prevent browser from scrolling the page if excalidraw
|
||||
// not full-page #4489
|
||||
//
|
||||
// as such, the above is broken when panning canvas while in wysiwyg
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
@@ -6652,11 +6793,24 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.device,
|
||||
);
|
||||
if (elementWithTransformHandleType != null) {
|
||||
this.setState({
|
||||
resizingElement: elementWithTransformHandleType.element,
|
||||
});
|
||||
pointerDownState.resize.handleType =
|
||||
elementWithTransformHandleType.transformHandleType;
|
||||
if (
|
||||
elementWithTransformHandleType.transformHandleType === "rotation"
|
||||
) {
|
||||
this.setState({
|
||||
resizingElement: elementWithTransformHandleType.element,
|
||||
});
|
||||
pointerDownState.resize.handleType =
|
||||
elementWithTransformHandleType.transformHandleType;
|
||||
} else if (this.state.croppingElementId) {
|
||||
pointerDownState.resize.handleType =
|
||||
elementWithTransformHandleType.transformHandleType;
|
||||
} else {
|
||||
this.setState({
|
||||
resizingElement: elementWithTransformHandleType.element,
|
||||
});
|
||||
pointerDownState.resize.handleType =
|
||||
elementWithTransformHandleType.transformHandleType;
|
||||
}
|
||||
}
|
||||
} else if (selectedElements.length > 1) {
|
||||
pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
|
||||
@@ -6723,6 +6877,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
|
||||
if (
|
||||
this.state.croppingElementId &&
|
||||
pointerDownState.hit.element?.id !== this.state.croppingElementId
|
||||
) {
|
||||
this.finishImageCropping();
|
||||
}
|
||||
|
||||
if (pointerDownState.hit.element) {
|
||||
// Early return if pointer is hitting link icon
|
||||
const hitLinkElement = this.getElementLinkAtPosition(
|
||||
@@ -7007,7 +7168,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
simulatePressure,
|
||||
locked: false,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
points: [point<LocalPoint>(0, 0)],
|
||||
points: [pointFrom<LocalPoint>(0, 0)],
|
||||
pressures: simulatePressure ? [] : [event.pressure],
|
||||
});
|
||||
|
||||
@@ -7216,7 +7377,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
multiElement.points.length > 1 &&
|
||||
lastCommittedPoint &&
|
||||
pointDistance(
|
||||
point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
|
||||
pointFrom(
|
||||
pointerDownState.origin.x - rx,
|
||||
pointerDownState.origin.y - ry,
|
||||
),
|
||||
lastCommittedPoint,
|
||||
) < LINE_CONFIRM_THRESHOLD
|
||||
) {
|
||||
@@ -7318,7 +7482,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
});
|
||||
mutateElement(element, {
|
||||
points: [...element.points, point<LocalPoint>(0, 0)],
|
||||
points: [...element.points, pointFrom<LocalPoint>(0, 0)],
|
||||
});
|
||||
const boundElement = getHoveredElementForBinding(
|
||||
pointerDownState.origin,
|
||||
@@ -7521,6 +7685,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState: PointerDownState,
|
||||
) {
|
||||
return withBatchedUpdatesThrottled((event: PointerEvent) => {
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
||||
const lastPointerCoords =
|
||||
this.lastPointerMoveCoords ?? pointerDownState.origin;
|
||||
this.lastPointerMoveCoords = pointerCoords;
|
||||
|
||||
// We need to initialize dragOffsetXY only after we've updated
|
||||
// `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
|
||||
// event handler should hopefully ensure we're already working with
|
||||
@@ -7543,8 +7712,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
|
||||
|
||||
if (isEraserActive(this.state)) {
|
||||
this.handleEraser(event, pointerDownState, pointerCoords);
|
||||
return;
|
||||
@@ -7571,8 +7738,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
if (
|
||||
pointDistance(
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
point(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
|
||||
) < DRAGGING_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
@@ -7581,6 +7748,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (pointerDownState.resize.isResizing) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
if (this.maybeHandleCrop(pointerDownState, event)) {
|
||||
return true;
|
||||
}
|
||||
if (this.maybeHandleResize(pointerDownState, event)) {
|
||||
return true;
|
||||
}
|
||||
@@ -7754,6 +7924,96 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
// #region move crop region
|
||||
if (this.state.croppingElementId) {
|
||||
const croppingElement = this.scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(this.state.croppingElementId);
|
||||
|
||||
if (
|
||||
croppingElement &&
|
||||
isImageElement(croppingElement) &&
|
||||
croppingElement.crop !== null &&
|
||||
pointerDownState.hit.element === croppingElement
|
||||
) {
|
||||
const crop = croppingElement.crop;
|
||||
const image =
|
||||
isInitializedImageElement(croppingElement) &&
|
||||
this.imageCache.get(croppingElement.fileId)?.image;
|
||||
|
||||
if (image && !(image instanceof Promise)) {
|
||||
const instantDragOffset = vectorScale(
|
||||
vector(
|
||||
pointerCoords.x - lastPointerCoords.x,
|
||||
pointerCoords.y - lastPointerCoords.y,
|
||||
),
|
||||
Math.max(this.state.zoom.value, 2),
|
||||
);
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
croppingElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const topLeft = vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
croppingElement.angle,
|
||||
),
|
||||
);
|
||||
const topRight = vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
croppingElement.angle,
|
||||
),
|
||||
);
|
||||
const bottomLeft = vectorFromPoint(
|
||||
pointRotateRads(
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
croppingElement.angle,
|
||||
),
|
||||
);
|
||||
const topEdge = vectorNormalize(
|
||||
vectorSubtract(topRight, topLeft),
|
||||
);
|
||||
const leftEdge = vectorNormalize(
|
||||
vectorSubtract(bottomLeft, topLeft),
|
||||
);
|
||||
|
||||
// project instantDrafOffset onto leftEdge and topEdge to decompose
|
||||
const offsetVector = vector(
|
||||
vectorDot(instantDragOffset, topEdge),
|
||||
vectorDot(instantDragOffset, leftEdge),
|
||||
);
|
||||
|
||||
const nextCrop = {
|
||||
...crop,
|
||||
x: clamp(
|
||||
crop.x -
|
||||
offsetVector[0] * Math.sign(croppingElement.scale[0]),
|
||||
0,
|
||||
image.naturalWidth - crop.width,
|
||||
),
|
||||
y: clamp(
|
||||
crop.y -
|
||||
offsetVector[1] * Math.sign(croppingElement.scale[1]),
|
||||
0,
|
||||
image.naturalHeight - crop.height,
|
||||
),
|
||||
};
|
||||
|
||||
mutateElement(croppingElement, {
|
||||
crop: nextCrop,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snap cache *must* be synchronously popuplated before initial drag,
|
||||
// otherwise the first drag even will not snap, causing a jump before
|
||||
// it snaps to its position if previously snapped already.
|
||||
@@ -7887,6 +8147,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.maybeCacheVisibleGaps(event, selectedElements, true);
|
||||
this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -7921,7 +8182,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
},
|
||||
false,
|
||||
@@ -7950,7 +8211,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -7958,7 +8219,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElbowArrow(
|
||||
newElement,
|
||||
elementsMap,
|
||||
[...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
[...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
vector(0, 0),
|
||||
undefined,
|
||||
{
|
||||
@@ -7970,7 +8231,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
|
||||
points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -8135,15 +8396,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const {
|
||||
newElement,
|
||||
resizingElement,
|
||||
croppingElementId,
|
||||
multiElement,
|
||||
activeTool,
|
||||
isResizing,
|
||||
isRotating,
|
||||
isCropping,
|
||||
} = this.state;
|
||||
|
||||
this.setState((prevState) => ({
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
isCropping: false,
|
||||
resizingElement: null,
|
||||
selectionElement: null,
|
||||
frameToHighlight: null,
|
||||
@@ -8153,6 +8417,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
originSnapOffset: null,
|
||||
}));
|
||||
|
||||
this.lastPointerMoveCoords = null;
|
||||
|
||||
SnapCache.setReferenceSnapPoints(null);
|
||||
SnapCache.setVisibleGaps(null);
|
||||
|
||||
@@ -8279,9 +8545,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: [...newElement.pressures, childEvent.pressure];
|
||||
|
||||
mutateElement(newElement, {
|
||||
points: [...points, point<LocalPoint>(dx, dy)],
|
||||
points: [...points, pointFrom<LocalPoint>(dx, dy)],
|
||||
pressures,
|
||||
lastCommittedPoint: point<LocalPoint>(dx, dy),
|
||||
lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
|
||||
});
|
||||
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
@@ -8328,7 +8594,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(newElement, {
|
||||
points: [
|
||||
...newElement.points,
|
||||
point<LocalPoint>(
|
||||
pointFrom<LocalPoint>(
|
||||
pointerCoords.x - newElement.x,
|
||||
pointerCoords.y - newElement.y,
|
||||
),
|
||||
@@ -8635,6 +8901,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
// click outside the cropping region to exit
|
||||
if (
|
||||
// not in the cropping mode at all
|
||||
!croppingElementId ||
|
||||
// in the cropping mode
|
||||
(croppingElementId &&
|
||||
// not cropping and no hit element
|
||||
((!hitElement && !isCropping) ||
|
||||
// hitting something else
|
||||
(hitElement && hitElement.id !== croppingElementId)))
|
||||
) {
|
||||
this.finishImageCropping();
|
||||
}
|
||||
|
||||
const pointerStart = this.lastPointerDownEvent;
|
||||
const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
|
||||
|
||||
@@ -8642,8 +8922,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.eraserTrail.endPath();
|
||||
|
||||
const draggedDistance = pointDistance(
|
||||
point(pointerStart.clientX, pointerStart.clientY),
|
||||
point(pointerEnd.clientX, pointerEnd.clientY),
|
||||
pointFrom(pointerStart.clientX, pointerStart.clientY),
|
||||
pointFrom(pointerEnd.clientX, pointerEnd.clientY),
|
||||
);
|
||||
|
||||
if (draggedDistance === 0) {
|
||||
@@ -8890,7 +9170,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
|
||||
if (
|
||||
pointerDownState.drag.hasOccurred ||
|
||||
isResizing ||
|
||||
isRotating ||
|
||||
isCropping
|
||||
) {
|
||||
// We only allow binding via linear elements, specifically via dragging
|
||||
// the endpoints ("start" or "end").
|
||||
const linearElements = this.scene
|
||||
@@ -9104,7 +9389,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
/**
|
||||
* inserts image into elements array and rerenders
|
||||
*/
|
||||
private insertImageElement = async (
|
||||
insertImageElement = async (
|
||||
imageElement: ExcalidrawImageElement,
|
||||
imageFile: File,
|
||||
showCursorImagePreview?: boolean,
|
||||
@@ -9257,7 +9542,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private initializeImageDimensions = (
|
||||
initializeImageDimensions = (
|
||||
imageElement: ExcalidrawImageElement,
|
||||
forceNaturalSize = false,
|
||||
) => {
|
||||
@@ -9305,7 +9590,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const x = imageElement.x + imageElement.width / 2 - width / 2;
|
||||
const y = imageElement.y + imageElement.height / 2 - height / 2;
|
||||
|
||||
mutateElement(imageElement, { x, y, width, height });
|
||||
mutateElement(imageElement, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
crop: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9844,6 +10135,83 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private maybeHandleCrop = (
|
||||
pointerDownState: PointerDownState,
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
): boolean => {
|
||||
// to crop, we must already be in the cropping mode, where croppingElement has been set
|
||||
if (!this.state.croppingElementId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transformHandleType = pointerDownState.resize.handleType;
|
||||
const pointerCoords = pointerDownState.lastCoords;
|
||||
const [x, y] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.resize.offset.x,
|
||||
pointerCoords.y - pointerDownState.resize.offset.y,
|
||||
this.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
const croppingElement = this.scene
|
||||
.getNonDeletedElementsMap()
|
||||
.get(this.state.croppingElementId);
|
||||
|
||||
if (
|
||||
transformHandleType &&
|
||||
croppingElement &&
|
||||
isImageElement(croppingElement)
|
||||
) {
|
||||
const croppingAtStateStart = pointerDownState.originalElements.get(
|
||||
croppingElement.id,
|
||||
);
|
||||
|
||||
const image =
|
||||
isInitializedImageElement(croppingElement) &&
|
||||
this.imageCache.get(croppingElement.fileId)?.image;
|
||||
|
||||
if (
|
||||
croppingAtStateStart &&
|
||||
isImageElement(croppingAtStateStart) &&
|
||||
image &&
|
||||
!(image instanceof Promise)
|
||||
) {
|
||||
mutateElement(
|
||||
croppingElement,
|
||||
cropElement(
|
||||
croppingElement,
|
||||
transformHandleType,
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
x,
|
||||
y,
|
||||
event.shiftKey
|
||||
? croppingAtStateStart.width / croppingAtStateStart.height
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
updateBoundElements(
|
||||
croppingElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
{
|
||||
oldSize: {
|
||||
width: croppingElement.width,
|
||||
height: croppingElement.height,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
this.setState({
|
||||
isCropping: transformHandleType && transformHandleType !== "rotation",
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private maybeHandleResize = (
|
||||
pointerDownState: PointerDownState,
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
@@ -9860,7 +10228,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// Frames cannot be rotated.
|
||||
(selectedFrames.length > 0 && transformHandleType === "rotation") ||
|
||||
// Elbow arrows cannot be transformed (resized or rotated).
|
||||
(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
|
||||
(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
|
||||
// Do not resize when in crop mode
|
||||
this.state.croppingElementId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -10035,6 +10405,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionSelectAllElementsInFrame,
|
||||
actionRemoveAllElementsFromFrame,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionToggleCropEditor,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
...options,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionCopyStyles,
|
||||
|
||||
@@ -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";
|
||||
@@ -275,6 +279,7 @@ function CommandPaletteInner({
|
||||
actionManager.actions.increaseFontSize,
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionManager.actions.cropEditor,
|
||||
actionLink,
|
||||
].map((action: Action) =>
|
||||
actionToCommand(
|
||||
@@ -382,6 +387,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>
|
||||
|
||||
@@ -222,6 +222,16 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropStart")}
|
||||
shortcuts={[t("helpDialog.doubleClick"), getShortcutKey("Enter")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.cropFinish")}
|
||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
@@ -288,6 +298,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;
|
||||
}
|
||||
@@ -91,6 +100,14 @@ const getHints = ({
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (appState.croppingElementId) {
|
||||
return t("hints.leaveCropEditor");
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
return t("hints.enterCropEditor");
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.selectionElement &&
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -20,7 +20,7 @@ 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";
|
||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
@@ -182,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
@@ -287,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
|
||||
@@ -13,7 +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";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
@@ -44,8 +44,8 @@ const moveElements = (
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -97,8 +97,8 @@ const moveGroupTo = (
|
||||
];
|
||||
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(latestElement.x, latestElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(latestElement.x, latestElement.y),
|
||||
pointFrom(cx, cy),
|
||||
latestElement.angle,
|
||||
);
|
||||
|
||||
@@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -241,8 +241,8 @@ const MultiPosition = ({
|
||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(el.x, el.y),
|
||||
point(cx, cy),
|
||||
pointFrom(el.x, el.y),
|
||||
pointFrom(cx, cy),
|
||||
el.angle,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
@@ -33,8 +33,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(origElement.x, origElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(origElement.x, origElement.y),
|
||||
pointFrom(cx, cy),
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
@@ -93,8 +93,8 @@ const Position = ({
|
||||
appState,
|
||||
}: PositionProps) => {
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(element.x, element.y),
|
||||
point(element.x + element.width / 2, element.y + element.height / 2),
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
element.angle,
|
||||
);
|
||||
const value =
|
||||
|
||||
@@ -25,7 +25,7 @@ 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";
|
||||
import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
@@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
|
||||
let [newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
@@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
|
||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||
|
||||
[newTopLeftX, newTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||
@@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||
@@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||
@@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
[currentTopLeftX, currentTopLeftY] = pointRotateRads(
|
||||
point(rectangle.x, rectangle.y),
|
||||
point(cx, cy),
|
||||
pointFrom(rectangle.x, rectangle.y),
|
||||
pointFrom(cx, cy),
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
updateBoundElements,
|
||||
@@ -231,8 +231,8 @@ export const moveElement = (
|
||||
originalElement.y + originalElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(originalElement.x, originalElement.y),
|
||||
point(cx, cy),
|
||||
pointFrom(originalElement.x, originalElement.y),
|
||||
pointFrom(cx, cy),
|
||||
originalElement.angle,
|
||||
);
|
||||
|
||||
@@ -240,8 +240,8 @@ export const moveElement = (
|
||||
const changeInY = newTopLeftY - topLeftY;
|
||||
|
||||
const [x, y] = pointRotateRads(
|
||||
point(newTopLeftX, newTopLeftY),
|
||||
point(cx + changeInX, cy + changeInY),
|
||||
pointFrom(newTopLeftX, newTopLeftY),
|
||||
pointFrom(cx + changeInX, cy + changeInY),
|
||||
-originalElement.angle as Radians,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,9 @@ const getRelevantAppStateProps = (
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingTextElement: appState.editingTextElement,
|
||||
isCropping: appState.isCropping,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -107,6 +107,7 @@ const getRelevantAppStateProps = (
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
currentHoveredFontFamily: appState.currentHoveredFontFamily,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
||||
@@ -36,7 +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";
|
||||
import { pointFrom, type GlobalPoint } from "../../../math";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -181,7 +181,7 @@ export const Hyperlink = ({
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
point(event.clientX, event.clientY),
|
||||
pointFrom(event.clientX, event.clientY),
|
||||
) as boolean;
|
||||
if (shouldHide) {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GlobalPoint, Radians } from "../../../math";
|
||||
import { point, pointRotateRads } from "../../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../../math";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import type { Bounds } from "../../element/bounds";
|
||||
import { getElementAbsoluteCoords } from "../../element/bounds";
|
||||
@@ -35,8 +35,8 @@ export const getLinkHandleFromCoords = (
|
||||
const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
|
||||
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(x + linkWidth / 2, y + linkHeight / 2),
|
||||
point(centerX, centerY),
|
||||
pointFrom(x + linkWidth / 2, y + linkHeight / 2),
|
||||
pointFrom(centerX, centerY),
|
||||
angle,
|
||||
);
|
||||
return [
|
||||
@@ -85,5 +85,10 @@ export const isPointHittingLink = (
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
|
||||
return isPointHittingLinkIcon(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
pointFrom(x, y),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2139,3 +2139,20 @@ 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,
|
||||
);
|
||||
|
||||
export const cropIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M8 5v10a1 1 0 0 0 1 1h10" />
|
||||
<path d="M5 8h10a1 1 0 0 1 1 1v10" />
|
||||
</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();
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ENV = {
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
|
||||
};
|
||||
|
||||
export const LIBRARY_SIDEBAR_TAB = "library";
|
||||
export const CANVAS_SEARCH_TAB = "search";
|
||||
|
||||
export const DEFAULT_SIDEBAR = {
|
||||
name: "default",
|
||||
|
||||
@@ -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,
|
||||
@@ -55,7 +57,7 @@ import {
|
||||
getNormalizedZoom,
|
||||
} from "../scene";
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import { isFiniteNumber, point } from "../../math";
|
||||
import { isFiniteNumber, pointFrom } from "../../math";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -101,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;
|
||||
}
|
||||
@@ -110,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]),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -254,6 +258,7 @@ const restoreElement = (
|
||||
status: element.status || "pending",
|
||||
fileId: element.fileId,
|
||||
scale: element.scale || [1, 1],
|
||||
crop: element.crop ?? null,
|
||||
});
|
||||
case "line":
|
||||
// @ts-ignore LEGACY type
|
||||
@@ -264,7 +269,7 @@ const restoreElement = (
|
||||
let y = element.y;
|
||||
let points = // migrate old arrow model to new one
|
||||
!Array.isArray(element.points) || element.points.length < 2
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
@@ -292,7 +297,7 @@ const restoreElement = (
|
||||
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
|
||||
? [point(0, 0), point(element.width, element.height)]
|
||||
? [pointFrom(0, 0), pointFrom(element.width, element.height)]
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
|
||||
@@ -2,7 +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";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -309,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"],
|
||||
@@ -352,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"],
|
||||
@@ -388,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -912,7 +917,7 @@ describe("Test Transform", () => {
|
||||
x: 111.262,
|
||||
y: 57,
|
||||
strokeWidth: 2,
|
||||
points: [point(0, 0), point(272.985, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(272.985, 0)],
|
||||
label: {
|
||||
text: "How are you?",
|
||||
fontSize: 20,
|
||||
@@ -935,7 +940,7 @@ describe("Test Transform", () => {
|
||||
x: 77.017,
|
||||
y: 79,
|
||||
strokeWidth: 2,
|
||||
points: [point(0, 0)],
|
||||
points: [pointFrom(0, 0)],
|
||||
label: {
|
||||
text: "Friendship",
|
||||
fontSize: 20,
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
} from "../utils";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
@@ -53,7 +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";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
@@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [point(0, 0), point(width, height)],
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
});
|
||||
|
||||
@@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [point(0, 0), point(width, height)],
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
type: "arrow",
|
||||
});
|
||||
@@ -717,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") {
|
||||
@@ -764,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();
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isRectangularElement,
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
import type { LocalPoint, Radians } from "../../math";
|
||||
import {
|
||||
lineSegment,
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
vectorFromPoint,
|
||||
@@ -719,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
|
||||
return vectorToHeading(
|
||||
vectorFromPoint(
|
||||
p,
|
||||
point<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
),
|
||||
@@ -765,15 +766,15 @@ export const bindPointToSnapToElementOutline = (
|
||||
const intersections = [
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
point(p[0], p[1] - 2 * bindableElement.height),
|
||||
point(p[0], p[1] + 2 * bindableElement.height),
|
||||
pointFrom(p[0], p[1] - 2 * bindableElement.height),
|
||||
pointFrom(p[0], p[1] + 2 * bindableElement.height),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
) ?? []),
|
||||
...(intersectElementWithLine(
|
||||
bindableElement,
|
||||
point(p[0] - 2 * bindableElement.width, p[1]),
|
||||
point(p[0] + 2 * bindableElement.width, p[1]),
|
||||
pointFrom(p[0] - 2 * bindableElement.width, p[1]),
|
||||
pointFrom(p[0] + 2 * bindableElement.width, p[1]),
|
||||
FIXED_BINDING_DISTANCE,
|
||||
elementsMap,
|
||||
) ?? []),
|
||||
@@ -797,7 +798,7 @@ export const bindPointToSnapToElementOutline = (
|
||||
isVertical
|
||||
? Math.abs(p[1] - i[1]) < 0.1
|
||||
: Math.abs(p[0] - i[0]) < 0.1,
|
||||
)[0] ?? point;
|
||||
)[0] ?? p;
|
||||
}
|
||||
|
||||
return p;
|
||||
@@ -814,25 +815,25 @@ const headingToMidBindPoint = (
|
||||
switch (true) {
|
||||
case compareHeading(heading, HEADING_UP):
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||
pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_RIGHT):
|
||||
return pointRotateRads(
|
||||
point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||
pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
case compareHeading(heading, HEADING_DOWN):
|
||||
return pointRotateRads(
|
||||
point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||
pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
default:
|
||||
return pointRotateRads(
|
||||
point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||
pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
|
||||
center,
|
||||
bindableElement.angle,
|
||||
);
|
||||
@@ -843,7 +844,7 @@ export const avoidRectangularCorner = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): GlobalPoint => {
|
||||
const center = point<GlobalPoint>(
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
@@ -853,13 +854,13 @@ export const avoidRectangularCorner = (
|
||||
// Top left
|
||||
if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads<GlobalPoint>(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -870,13 +871,16 @@ export const avoidRectangularCorner = (
|
||||
// Bottom left
|
||||
if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
|
||||
return pointRotateRads(
|
||||
point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
|
||||
pointFrom(
|
||||
element.x,
|
||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||
pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -890,7 +894,7 @@ export const avoidRectangularCorner = (
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width,
|
||||
element.y + element.height + FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
@@ -899,7 +903,7 @@ export const avoidRectangularCorner = (
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width + FIXED_BINDING_DISTANCE,
|
||||
element.y + element.height,
|
||||
),
|
||||
@@ -916,13 +920,16 @@ export const avoidRectangularCorner = (
|
||||
element.width + FIXED_BINDING_DISTANCE
|
||||
) {
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(
|
||||
element.x + element.width,
|
||||
element.y - FIXED_BINDING_DISTANCE,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
return pointRotateRads(
|
||||
point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||
pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -937,7 +944,10 @@ export const snapToMid = (
|
||||
tolerance: number = 0.05,
|
||||
): GlobalPoint => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
|
||||
const center = pointFrom<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
|
||||
@@ -952,7 +962,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// LEFT
|
||||
return pointRotateRads(
|
||||
point(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -963,7 +973,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// TOP
|
||||
return pointRotateRads(
|
||||
point(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -974,7 +984,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// RIGHT
|
||||
return pointRotateRads(
|
||||
point(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -985,7 +995,7 @@ export const snapToMid = (
|
||||
) {
|
||||
// DOWN
|
||||
return pointRotateRads(
|
||||
point(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
|
||||
center,
|
||||
angle,
|
||||
);
|
||||
@@ -1013,7 +1023,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(
|
||||
@@ -1022,11 +1032,11 @@ const updateBoundPoint = (
|
||||
startOrEnd === "startBinding" ? "start" : "end",
|
||||
elementsMap,
|
||||
).fixedPoint;
|
||||
const globalMidPoint = point<GlobalPoint>(
|
||||
const globalMidPoint = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + bindableElement.width / 2,
|
||||
bindableElement.y + bindableElement.height / 2,
|
||||
);
|
||||
const global = point<GlobalPoint>(
|
||||
const global = pointFrom<GlobalPoint>(
|
||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||
);
|
||||
@@ -1117,7 +1127,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
);
|
||||
const globalMidPoint = point(
|
||||
const globalMidPoint = pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
@@ -1336,9 +1346,9 @@ export const bindingBorderTest = (
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(point(x, y), shape, threshold) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(point(x, y), aabbForElement(element)))
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2196,11 +2206,11 @@ export const getGlobalFixedPointForBindableElement = (
|
||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||
|
||||
return pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.width * fixedX,
|
||||
element.y + element.height * fixedY,
|
||||
),
|
||||
point<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
@@ -2228,7 +2238,7 @@ const getGlobalFixedPoints = (
|
||||
arrow.startBinding.fixedPoint,
|
||||
startElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: point<GlobalPoint>(
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[0][0],
|
||||
arrow.y + arrow.points[0][1],
|
||||
);
|
||||
@@ -2238,7 +2248,7 @@ const getGlobalFixedPoints = (
|
||||
arrow.endBinding.fixedPoint,
|
||||
endElement as ExcalidrawBindableElement,
|
||||
)
|
||||
: point<GlobalPoint>(
|
||||
: pointFrom<GlobalPoint>(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
arrow.y + arrow.points[arrow.points.length - 1][1],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
@@ -125,9 +125,9 @@ describe("getElementBounds", () => {
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
point<LocalPoint>(0, 0),
|
||||
point<LocalPoint>(67.33984375, 92.48828125),
|
||||
point<LocalPoint>(-102.7890625, 52.15625),
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
@@ -113,8 +113,8 @@ export class ElementBounds {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
pointRotateRads(
|
||||
point(x, y),
|
||||
point(cx - element.x, cy - element.y),
|
||||
pointFrom(x, y),
|
||||
pointFrom(cx - element.x, cy - element.y),
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
@@ -130,23 +130,23 @@ export class ElementBounds {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(cx, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
point(cx, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
point(x1, cy),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
point(x2, cy),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
@@ -164,23 +164,23 @@ export class ElementBounds {
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = pointRotateRads(
|
||||
point(x1, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x12, y12] = pointRotateRads(
|
||||
point(x1, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x22, y22] = pointRotateRads(
|
||||
point(x2, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const [x21, y21] = pointRotateRads(
|
||||
point(x2, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
@@ -255,7 +255,7 @@ export const getElementLineSegments = (
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const center: GlobalPoint = point(cx, cy);
|
||||
const center: GlobalPoint = pointFrom(cx, cy);
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
@@ -266,7 +266,7 @@ export const getElementLineSegments = (
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
),
|
||||
@@ -274,7 +274,7 @@ export const getElementLineSegments = (
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
),
|
||||
@@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (p: GlobalPoint) => GlobalPoint,
|
||||
): Bounds => {
|
||||
let currentP: GlobalPoint = point(0, 0);
|
||||
let currentP: GlobalPoint = pointFrom(0, 0);
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
@@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
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 = pointFrom<GlobalPoint>(data[0], data[1]);
|
||||
const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
|
||||
const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
|
||||
|
||||
const p1 = transformXY ? transformXY(_p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(_p2) : _p2;
|
||||
@@ -591,21 +591,21 @@ export const getArrowheadPoints = (
|
||||
|
||||
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]);
|
||||
const p3 = pointFrom(data[4], data[5]);
|
||||
const p2 = pointFrom(data[2], data[3]);
|
||||
const p1 = pointFrom(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 = pointFrom(0, 0);
|
||||
if (prevOp.op === "move") {
|
||||
const p = pointFromArray(prevOp.data);
|
||||
invariant(p != null, "Op data is not a point");
|
||||
p0 = p;
|
||||
} else if (prevOp.op === "bcurveTo") {
|
||||
p0 = point(prevOp.data[4], prevOp.data[5]);
|
||||
p0 = pointFrom(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
|
||||
@@ -671,13 +671,13 @@ export const getArrowheadPoints = (
|
||||
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
point(xs, ys),
|
||||
point(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
point(xs, ys),
|
||||
point(x2, y2),
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@@ -690,8 +690,8 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 + minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
);
|
||||
} else {
|
||||
@@ -701,8 +701,8 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
point(x2 - minSize * 2, y2),
|
||||
point(x2, y2),
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
);
|
||||
}
|
||||
@@ -746,8 +746,8 @@ const getLinearElementRotatedBounds = (
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = pointRotateRads(
|
||||
point(element.x + pointX, element.y + pointY),
|
||||
point(cx, cy),
|
||||
pointFrom(element.x + pointX, element.y + pointY),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -775,8 +775,8 @@ const getLinearElementRotatedBounds = (
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
pointRotateRads<GlobalPoint>(
|
||||
point(element.x + x, element.y + y),
|
||||
point(cx, cy),
|
||||
pointFrom(element.x + x, element.y + y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
@@ -931,8 +931,8 @@ export const getClosestElementBounds = (
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const distance = pointDistance(
|
||||
point((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
point(from.x, from.y),
|
||||
pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
|
||||
pointFrom(from.x, from.y),
|
||||
);
|
||||
|
||||
if (distance < minDistance) {
|
||||
@@ -990,7 +990,7 @@ export const getVisibleSceneBounds = ({
|
||||
};
|
||||
|
||||
export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
point(
|
||||
pointFrom(
|
||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./typeChecks";
|
||||
import { getBoundTextShape, isPathALoop } from "../shapes";
|
||||
import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
|
||||
import { isPointWithinBounds, point } from "../../math";
|
||||
import { isPointWithinBounds, pointFrom } from "../../math";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
if (element.type === "arrow") {
|
||||
@@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
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(point(x, y), shape) ||
|
||||
isPointOnShape(point(x, y), shape, threshold)
|
||||
: isPointOnShape(point(x, y), shape, threshold);
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(point(x, y), {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
@@ -89,7 +89,11 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
@@ -115,5 +119,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(point(x, y), textShape);
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
import { type Point } from "points-on-curve";
|
||||
import {
|
||||
type Radians,
|
||||
pointFrom,
|
||||
pointCenter,
|
||||
pointRotateRads,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorSubtract,
|
||||
vectorAdd,
|
||||
vectorScale,
|
||||
pointFromVector,
|
||||
clamp,
|
||||
isCloseTo,
|
||||
} from "../../math";
|
||||
import type { TransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ImageCrop,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
widthAspectRatio?: number,
|
||||
) => {
|
||||
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||
getUncroppedWidthAndHeight(element);
|
||||
|
||||
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
|
||||
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
|
||||
|
||||
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
|
||||
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
|
||||
|
||||
/**
|
||||
* uncropped width
|
||||
* *––––––––––––––––––––––––*
|
||||
* | (x,y) (natural) |
|
||||
* | *–––––––* |
|
||||
* | |///////| height | uncropped height
|
||||
* | *–––––––* |
|
||||
* | width (natural) |
|
||||
* *––––––––––––––––––––––––*
|
||||
*/
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
pointerX = rotatedPointer[0];
|
||||
pointerY = rotatedPointer[1];
|
||||
|
||||
let nextWidth = element.width;
|
||||
let nextHeight = element.height;
|
||||
|
||||
let crop: ImageCrop | null = element.crop ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: naturalWidth,
|
||||
height: naturalHeight,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
};
|
||||
|
||||
const previousCropHeight = crop.height;
|
||||
const previousCropWidth = crop.width;
|
||||
|
||||
const isFlippedByX = element.scale[0] === -1;
|
||||
const isFlippedByY = element.scale[1] === -1;
|
||||
|
||||
let changeInHeight = pointerY - element.y;
|
||||
let changeInWidth = pointerX - element.x;
|
||||
|
||||
if (transformHandle.includes("n")) {
|
||||
nextHeight = clamp(
|
||||
element.height - changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("s")) {
|
||||
changeInHeight = pointerY - element.y - element.height;
|
||||
nextHeight = clamp(
|
||||
element.height + changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("e")) {
|
||||
changeInWidth = pointerX - element.x - element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
element.width + changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("w")) {
|
||||
nextWidth = clamp(
|
||||
element.width - changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
const updateCropWidthAndHeight = (crop: ImageCrop) => {
|
||||
crop.height = nextHeight * naturalHeightToUncropped;
|
||||
crop.width = nextWidth * naturalWidthToUncropped;
|
||||
};
|
||||
|
||||
updateCropWidthAndHeight(crop);
|
||||
|
||||
const adjustFlipForHandle = (
|
||||
handle: TransformHandleType,
|
||||
crop: ImageCrop,
|
||||
) => {
|
||||
updateCropWidthAndHeight(crop);
|
||||
if (handle.includes("n")) {
|
||||
if (!isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("s")) {
|
||||
if (isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("e")) {
|
||||
if (isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
if (handle.includes("w")) {
|
||||
if (!isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
switch (transformHandle) {
|
||||
case "n": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "s": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "w": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "e": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > -changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "nw": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth < changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "se": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "sw": {
|
||||
if (widthAspectRatio) {
|
||||
if (-changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const newOrigin = recomputeOrigin(
|
||||
element,
|
||||
transformHandle,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
!!widthAspectRatio,
|
||||
);
|
||||
|
||||
// reset crop to null if we're back to orig size
|
||||
if (
|
||||
isCloseTo(crop.width, crop.naturalWidth) &&
|
||||
isCloseTo(crop.height, crop.naturalHeight)
|
||||
) {
|
||||
crop = null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
crop,
|
||||
};
|
||||
};
|
||||
|
||||
const recomputeOrigin = (
|
||||
stateAtCropStart: NonDeleted<ExcalidrawElement>,
|
||||
transformHandle: TransformHandleType,
|
||||
width: number,
|
||||
height: number,
|
||||
shouldMaintainAspectRatio?: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtCropStart,
|
||||
stateAtCropStart.width,
|
||||
stateAtCropStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
|
||||
if (["n", "w", "nw"].includes(transformHandle)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||
];
|
||||
}
|
||||
if (transformHandle === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||
}
|
||||
if (transformHandle === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||
}
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (["s", "n"].includes(transformHandle)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandle)) {
|
||||
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtCropStart.angle;
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
|
||||
|
||||
return newOrigin;
|
||||
};
|
||||
|
||||
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
|
||||
export const getUncroppedImageElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (element.crop) {
|
||||
const { width, height } = getUncroppedWidthAndHeight(element);
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const topLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topRightVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topEdgeNormalized = vectorNormalize(
|
||||
vectorSubtract(topRightVector, topLeftVector),
|
||||
);
|
||||
const bottomLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
|
||||
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
|
||||
|
||||
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
|
||||
|
||||
const rotatedTopLeft = vectorAdd(
|
||||
vectorAdd(
|
||||
topLeftVector,
|
||||
vectorScale(
|
||||
topEdgeNormalized,
|
||||
(-cropX * width) / element.crop.naturalWidth,
|
||||
),
|
||||
),
|
||||
vectorScale(
|
||||
leftEdgeNormalized,
|
||||
(-cropY * height) / element.crop.naturalHeight,
|
||||
),
|
||||
);
|
||||
|
||||
const center = pointFromVector(
|
||||
vectorAdd(
|
||||
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
|
||||
vectorScale(leftEdgeNormalized, height / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const unrotatedTopLeft = pointRotateRads(
|
||||
pointFromVector(rotatedTopLeft),
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
const uncroppedElement: ExcalidrawImageElement = {
|
||||
...element,
|
||||
x: unrotatedTopLeft[0],
|
||||
y: unrotatedTopLeft[1],
|
||||
width,
|
||||
height,
|
||||
crop: null,
|
||||
};
|
||||
|
||||
return uncroppedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
|
||||
if (element.crop) {
|
||||
const width =
|
||||
element.width / (element.crop.width / element.crop.naturalWidth);
|
||||
const height =
|
||||
element.height / (element.crop.height / element.crop.naturalHeight);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustCropPosition = (
|
||||
crop: ImageCrop,
|
||||
scale: ExcalidrawImageElement["scale"],
|
||||
) => {
|
||||
let cropX = crop.x;
|
||||
let cropY = crop.y;
|
||||
|
||||
const flipX = scale[0] === -1;
|
||||
const flipY = scale[1] === -1;
|
||||
|
||||
if (flipX) {
|
||||
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
|
||||
}
|
||||
|
||||
if (flipY) {
|
||||
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
|
||||
}
|
||||
|
||||
return {
|
||||
cropX,
|
||||
cropY,
|
||||
};
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { getFontString } from "../utils";
|
||||
@@ -35,7 +36,6 @@ export const dragSelectedElements = (
|
||||
) => {
|
||||
if (
|
||||
_selectedElements.length === 1 &&
|
||||
isArrowElement(_selectedElements[0]) &&
|
||||
isElbowArrow(_selectedElements[0]) &&
|
||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||
) {
|
||||
@@ -43,13 +43,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
|
||||
@@ -258,6 +252,14 @@ export const dragNewElement = ({
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
imageInitialDimension = {
|
||||
initialWidth: width,
|
||||
initialHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
@@ -266,6 +268,7 @@ export const dragNewElement = ({
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
informMutation,
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import { invariant } from "../utils";
|
||||
import { point, type LocalPoint } from "../../math";
|
||||
import { pointFrom, type LocalPoint } from "../../math";
|
||||
import { aabbForElement } from "../shapes";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
@@ -421,7 +421,7 @@ const createBindingArrow = (
|
||||
strokeColor: appState.currentItemStrokeColor,
|
||||
strokeStyle: appState.currentItemStrokeStyle,
|
||||
strokeWidth: appState.currentItemStrokeWidth,
|
||||
points: [point(0, 0), point(endX, endY)],
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
|
||||
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y),
|
||||
pointFrom(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@@ -91,7 +91,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width, element.y + element.height / 2),
|
||||
pointFrom(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@@ -100,7 +100,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x + element.width / 2, element.y + element.height),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@@ -109,7 +109,7 @@ export const headingForPointFromElement = <
|
||||
);
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
point(element.x, element.y + element.height / 2),
|
||||
pointFrom(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
@@ -133,22 +133,22 @@ export const headingForPointFromElement = <
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[1]),
|
||||
pointFrom(aabb[0], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const topRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[1]),
|
||||
pointFrom(aabb[2], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomLeft = pointScaleFromOrigin(
|
||||
point(aabb[0], aabb[3]),
|
||||
pointFrom(aabb[0], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomRight = pointScaleFromOrigin(
|
||||
point(aabb[2], aabb[3]),
|
||||
pointFrom(aabb[2], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
||||
@@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vector,
|
||||
@@ -102,12 +102,13 @@ export class LinearElementEditor {
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
if (!pointsEqual(element.points[0], point(0, 0))) {
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -285,7 +287,7 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
referencePoint,
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
@@ -294,7 +296,7 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: point(
|
||||
point: pointFrom(
|
||||
width + referencePoint[0],
|
||||
height + referencePoint[1],
|
||||
),
|
||||
@@ -327,7 +329,7 @@ export class LinearElementEditor {
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
)
|
||||
: point(
|
||||
: pointFrom(
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
);
|
||||
@@ -588,11 +590,11 @@ export class LinearElementEditor {
|
||||
linearElementEditor.segmentMidPointHoveredCoords;
|
||||
if (existingSegmentMidpointHitCoords) {
|
||||
const distance = pointDistance(
|
||||
point(
|
||||
pointFrom(
|
||||
existingSegmentMidpointHitCoords[0],
|
||||
existingSegmentMidpointHitCoords[1],
|
||||
),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return existingSegmentMidpointHitCoords;
|
||||
@@ -604,8 +606,8 @@ export class LinearElementEditor {
|
||||
while (index < midPoints.length) {
|
||||
if (midPoints[index] !== null) {
|
||||
const distance = pointDistance(
|
||||
point(midPoints[index]![0], midPoints[index]![1]),
|
||||
point(scenePointer.x, scenePointer.y),
|
||||
pointFrom(midPoints[index]![0], midPoints[index]![1]),
|
||||
pointFrom(scenePointer.x, scenePointer.y),
|
||||
);
|
||||
if (distance <= threshold) {
|
||||
return midPoints[index];
|
||||
@@ -624,8 +626,8 @@ export class LinearElementEditor {
|
||||
zoom: AppState["zoom"],
|
||||
) {
|
||||
let distance = pointDistance(
|
||||
point(startPoint[0], startPoint[1]),
|
||||
point(endPoint[0], endPoint[1]),
|
||||
pointFrom(startPoint[0], startPoint[1]),
|
||||
pointFrom(endPoint[0], endPoint[1]),
|
||||
);
|
||||
if (element.points.length > 2 && element.roundness) {
|
||||
distance = getBezierCurveLength(element, endPoint);
|
||||
@@ -827,11 +829,11 @@ export class LinearElementEditor {
|
||||
const targetPoint =
|
||||
clickedPointIndex > -1 &&
|
||||
pointRotateRads(
|
||||
point(
|
||||
pointFrom(
|
||||
element.x + element.points[clickedPointIndex][0],
|
||||
element.y + element.points[clickedPointIndex][1],
|
||||
),
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
@@ -926,11 +928,11 @@ export class LinearElementEditor {
|
||||
element,
|
||||
elementsMap,
|
||||
lastCommittedPoint,
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
);
|
||||
|
||||
newPoint = point(
|
||||
newPoint = pointFrom(
|
||||
width + lastCommittedPoint[0],
|
||||
height + lastCommittedPoint[1],
|
||||
);
|
||||
@@ -982,8 +984,8 @@ export class LinearElementEditor {
|
||||
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
point(x + p[0], y + p[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
}
|
||||
@@ -999,8 +1001,8 @@ export class LinearElementEditor {
|
||||
return element.points.map((p) => {
|
||||
const { x, y } = element;
|
||||
return pointRotateRads(
|
||||
point(x + p[0], y + p[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
});
|
||||
@@ -1023,8 +1025,12 @@ export class LinearElementEditor {
|
||||
const { x, y } = element;
|
||||
|
||||
return p
|
||||
? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
|
||||
: pointRotateRads(point(x, y), point(cx, cy), element.angle);
|
||||
? pointRotateRads(
|
||||
pointFrom(x + p[0], y + p[1]),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
)
|
||||
: pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
@@ -1034,7 +1040,7 @@ export class LinearElementEditor {
|
||||
): LocalPoint {
|
||||
if (isElbowArrow(element)) {
|
||||
// No rotation for elbow arrows
|
||||
return point(
|
||||
return pointFrom(
|
||||
absoluteCoords[0] - element.x,
|
||||
absoluteCoords[1] - element.y,
|
||||
);
|
||||
@@ -1044,11 +1050,11 @@ export class LinearElementEditor {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [x, y] = pointRotateRads(
|
||||
point(absoluteCoords[0], absoluteCoords[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(absoluteCoords[0], absoluteCoords[1]),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
return point(x - element.x, y - element.y);
|
||||
return pointFrom(x - element.x, y - element.y);
|
||||
}
|
||||
|
||||
static getPointIndexUnderCursor(
|
||||
@@ -1069,7 +1075,7 @@ export class LinearElementEditor {
|
||||
while (--idx > -1) {
|
||||
const p = pointHandles[idx];
|
||||
if (
|
||||
pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
|
||||
pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
@@ -1091,12 +1097,12 @@ export class LinearElementEditor {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerOnGrid[0], pointerOnGrid[1]),
|
||||
point(cx, cy),
|
||||
pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
return point(rotatedX - element.x, rotatedY - element.y);
|
||||
return pointFrom(rotatedX - element.x, rotatedY - element.y);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1116,7 +1122,7 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
return point(p[0] - offsetX, p[1] - offsetY);
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
@@ -1170,8 +1176,8 @@ export class LinearElementEditor {
|
||||
}
|
||||
acc.push(
|
||||
nextPoint
|
||||
? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||
: point(p[0], p[1]),
|
||||
? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
|
||||
: pointFrom(p[0], p[1]),
|
||||
);
|
||||
|
||||
nextSelectedIndices.push(indexCursor + 1);
|
||||
@@ -1192,7 +1198,7 @@ export class LinearElementEditor {
|
||||
[
|
||||
{
|
||||
index: element.points.length - 1,
|
||||
point: point(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
|
||||
},
|
||||
],
|
||||
elementsMap,
|
||||
@@ -1233,7 +1239,9 @@ export class LinearElementEditor {
|
||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
||||
if (!pointIndices.includes(idx)) {
|
||||
acc.push(
|
||||
!acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
|
||||
!acc.length
|
||||
? pointFrom(0, 0)
|
||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
@@ -1310,9 +1318,9 @@ export class LinearElementEditor {
|
||||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
|
||||
}
|
||||
return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
|
||||
});
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
@@ -1366,8 +1374,8 @@ export class LinearElementEditor {
|
||||
|
||||
const origin = linearElementEditor.pointerDownState.origin!;
|
||||
const dist = pointDistance(
|
||||
point(origin.x, origin.y),
|
||||
point(pointerCoords.x, pointerCoords.y),
|
||||
pointFrom(origin.x, origin.y),
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
@@ -1477,7 +1485,9 @@ export class LinearElementEditor {
|
||||
nextPoints,
|
||||
vector(offsetX, offsetY),
|
||||
bindings,
|
||||
options,
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
@@ -1489,8 +1499,8 @@ export class LinearElementEditor {
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotated = pointRotateRads(
|
||||
point(offsetX, offsetY),
|
||||
point(dX, dY),
|
||||
pointFrom(offsetX, offsetY),
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
);
|
||||
mutateElement(element, {
|
||||
@@ -1536,8 +1546,8 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
point(width, height),
|
||||
point(0, 0),
|
||||
pointFrom(width, height),
|
||||
pointFrom(0, 0),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
}
|
||||
@@ -1607,36 +1617,36 @@ export class LinearElementEditor {
|
||||
);
|
||||
const boundTextX2 = boundTextX1 + boundTextElement.width;
|
||||
const boundTextY2 = boundTextY1 + boundTextElement.height;
|
||||
const centerPoint = point(cx, cy);
|
||||
const centerPoint = pointFrom(cx, cy);
|
||||
|
||||
const topLeftRotatedPoint = pointRotateRads(
|
||||
point(x1, y1),
|
||||
pointFrom(x1, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
const topRightRotatedPoint = pointRotateRads(
|
||||
point(x2, y1),
|
||||
pointFrom(x2, y1),
|
||||
centerPoint,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const counterRotateBoundTextTopLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY1),
|
||||
pointFrom(boundTextX1, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextTopRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY1),
|
||||
pointFrom(boundTextX2, boundTextY1),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomLeft = pointRotateRads(
|
||||
point(boundTextX1, boundTextY2),
|
||||
pointFrom(boundTextX1, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const counterRotateBoundTextBottomRight = pointRotateRads(
|
||||
point(boundTextX2, boundTextY2),
|
||||
pointFrom(boundTextX2, boundTextY2),
|
||||
centerPoint,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
import type { LocalPoint } from "../../math";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
@@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
@@ -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> => {
|
||||
@@ -478,6 +477,7 @@ export const newImageElement = (
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
crop?: ExcalidrawImageElement["crop"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
@@ -488,6 +488,7 @@ export const newImageElement = (
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
crop: opts.crop ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
@@ -57,7 +58,7 @@ import type { GlobalPoint } from "../../math";
|
||||
import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
point,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
@@ -239,8 +240,8 @@ const resizeSingleTextElement = (
|
||||
);
|
||||
// rotation pointer with reverse angle
|
||||
const [rotatedX, rotatedY] = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
point(cx, cy),
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(cx, cy),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
let scaleX = 0;
|
||||
@@ -275,23 +276,23 @@ const resizeSingleTextElement = (
|
||||
const startBottomRight = [x2, y2];
|
||||
const startCenter = [cx, cy];
|
||||
|
||||
let newTopLeft = point<GlobalPoint>(x1, y1);
|
||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
startBottomRight[0] - Math.abs(nextWidth),
|
||||
startBottomRight[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
bottomLeft[0],
|
||||
bottomLeft[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
if (transformHandleType === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = point<GlobalPoint>(
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
topRight[0] - Math.abs(nextWidth),
|
||||
topRight[1],
|
||||
);
|
||||
@@ -310,12 +311,20 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
|
||||
const angle = element.angle;
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
|
||||
const newCenter = point<GlobalPoint>(
|
||||
const rotatedTopLeft = pointRotateRads(
|
||||
newTopLeft,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
const newCenter = pointFrom<GlobalPoint>(
|
||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
||||
);
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
|
||||
const rotatedNewCenter = pointRotateRads(
|
||||
newCenter,
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
@@ -340,12 +349,12 @@ const resizeSingleTextElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = point<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = point<GlobalPoint>(x2, y2);
|
||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
pointFrom(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
@@ -418,7 +427,7 @@ const resizeSingleTextElement = (
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
const newCenter = pointFrom(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
);
|
||||
@@ -460,13 +469,13 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = point(x1, y1);
|
||||
const startBottomRight = point(x2, y2);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
// Calculate new dimensions based on cursor position
|
||||
const rotatedPointer = pointRotateRads(
|
||||
point(pointerX, pointerY),
|
||||
pointFrom(pointerX, pointerY),
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle as Radians,
|
||||
);
|
||||
@@ -647,7 +656,7 @@ export const resizeSingleElement = (
|
||||
startCenter,
|
||||
angle,
|
||||
);
|
||||
const newCenter = point(
|
||||
const newCenter = pointFrom(
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
);
|
||||
@@ -816,20 +825,20 @@ export const resizeMultipleElements = (
|
||||
const direction = transformHandleType;
|
||||
|
||||
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),
|
||||
ne: pointFrom(minX, maxY),
|
||||
se: pointFrom(minX, minY),
|
||||
sw: pointFrom(maxX, minY),
|
||||
nw: pointFrom(maxX, maxY),
|
||||
e: pointFrom(minX, minY + height / 2),
|
||||
w: pointFrom(maxX, minY + height / 2),
|
||||
n: pointFrom(minX + width / 2, maxY),
|
||||
s: pointFrom(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] = shouldResizeFromCenter
|
||||
? point(midX, midY)
|
||||
? pointFrom(midX, midY)
|
||||
: anchorsMap[direction];
|
||||
|
||||
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
|
||||
@@ -909,6 +918,8 @@ export const resizeMultipleElements = (
|
||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
startBinding?: ExcalidrawArrowElement["startBinding"];
|
||||
endBinding?: ExcalidrawArrowElement["endBinding"];
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
@@ -993,19 +1004,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 },
|
||||
@@ -1054,12 +1052,12 @@ const rotateMultipleElements = (
|
||||
const origAngle =
|
||||
originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = pointRotateRads(
|
||||
point(cx, cy),
|
||||
point(centerX, centerY),
|
||||
pointFrom(cx, cy),
|
||||
pointFrom(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 {
|
||||
@@ -1111,40 +1109,44 @@ export const getResizeOffsetXY = (
|
||||
const angle = (
|
||||
selectedElements.length === 1 ? selectedElements[0].angle : 0
|
||||
) as Radians;
|
||||
[x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
|
||||
[x, y] = pointRotateRads(
|
||||
pointFrom(x, y),
|
||||
pointFrom(cx, cy),
|
||||
-angle as Radians,
|
||||
);
|
||||
switch (transformHandleType) {
|
||||
case "n":
|
||||
return pointRotateRads(
|
||||
point(x - (x1 + x2) / 2, y - y1),
|
||||
point(0, 0),
|
||||
pointFrom(x - (x1 + x2) / 2, y - y1),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "s":
|
||||
return pointRotateRads(
|
||||
point(x - (x1 + x2) / 2, y - y2),
|
||||
point(0, 0),
|
||||
pointFrom(x - (x1 + x2) / 2, y - y2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "w":
|
||||
return pointRotateRads(
|
||||
point(x - x1, y - (y1 + y2) / 2),
|
||||
point(0, 0),
|
||||
pointFrom(x - x1, y - (y1 + y2) / 2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "e":
|
||||
return pointRotateRads(
|
||||
point(x - x2, y - (y1 + y2) / 2),
|
||||
point(0, 0),
|
||||
pointFrom(x - x2, y - (y1 + y2) / 2),
|
||||
pointFrom(0, 0),
|
||||
angle,
|
||||
);
|
||||
case "nw":
|
||||
return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
|
||||
case "ne":
|
||||
return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
|
||||
case "sw":
|
||||
return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
|
||||
case "se":
|
||||
return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
|
||||
return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
|
||||
default:
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ import type { AppState, Device, Zoom } from "../types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
@@ -90,18 +90,26 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
|
||||
// do not resize from the sides for linear elements with only two points
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const SPACING = isImageElement(element)
|
||||
? 0
|
||||
: SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const ZOOMED_SIDE_RESIZING_THRESHOLD =
|
||||
SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
|
||||
pointOnLineSegment(
|
||||
pointFrom(x, y),
|
||||
side as LineSegment<Point>,
|
||||
ZOOMED_SIDE_RESIZING_THRESHOLD,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
@@ -178,9 +186,9 @@ export const getTransformHandleTypeFromCoords = <
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
|
||||
const sides = getSelectionBorders(
|
||||
point(x1 - SPACING, y1 - SPACING),
|
||||
point(x2 + SPACING, y2 + SPACING),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
0 as Radians,
|
||||
);
|
||||
|
||||
@@ -188,7 +196,7 @@ export const getTransformHandleTypeFromCoords = <
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
point(scenePointerX, scenePointerY),
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
side as LineSegment<Point>,
|
||||
SPACING,
|
||||
)
|
||||
@@ -265,10 +273,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
) => {
|
||||
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);
|
||||
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
|
||||
|
||||
return {
|
||||
n: [topLeft, topRight],
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import { point } from "../../math";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("elbow arrow routing", () => {
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
|
||||
point(-45 - arrow.x, -100.1 - arrow.y),
|
||||
point(45 - arrow.x, 99.9 - arrow.y),
|
||||
pointFrom(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom(45 - arrow.x, 99.9 - arrow.y),
|
||||
]);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@@ -69,7 +69,7 @@ describe("elbow arrow routing", () => {
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [point(0, 0), point(90, 200)],
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
@@ -81,7 +81,7 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
|
||||
mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
@@ -94,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 () => {
|
||||
@@ -130,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],
|
||||
]);
|
||||
});
|
||||
@@ -163,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(
|
||||
@@ -182,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,6 +1,6 @@
|
||||
import type { Radians } from "../../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointScaleFromOrigin,
|
||||
pointTranslate,
|
||||
vector,
|
||||
@@ -36,11 +36,11 @@ import {
|
||||
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";
|
||||
@@ -72,16 +72,48 @@ export const mutateElbowArrow = (
|
||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||
nextPoints: readonly LocalPoint[],
|
||||
offset?: Vector,
|
||||
otherUpdates?: {
|
||||
startBinding?: FixedPointBinding | null;
|
||||
endBinding?: FixedPointBinding | null;
|
||||
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;
|
||||
},
|
||||
) => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||
pointTranslate<LocalPoint, GlobalPoint>(
|
||||
nextPoints[0],
|
||||
@@ -235,6 +267,8 @@ export const mutateElbowArrow = (
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||
);
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
@@ -295,18 +329,10 @@ export const mutateElbowArrow = (
|
||||
startDongle && points.unshift(startGlobalPoint);
|
||||
endDongle && points.push(endGlobalPoint);
|
||||
|
||||
mutateElement(
|
||||
arrow,
|
||||
{
|
||||
...otherUpdates,
|
||||
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
options?.informMutation,
|
||||
);
|
||||
} else {
|
||||
console.error("Elbow arrow cannot find a route");
|
||||
return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const offsetFromHeading = (
|
||||
@@ -475,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,
|
||||
];
|
||||
@@ -484,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,
|
||||
@@ -514,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,
|
||||
@@ -713,13 +743,13 @@ const getDonglePosition = (
|
||||
): GlobalPoint => {
|
||||
switch (heading) {
|
||||
case HEADING_UP:
|
||||
return point(p[0], bounds[1]);
|
||||
return pointFrom(p[0], bounds[1]);
|
||||
case HEADING_RIGHT:
|
||||
return point(bounds[2], p[1]);
|
||||
return pointFrom(bounds[2], p[1]);
|
||||
case HEADING_DOWN:
|
||||
return point(p[0], bounds[3]);
|
||||
return pointFrom(p[0], bounds[3]);
|
||||
}
|
||||
return point(bounds[0], p[1]);
|
||||
return pointFrom(bounds[0], p[1]);
|
||||
};
|
||||
|
||||
const estimateSegmentCount = (
|
||||
|
||||
@@ -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,7 +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";
|
||||
import { pointFrom } from "../../math";
|
||||
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
@@ -42,7 +42,7 @@ describe("textWysiwyg", () => {
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [point(0, 0), point(100, 0)],
|
||||
points: [pointFrom(0, 0), pointFrom(100, 0)],
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
|
||||
@@ -247,7 +247,7 @@ export const textWysiwyg = ({
|
||||
|
||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
isIOS,
|
||||
} from "../constants";
|
||||
import type { Radians } from "../../math";
|
||||
import { point, pointRotateRads } from "../../math";
|
||||
import { pointFrom, pointRotateRads } from "../../math";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
@@ -95,8 +96,8 @@ const generateTransformHandle = (
|
||||
angle: Radians,
|
||||
): TransformHandle => {
|
||||
const [xx, yy] = pointRotateRads(
|
||||
point(x + width / 2, y + height / 2),
|
||||
point(cx, cy),
|
||||
pointFrom(x + width / 2, y + height / 2),
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
@@ -129,6 +130,7 @@ export const getTransformHandlesFromCoords = (
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
margin = 4,
|
||||
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
): TransformHandles => {
|
||||
const size = transformHandleSizes[pointerType];
|
||||
const handleWidth = size / zoom.value;
|
||||
@@ -140,8 +142,7 @@ export const getTransformHandlesFromCoords = (
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
const centeringOffset =
|
||||
(size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value);
|
||||
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
|
||||
|
||||
const transformHandles: TransformHandles = {
|
||||
nw: omitSides.nw
|
||||
@@ -301,8 +302,10 @@ export const getTransformHandles = (
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
const margin = isLinearElement(element)
|
||||
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
|
||||
: isImageElement(element)
|
||||
? 0
|
||||
: DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element, elementsMap, true),
|
||||
@@ -310,7 +313,8 @@ export const getTransformHandles = (
|
||||
zoom,
|
||||
pointerType,
|
||||
omitSides,
|
||||
dashedLineMargin,
|
||||
margin,
|
||||
isImageElement(element) ? 0 : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -320,9 +320,12 @@ 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
|
||||
|
||||
@@ -132,6 +132,15 @@ export type IframeData =
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
);
|
||||
|
||||
export type ImageCrop = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
@@ -140,6 +149,8 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
status: "pending" | "saved" | "error";
|
||||
/** X and Y scale factors <-1, 1>, used for image axis flipping */
|
||||
scale: [number, number];
|
||||
/** whether an element is cropped */
|
||||
crop: ImageCrop | null;
|
||||
}>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
@@ -193,6 +204,7 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
@@ -268,15 +280,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"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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";
|
||||
@@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
|
||||
|
||||
// it's dataurl (server), the font is inlined as base64, no need to fetch
|
||||
if (url.protocol === "data:") {
|
||||
const arrayBuffer = Buffer.from(
|
||||
url.toString().split(",")[1],
|
||||
"base64",
|
||||
).buffer;
|
||||
const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
|
||||
|
||||
const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
|
||||
arrayBuffer,
|
||||
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
|
||||
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
|
||||
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
|
||||
import type { ReadonlySetLike } from "./utility-types";
|
||||
import { isPointWithinBounds, point } from "../math";
|
||||
import { isPointWithinBounds, pointFrom } from "../math";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
|
||||
return isPointWithinBounds(
|
||||
point(fx1, fy1),
|
||||
point(cursorCoords.x, cursorCoords.y),
|
||||
point(fx2, fy2),
|
||||
pointFrom(fx1, fy1),
|
||||
pointFrom(cursorCoords.x, cursorCoords.y),
|
||||
pointFrom(fx2, fy2),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -162,6 +162,13 @@
|
||||
"hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
|
||||
"hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
|
||||
},
|
||||
"search": {
|
||||
"title": "Find on canvas",
|
||||
"noMatch": "No matches found...",
|
||||
"singleResult": "result",
|
||||
"multipleResults": "results",
|
||||
"placeholder": "Find text on canvas..."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
"exportJSON": "Export to file",
|
||||
@@ -297,6 +304,7 @@
|
||||
"shapes": "Shapes"
|
||||
},
|
||||
"hints": {
|
||||
"dismissSearch": "Escape to dismiss search",
|
||||
"canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
|
||||
"linearElement": "Click to start multiple points, drag for single line",
|
||||
"arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",
|
||||
@@ -320,7 +328,9 @@
|
||||
"deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
|
||||
"eraserRevert": "Hold Alt to revert the elements marked for deletion",
|
||||
"firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page.",
|
||||
"disableSnapping": "Hold CtrlOrCmd to disable snapping"
|
||||
"disableSnapping": "Hold CtrlOrCmd to disable snapping",
|
||||
"enterCropEditor": "Double click the image or press ENTER to crop the image",
|
||||
"leaveCropEditor": "Click outside the image or press ENTER or ESCAPE to finish cropping"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Cannot show preview",
|
||||
@@ -391,7 +401,9 @@
|
||||
"zoomToSelection": "Zoom to selection",
|
||||
"toggleElementLock": "Lock/unlock selection",
|
||||
"movePageUpDown": "Move page up/down",
|
||||
"movePageLeftRight": "Move page left/right"
|
||||
"movePageLeftRight": "Move page left/right",
|
||||
"cropStart": "Crop image",
|
||||
"cropFinish": "Finish image cropping"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Clear canvas"
|
||||
|
||||
@@ -30,8 +30,12 @@ import {
|
||||
shouldShowBoundingBox,
|
||||
} from "../element/transformHandles";
|
||||
import { arrayToMap, throttleRAF } from "../utils";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
FRAME_STYLE,
|
||||
THEME,
|
||||
} from "../constants";
|
||||
import { type InteractiveCanvasAppState } from "../types";
|
||||
|
||||
import { renderSnaps } from "../renderer/renderSnaps";
|
||||
|
||||
@@ -48,9 +52,9 @@ import {
|
||||
} from "./helpers";
|
||||
import oc from "open-color";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
@@ -59,6 +63,7 @@ import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
GroupId,
|
||||
@@ -304,38 +309,42 @@ const renderBindingHighlightForSuggestedPointBinding = (
|
||||
});
|
||||
};
|
||||
|
||||
type ElementSelectionBorder = {
|
||||
angle: number;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
selectionColors: string[];
|
||||
dashed?: boolean;
|
||||
cx: number;
|
||||
cy: number;
|
||||
activeEmbeddable: boolean;
|
||||
padding?: number;
|
||||
};
|
||||
|
||||
const renderSelectionBorder = (
|
||||
context: CanvasRenderingContext2D,
|
||||
appState: InteractiveCanvasAppState,
|
||||
elementProperties: {
|
||||
angle: number;
|
||||
elementX1: number;
|
||||
elementY1: number;
|
||||
elementX2: number;
|
||||
elementY2: number;
|
||||
selectionColors: string[];
|
||||
dashed?: boolean;
|
||||
cx: number;
|
||||
cy: number;
|
||||
activeEmbeddable: boolean;
|
||||
},
|
||||
elementProperties: ElementSelectionBorder,
|
||||
) => {
|
||||
const {
|
||||
angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
selectionColors,
|
||||
cx,
|
||||
cy,
|
||||
dashed,
|
||||
activeEmbeddable,
|
||||
} = elementProperties;
|
||||
const elementWidth = elementX2 - elementX1;
|
||||
const elementHeight = elementY2 - elementY1;
|
||||
const elementWidth = x2 - x1;
|
||||
const elementHeight = y2 - y1;
|
||||
|
||||
const padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
|
||||
const padding =
|
||||
elementProperties.padding ?? DEFAULT_TRANSFORM_HANDLE_SPACING * 2;
|
||||
|
||||
const linePadding = padding / appState.zoom.value;
|
||||
const lineWidth = 8 / appState.zoom.value;
|
||||
@@ -357,8 +366,8 @@ const renderSelectionBorder = (
|
||||
context.lineDashOffset = (lineWidth + spaceWidth) * index;
|
||||
strokeRectWithRotation(
|
||||
context,
|
||||
elementX1 - linePadding,
|
||||
elementY1 - linePadding,
|
||||
x1 - linePadding,
|
||||
y1 - linePadding,
|
||||
elementWidth + linePadding * 2,
|
||||
elementHeight + linePadding * 2,
|
||||
cx,
|
||||
@@ -430,18 +439,17 @@ const renderElementsBoxHighlight = (
|
||||
);
|
||||
|
||||
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
getCommonBounds(elements);
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
return {
|
||||
angle: 0,
|
||||
elementX1,
|
||||
elementX2,
|
||||
elementY1,
|
||||
elementY2,
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
selectionColors: ["rgb(0,118,255)"],
|
||||
dashed: false,
|
||||
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||
cx: x1 + (x2 - x1) / 2,
|
||||
cy: y1 + (y2 - y1) / 2,
|
||||
activeEmbeddable: false,
|
||||
};
|
||||
};
|
||||
@@ -591,6 +599,111 @@ const renderTransformHandles = (
|
||||
});
|
||||
};
|
||||
|
||||
const renderCropHandles = (
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: InteractiveCanvasRenderConfig,
|
||||
appState: InteractiveCanvasAppState,
|
||||
croppingElement: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
): void => {
|
||||
const [x1, y1, , , cx, cy] = getElementAbsoluteCoords(
|
||||
croppingElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const LINE_WIDTH = 3;
|
||||
const LINE_LENGTH = 20;
|
||||
|
||||
const ZOOMED_LINE_WIDTH = LINE_WIDTH / appState.zoom.value;
|
||||
const ZOOMED_HALF_LINE_WIDTH = ZOOMED_LINE_WIDTH / 2;
|
||||
|
||||
const HALF_WIDTH = cx - x1 + ZOOMED_LINE_WIDTH;
|
||||
const HALF_HEIGHT = cy - y1 + ZOOMED_LINE_WIDTH;
|
||||
|
||||
const HORIZONTAL_LINE_LENGTH = Math.min(
|
||||
LINE_LENGTH / appState.zoom.value,
|
||||
HALF_WIDTH,
|
||||
);
|
||||
const VERTICAL_LINE_LENGTH = Math.min(
|
||||
LINE_LENGTH / appState.zoom.value,
|
||||
HALF_HEIGHT,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = renderConfig.selectionColor;
|
||||
context.strokeStyle = renderConfig.selectionColor;
|
||||
context.lineWidth = ZOOMED_LINE_WIDTH;
|
||||
|
||||
const handles: Array<
|
||||
[
|
||||
[number, number],
|
||||
[number, number],
|
||||
[number, number],
|
||||
[number, number],
|
||||
[number, number],
|
||||
]
|
||||
> = [
|
||||
[
|
||||
// x, y
|
||||
[-HALF_WIDTH, -HALF_HEIGHT],
|
||||
// horizontal line: first start and to
|
||||
[0, ZOOMED_HALF_LINE_WIDTH],
|
||||
[HORIZONTAL_LINE_LENGTH, ZOOMED_HALF_LINE_WIDTH],
|
||||
// vertical line: second start and to
|
||||
[ZOOMED_HALF_LINE_WIDTH, 0],
|
||||
[ZOOMED_HALF_LINE_WIDTH, VERTICAL_LINE_LENGTH],
|
||||
],
|
||||
[
|
||||
[HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, -HALF_HEIGHT],
|
||||
[ZOOMED_HALF_LINE_WIDTH, ZOOMED_HALF_LINE_WIDTH],
|
||||
[
|
||||
-HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
|
||||
ZOOMED_HALF_LINE_WIDTH,
|
||||
],
|
||||
[0, 0],
|
||||
[0, VERTICAL_LINE_LENGTH],
|
||||
],
|
||||
[
|
||||
[-HALF_WIDTH, HALF_HEIGHT],
|
||||
[0, -ZOOMED_HALF_LINE_WIDTH],
|
||||
[HORIZONTAL_LINE_LENGTH, -ZOOMED_HALF_LINE_WIDTH],
|
||||
[ZOOMED_HALF_LINE_WIDTH, 0],
|
||||
[ZOOMED_HALF_LINE_WIDTH, -VERTICAL_LINE_LENGTH],
|
||||
],
|
||||
[
|
||||
[HALF_WIDTH - ZOOMED_HALF_LINE_WIDTH, HALF_HEIGHT],
|
||||
[ZOOMED_HALF_LINE_WIDTH, -ZOOMED_HALF_LINE_WIDTH],
|
||||
[
|
||||
-HORIZONTAL_LINE_LENGTH + ZOOMED_HALF_LINE_WIDTH,
|
||||
-ZOOMED_HALF_LINE_WIDTH,
|
||||
],
|
||||
[0, 0],
|
||||
[0, -VERTICAL_LINE_LENGTH],
|
||||
],
|
||||
];
|
||||
|
||||
handles.forEach((handle) => {
|
||||
const [[x, y], [x1s, y1s], [x1t, y1t], [x2s, y2s], [x2t, y2t]] = handle;
|
||||
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
context.rotate(croppingElement.angle);
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(x + x1s, y + y1s);
|
||||
context.lineTo(x + x1t, y + y1t);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(x + x2s, y + y2s);
|
||||
context.lineTo(x + x2t, y + y2t);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderTextBox = (
|
||||
text: NonDeleted<ExcalidrawTextElement>,
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -668,7 +781,7 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
// Paint selection element
|
||||
if (appState.selectionElement) {
|
||||
if (appState.selectionElement && !appState.isCropping) {
|
||||
try {
|
||||
renderSelectionElement(
|
||||
appState.selectionElement,
|
||||
@@ -780,18 +893,7 @@ const _renderInteractiveScene = ({
|
||||
// Optimisation for finding quickly relevant element ids
|
||||
const locallySelectedIds = arrayToMap(selectedElements);
|
||||
|
||||
const selections: {
|
||||
angle: number;
|
||||
elementX1: number;
|
||||
elementY1: number;
|
||||
elementX2: number;
|
||||
elementY2: number;
|
||||
selectionColors: string[];
|
||||
dashed?: boolean;
|
||||
cx: number;
|
||||
cy: number;
|
||||
activeEmbeddable: boolean;
|
||||
}[] = [];
|
||||
const selections: ElementSelectionBorder[] = [];
|
||||
|
||||
for (const element of elementsMap.values()) {
|
||||
const selectionColors = [];
|
||||
@@ -803,7 +905,6 @@ const _renderInteractiveScene = ({
|
||||
// Elbow arrow elements cannot be selected when bound on either end
|
||||
(
|
||||
isSingleLinearElementSelected &&
|
||||
isArrowElement(element) &&
|
||||
isElbowArrow(element) &&
|
||||
(element.startBinding || element.endBinding)
|
||||
)
|
||||
@@ -831,14 +932,17 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
if (selectionColors.length) {
|
||||
const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
|
||||
getElementAbsoluteCoords(element, elementsMap, true);
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
selections.push({
|
||||
angle: element.angle,
|
||||
elementX1,
|
||||
elementY1,
|
||||
elementX2,
|
||||
elementY2,
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
selectionColors,
|
||||
dashed: !!remoteClients,
|
||||
cx,
|
||||
@@ -846,24 +950,28 @@ const _renderInteractiveScene = ({
|
||||
activeEmbeddable:
|
||||
appState.activeEmbeddable?.element === element &&
|
||||
appState.activeEmbeddable.state === "active",
|
||||
padding:
|
||||
element.id === appState.croppingElementId ||
|
||||
isImageElement(element)
|
||||
? 0
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addSelectionForGroupId = (groupId: GroupId) => {
|
||||
const groupElements = getElementsInGroup(elementsMap, groupId);
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
getCommonBounds(groupElements);
|
||||
const [x1, y1, x2, y2] = getCommonBounds(groupElements);
|
||||
selections.push({
|
||||
angle: 0,
|
||||
elementX1,
|
||||
elementX2,
|
||||
elementY1,
|
||||
elementY2,
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
selectionColors: [oc.black],
|
||||
dashed: true,
|
||||
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||
cx: x1 + (x2 - x1) / 2,
|
||||
cy: y1 + (y2 - y1) / 2,
|
||||
activeEmbeddable: false,
|
||||
});
|
||||
};
|
||||
@@ -898,7 +1006,9 @@ const _renderInteractiveScene = ({
|
||||
!appState.viewModeEnabled &&
|
||||
showBoundingBox &&
|
||||
// do not show transform handles when text is being edited
|
||||
!isTextElement(appState.editingTextElement)
|
||||
!isTextElement(appState.editingTextElement) &&
|
||||
// do not show transform handles when image is being cropped
|
||||
!appState.croppingElementId
|
||||
) {
|
||||
renderTransformHandles(
|
||||
context,
|
||||
@@ -908,6 +1018,20 @@ const _renderInteractiveScene = ({
|
||||
selectedElements[0].angle,
|
||||
);
|
||||
}
|
||||
|
||||
if (appState.croppingElementId && !appState.isCropping) {
|
||||
const croppingElement = elementsMap.get(appState.croppingElementId);
|
||||
|
||||
if (croppingElement && isImageElement(croppingElement)) {
|
||||
renderCropHandles(
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
croppingElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (selectedElements.length > 1 && !appState.isRotating) {
|
||||
const dashedLinePadding =
|
||||
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value;
|
||||
@@ -952,9 +1076,48 @@ const _renderInteractiveScene = ({
|
||||
context.restore();
|
||||
}
|
||||
|
||||
appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
|
||||
const element = elementsMap.get(id);
|
||||
|
||||
if (element && isTextElement(element)) {
|
||||
const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
context.save();
|
||||
if (appState.theme === THEME.LIGHT) {
|
||||
if (focus) {
|
||||
context.fillStyle = "rgba(255, 124, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(255, 226, 0, 0.4)";
|
||||
}
|
||||
} else if (focus) {
|
||||
context.fillStyle = "rgba(229, 82, 0, 0.4)";
|
||||
} else {
|
||||
context.fillStyle = "rgba(99, 52, 0, 0.4)";
|
||||
}
|
||||
|
||||
context.translate(appState.scrollX, appState.scrollY);
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
|
||||
matchedLines.forEach((matchedLine) => {
|
||||
context.fillRect(
|
||||
elementX1 + matchedLine.offsetX - cx,
|
||||
elementY1 + matchedLine.offsetY - cy,
|
||||
matchedLine.width,
|
||||
matchedLine.height,
|
||||
);
|
||||
});
|
||||
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
|
||||
renderSnaps(context, appState);
|
||||
|
||||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
renderRemoteCursors({
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isArrowElement,
|
||||
hasBoundTextElement,
|
||||
isMagicFrameElement,
|
||||
isImageElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getElementAbsoluteCoords } from "../element/bounds";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
@@ -61,6 +62,7 @@ import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { isRightAngleRads } from "../../math";
|
||||
import { getCornerRadius } from "../shapes";
|
||||
import { getUncroppedImageElement } from "../element/cropElement";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@@ -434,8 +436,22 @@ const drawElementOnCanvas = (
|
||||
);
|
||||
context.clip();
|
||||
}
|
||||
|
||||
const { x, y, width, height } = element.crop
|
||||
? element.crop
|
||||
: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
@@ -921,14 +937,53 @@ export const renderElement = (
|
||||
context.imageSmoothingEnabled = false;
|
||||
}
|
||||
|
||||
drawElementFromCanvas(
|
||||
elementWithCanvas,
|
||||
context,
|
||||
if (
|
||||
element.id === appState.croppingElementId &&
|
||||
isImageElement(elementWithCanvas.element) &&
|
||||
elementWithCanvas.element.crop !== null
|
||||
) {
|
||||
context.save();
|
||||
context.globalAlpha = 0.1;
|
||||
|
||||
const uncroppedElementCanvas = generateElementCanvas(
|
||||
getUncroppedImageElement(elementWithCanvas.element, elementsMap),
|
||||
allElementsMap,
|
||||
appState.zoom,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
if (uncroppedElementCanvas) {
|
||||
drawElementFromCanvas(
|
||||
uncroppedElementCanvas,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
const _elementWithCanvas = generateElementCanvas(
|
||||
elementWithCanvas.element,
|
||||
allElementsMap,
|
||||
appState.zoom,
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
);
|
||||
|
||||
if (_elementWithCanvas) {
|
||||
drawElementFromCanvas(
|
||||
_elementWithCanvas,
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
allElementsMap,
|
||||
);
|
||||
}
|
||||
|
||||
// reset
|
||||
context.imageSmoothingEnabled = currentImageSmoothingStatus;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { point, type GlobalPoint, type LocalPoint } from "../../math";
|
||||
import { pointFrom, type GlobalPoint, type LocalPoint } from "../../math";
|
||||
import { THEME } from "../constants";
|
||||
import type { PointSnapLine, PointerSnapLine } from "../snapping";
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
@@ -140,27 +140,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(
|
||||
point(from[0], from[1] - FULL),
|
||||
point(from[0], from[1] + FULL),
|
||||
pointFrom(from[0], from[1] - FULL),
|
||||
pointFrom(from[0], from[1] + FULL),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
|
||||
point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
|
||||
pointFrom(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
|
||||
pointFrom(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
|
||||
point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
|
||||
pointFrom(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
|
||||
pointFrom(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
|
||||
drawLine(
|
||||
pointFrom(to[0], to[1] - FULL),
|
||||
pointFrom(to[0], to[1] + FULL),
|
||||
context,
|
||||
);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
@@ -170,27 +174,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
|
||||
// (1)
|
||||
if (!appState.zenModeEnabled) {
|
||||
drawLine(
|
||||
point(from[0] - FULL, from[1]),
|
||||
point(from[0] + FULL, from[1]),
|
||||
pointFrom(from[0] - FULL, from[1]),
|
||||
pointFrom(from[0] + FULL, from[1]),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// (3)
|
||||
drawLine(
|
||||
point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
|
||||
point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
|
||||
pointFrom(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
|
||||
pointFrom(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
|
||||
context,
|
||||
);
|
||||
drawLine(
|
||||
point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
|
||||
point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
|
||||
pointFrom(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
|
||||
pointFrom(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
|
||||
context,
|
||||
);
|
||||
|
||||
if (!appState.zenModeEnabled) {
|
||||
// (4)
|
||||
drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
|
||||
drawLine(
|
||||
pointFrom(to[0] - FULL, to[1]),
|
||||
pointFrom(to[0] + FULL, to[1]),
|
||||
context,
|
||||
);
|
||||
|
||||
// (2)
|
||||
drawLine(from, to, context);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SVG_NS,
|
||||
} from "../constants";
|
||||
import { normalizeLink, toValidURL } from "../data/url";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
import { getElementAbsoluteCoords, hashString } from "../element";
|
||||
import {
|
||||
createPlaceholderEmbeddableLabel,
|
||||
getEmbedLink,
|
||||
@@ -37,6 +37,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
|
||||
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
|
||||
import { getVerticalOffset } from "../fonts";
|
||||
import { getCornerRadius, isPathALoop } from "../shapes";
|
||||
import { getUncroppedWidthAndHeight } from "../element/cropElement";
|
||||
|
||||
const roughSVGDrawWithPrecision = (
|
||||
rsvg: RoughSVG,
|
||||
@@ -410,17 +411,35 @@ const renderElementToSvg = (
|
||||
const fileData =
|
||||
isInitializedImageElement(element) && files[element.fileId];
|
||||
if (fileData) {
|
||||
const symbolId = `image-${fileData.id}`;
|
||||
const cropHash = hashString(JSON.stringify(element.crop));
|
||||
const symbolId = `image-${fileData.id}-${cropHash}`;
|
||||
let symbol = svgRoot.querySelector(`#${symbolId}`);
|
||||
if (!symbol) {
|
||||
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
|
||||
symbol.id = symbolId;
|
||||
|
||||
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
|
||||
|
||||
image.setAttribute("width", "100%");
|
||||
image.setAttribute("height", "100%");
|
||||
image.setAttribute("href", fileData.dataURL);
|
||||
image.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
if (element.crop) {
|
||||
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||
getUncroppedWidthAndHeight(element);
|
||||
|
||||
symbol.setAttribute(
|
||||
"viewBox",
|
||||
`${
|
||||
element.crop.x / (element.crop.naturalWidth / uncroppedWidth)
|
||||
} ${
|
||||
element.crop.y / (element.crop.naturalHeight / uncroppedHeight)
|
||||
} ${width} ${height}`,
|
||||
);
|
||||
image.setAttribute("width", `${uncroppedWidth}`);
|
||||
image.setAttribute("height", `${uncroppedHeight}`);
|
||||
} else {
|
||||
image.setAttribute("width", "100%");
|
||||
image.setAttribute("height", "100%");
|
||||
}
|
||||
|
||||
symbol.appendChild(image);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import type { EmbedsValidationStatus } from "../types";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
@@ -408,7 +408,7 @@ export const _generateElementShape = (
|
||||
// initial position to it
|
||||
const points = element.points.length
|
||||
? element.points
|
||||
: [point<LocalPoint>(0, 0)];
|
||||
: [pointFrom<LocalPoint>(0, 0)];
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
shape = [
|
||||
|
||||
@@ -185,6 +185,11 @@ export const exportToCanvas = async (
|
||||
exportingFrame ?? null,
|
||||
appState.frameRendering ?? null,
|
||||
);
|
||||
// for canvas export, don't clip if exporting a specific frame as it would
|
||||
// clip the corners of the content
|
||||
if (exportingFrame) {
|
||||
frameRendering.clip = false;
|
||||
}
|
||||
|
||||
const elementsForRender = prepareElementsForRender({
|
||||
elements,
|
||||
@@ -351,6 +356,11 @@ export const exportToSvg = async (
|
||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||
width="${frame.width}"
|
||||
height="${frame.height}"
|
||||
${
|
||||
exportingFrame
|
||||
? ""
|
||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
||||
}
|
||||
>
|
||||
</rect>
|
||||
</clipPath>`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AppState, PointerCoords, Zoom } from "../types";
|
||||
import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
|
||||
import type { ExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
getCommonBounds,
|
||||
@@ -31,14 +31,28 @@ export const centerScrollOn = ({
|
||||
scenePoint,
|
||||
viewportDimensions,
|
||||
zoom,
|
||||
offsets,
|
||||
}: {
|
||||
scenePoint: PointerCoords;
|
||||
viewportDimensions: { height: number; width: number };
|
||||
zoom: Zoom;
|
||||
offsets?: Offsets;
|
||||
}) => {
|
||||
let scrollX =
|
||||
(viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.x;
|
||||
|
||||
scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
|
||||
|
||||
let scrollY =
|
||||
(viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
|
||||
scenePoint.y;
|
||||
|
||||
scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
|
||||
|
||||
return {
|
||||
scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x,
|
||||
scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y,
|
||||
scrollX,
|
||||
scrollY,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
isPoint,
|
||||
point,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
@@ -167,15 +167,15 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
? getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
point<Point>(element.x, element.y),
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
point(cx, cy),
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export const getControlPointsForBezierCurve = <
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
let currentP = point<P>(0, 0);
|
||||
let currentP = pointFrom<P>(0, 0);
|
||||
let index = 0;
|
||||
let minDistance = Infinity;
|
||||
let controlPoints: P[] | null = null;
|
||||
@@ -249,9 +249,9 @@ export const getControlPointsForBezierCurve = <
|
||||
}
|
||||
if (op === "bcurveTo") {
|
||||
const p0 = currentP;
|
||||
const p1 = point<P>(data[0], data[1]);
|
||||
const p2 = point<P>(data[2], data[3]);
|
||||
const p3 = point<P>(data[4], data[5]);
|
||||
const p1 = pointFrom<P>(data[0], data[1]);
|
||||
const p2 = pointFrom<P>(data[2], data[3]);
|
||||
const p3 = pointFrom<P>(data[4], data[5]);
|
||||
const distance = pointDistance(p3, endPoint);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
@@ -279,7 +279,7 @@ export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const tx = equation(t, 0);
|
||||
const ty = equation(t, 1);
|
||||
return point(tx, ty);
|
||||
return pointFrom(tx, ty);
|
||||
};
|
||||
|
||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
@@ -301,12 +301,12 @@ const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
||||
controlPoints[3],
|
||||
t,
|
||||
);
|
||||
pointsOnCurve.push(point(p[0], p[1]));
|
||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
||||
t -= 0.05;
|
||||
}
|
||||
if (pointsOnCurve.length) {
|
||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
||||
pointsOnCurve.push(point(endPoint[0], endPoint[1]));
|
||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
||||
}
|
||||
}
|
||||
return pointsOnCurve;
|
||||
@@ -393,24 +393,24 @@ export const aabbForElement = (
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = point(bbox.midX, bbox.midY);
|
||||
const center = pointFrom(bbox.midX, bbox.midY);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
point(bbox.minX, bbox.minY),
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
point(bbox.maxX, bbox.minY),
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
point(bbox.maxX, bbox.maxY),
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
point(bbox.minX, bbox.maxY),
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
@@ -442,14 +442,14 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
||||
pointInsideBounds(point(a[0], a[1]), b) ||
|
||||
pointInsideBounds(point(a[2], a[1]), b) ||
|
||||
pointInsideBounds(point(a[2], a[3]), b) ||
|
||||
pointInsideBounds(point(a[0], a[3]), b) ||
|
||||
pointInsideBounds(point(b[0], b[1]), a) ||
|
||||
pointInsideBounds(point(b[2], b[1]), a) ||
|
||||
pointInsideBounds(point(b[2], b[3]), a) ||
|
||||
pointInsideBounds(point(b[0], b[3]), a);
|
||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
||||
|
||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InclusiveRange } from "../math";
|
||||
import {
|
||||
point,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
rangeInclusive,
|
||||
rangeIntersection,
|
||||
@@ -228,52 +228,52 @@ export const getElementsCorners = (
|
||||
!boundingBoxCorners
|
||||
) {
|
||||
const leftMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y1 + halfHeight),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1 + halfHeight),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const topMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1 + halfWidth, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 + halfWidth, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const rightMid = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y1 + halfHeight),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1 + halfHeight),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomMid = pointRotateRads<GlobalPoint>(
|
||||
point(x1 + halfWidth, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1 + halfWidth, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const center = point<GlobalPoint>(cx, cy);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
result = omitCenter
|
||||
? [leftMid, topMid, rightMid, bottomMid]
|
||||
: [leftMid, topMid, rightMid, bottomMid, center];
|
||||
} else {
|
||||
const topLeft = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const topRight = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y1),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomLeft = pointRotateRads<GlobalPoint>(
|
||||
point(x1, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const bottomRight = pointRotateRads<GlobalPoint>(
|
||||
point(x2, y2),
|
||||
point(cx, cy),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const center = point<GlobalPoint>(cx, cy);
|
||||
const center = pointFrom<GlobalPoint>(cx, cy);
|
||||
|
||||
result = omitCenter
|
||||
? [topLeft, topRight, bottomLeft, bottomRight]
|
||||
@@ -287,18 +287,18 @@ export const getElementsCorners = (
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
const topLeft = point<GlobalPoint>(minX, minY);
|
||||
const topRight = point<GlobalPoint>(maxX, minY);
|
||||
const bottomLeft = point<GlobalPoint>(minX, maxY);
|
||||
const bottomRight = point<GlobalPoint>(maxX, maxY);
|
||||
const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
|
||||
const topLeft = pointFrom<GlobalPoint>(minX, minY);
|
||||
const topRight = pointFrom<GlobalPoint>(maxX, minY);
|
||||
const bottomLeft = pointFrom<GlobalPoint>(minX, maxY);
|
||||
const bottomRight = pointFrom<GlobalPoint>(maxX, maxY);
|
||||
const center = pointFrom<GlobalPoint>(minX + width / 2, minY + height / 2);
|
||||
|
||||
result = omitCenter
|
||||
? [topLeft, topRight, bottomLeft, bottomRight]
|
||||
: [topLeft, topRight, bottomLeft, bottomRight, center];
|
||||
}
|
||||
|
||||
return result.map((p) => point(round(p[0]), round(p[1])));
|
||||
return result.map((p) => pointFrom(round(p[0]), round(p[1])));
|
||||
};
|
||||
|
||||
const getReferenceElements = (
|
||||
@@ -375,8 +375,11 @@ export const getVisibleGaps = (
|
||||
horizontalGaps.push({
|
||||
startBounds,
|
||||
endBounds,
|
||||
startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
|
||||
endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
|
||||
startSide: [
|
||||
pointFrom(startMaxX, startMinY),
|
||||
pointFrom(startMaxX, startMaxY),
|
||||
],
|
||||
endSide: [pointFrom(endMinX, endMinY), pointFrom(endMinX, endMaxY)],
|
||||
length: endMinX - startMaxX,
|
||||
overlap: rangeIntersection(
|
||||
rangeInclusive(startMinY, startMaxY),
|
||||
@@ -415,8 +418,11 @@ export const getVisibleGaps = (
|
||||
verticalGaps.push({
|
||||
startBounds,
|
||||
endBounds,
|
||||
startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
|
||||
endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
|
||||
startSide: [
|
||||
pointFrom(startMinX, startMaxY),
|
||||
pointFrom(startMaxX, startMaxY),
|
||||
],
|
||||
endSide: [pointFrom(endMinX, endMinY), pointFrom(endMaxX, endMinY)],
|
||||
length: endMinY - startMaxY,
|
||||
overlap: rangeIntersection(
|
||||
rangeInclusive(startMinX, startMaxX),
|
||||
@@ -832,7 +838,7 @@ const createPointSnapLines = (
|
||||
}
|
||||
snapsX[key].push(
|
||||
...snap.points.map((p) =>
|
||||
point<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -849,7 +855,7 @@ const createPointSnapLines = (
|
||||
}
|
||||
snapsY[key].push(
|
||||
...snap.points.map((p) =>
|
||||
point<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -863,7 +869,7 @@ const createPointSnapLines = (
|
||||
points: dedupePoints(
|
||||
points
|
||||
.map((p) => {
|
||||
return point<GlobalPoint>(Number(key), p[1]);
|
||||
return pointFrom<GlobalPoint>(Number(key), p[1]);
|
||||
})
|
||||
.sort((a, b) => a[1] - b[1]),
|
||||
),
|
||||
@@ -876,7 +882,7 @@ const createPointSnapLines = (
|
||||
points: dedupePoints(
|
||||
points
|
||||
.map((p) => {
|
||||
return point<GlobalPoint>(p[0], Number(key));
|
||||
return pointFrom<GlobalPoint>(p[0], Number(key));
|
||||
})
|
||||
.sort((a, b) => a[0] - b[0]),
|
||||
),
|
||||
@@ -940,16 +946,16 @@ const createGapSnapLines = (
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [
|
||||
point(gapSnap.gap.startSide[0][0], gapLineY),
|
||||
point(minX, gapLineY),
|
||||
pointFrom(gapSnap.gap.startSide[0][0], gapLineY),
|
||||
pointFrom(minX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [
|
||||
point(maxX, gapLineY),
|
||||
point(gapSnap.gap.endSide[0][0], gapLineY),
|
||||
pointFrom(maxX, gapLineY),
|
||||
pointFrom(gapSnap.gap.endSide[0][0], gapLineY),
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -966,16 +972,16 @@ const createGapSnapLines = (
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [
|
||||
point(gapLineX, gapSnap.gap.startSide[0][1]),
|
||||
point(gapLineX, minY),
|
||||
pointFrom(gapLineX, gapSnap.gap.startSide[0][1]),
|
||||
pointFrom(gapLineX, minY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [
|
||||
point(gapLineX, maxY),
|
||||
point(gapLineX, gapSnap.gap.endSide[0][1]),
|
||||
pointFrom(gapLineX, maxY),
|
||||
pointFrom(gapLineX, gapSnap.gap.endSide[0][1]),
|
||||
],
|
||||
},
|
||||
);
|
||||
@@ -991,12 +997,15 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(startMaxX, gapLineY),
|
||||
pointFrom(endMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
|
||||
points: [pointFrom(endMaxX, gapLineY), pointFrom(minX, gapLineY)],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1011,12 +1020,18 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(maxX, gapLineY),
|
||||
pointFrom(startMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "horizontal",
|
||||
points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
|
||||
points: [
|
||||
pointFrom(startMaxX, gapLineY),
|
||||
pointFrom(endMinX, gapLineY),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1031,12 +1046,18 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, maxY),
|
||||
pointFrom(gapLineX, startMinY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, startMaxY),
|
||||
pointFrom(gapLineX, endMinY),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1051,12 +1072,15 @@ const createGapSnapLines = (
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
|
||||
points: [
|
||||
pointFrom(gapLineX, startMaxY),
|
||||
pointFrom(gapLineX, endMinY),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "gap",
|
||||
direction: "vertical",
|
||||
points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
|
||||
points: [pointFrom(gapLineX, endMaxY), pointFrom(gapLineX, minY)],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1070,7 +1094,7 @@ const createGapSnapLines = (
|
||||
return {
|
||||
...gapSnapLine,
|
||||
points: gapSnapLine.points.map((p) =>
|
||||
point(round(p[0]), round(p[1])),
|
||||
pointFrom(round(p[0]), round(p[1])),
|
||||
) as PointPair,
|
||||
};
|
||||
}),
|
||||
@@ -1120,35 +1144,35 @@ export const snapResizingElements = (
|
||||
if (transformHandle) {
|
||||
switch (transformHandle) {
|
||||
case "e": {
|
||||
selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "w": {
|
||||
selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY));
|
||||
break;
|
||||
}
|
||||
case "n": {
|
||||
selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY));
|
||||
break;
|
||||
}
|
||||
case "s": {
|
||||
selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
selectionSnapPoints.push(point(maxX, minY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, minY));
|
||||
break;
|
||||
}
|
||||
case "nw": {
|
||||
selectionSnapPoints.push(point(minX, minY));
|
||||
selectionSnapPoints.push(pointFrom(minX, minY));
|
||||
break;
|
||||
}
|
||||
case "se": {
|
||||
selectionSnapPoints.push(point(maxX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(maxX, maxY));
|
||||
break;
|
||||
}
|
||||
case "sw": {
|
||||
selectionSnapPoints.push(point(minX, maxY));
|
||||
selectionSnapPoints.push(pointFrom(minX, maxY));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1191,10 +1215,10 @@ export const snapResizingElements = (
|
||||
);
|
||||
|
||||
const corners: GlobalPoint[] = [
|
||||
point(x1, y1),
|
||||
point(x1, y2),
|
||||
point(x2, y1),
|
||||
point(x2, y2),
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x1, y2),
|
||||
pointFrom(x2, y1),
|
||||
pointFrom(x2, y2),
|
||||
];
|
||||
|
||||
getPointSnaps(
|
||||
@@ -1231,7 +1255,7 @@ export const snapNewElement = (
|
||||
}
|
||||
|
||||
const selectionSnapPoints: GlobalPoint[] = [
|
||||
point(origin.x + dragOffset.x, origin.y + dragOffset.y),
|
||||
pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y),
|
||||
];
|
||||
|
||||
const snapDistance = getSnapDistance(app.state.zoom.value);
|
||||
@@ -1331,7 +1355,7 @@ export const getSnapLinesAtPointer = (
|
||||
|
||||
verticalSnapLines.push({
|
||||
type: "pointer",
|
||||
points: [corner, point(corner[0], pointer.y)],
|
||||
points: [corner, pointFrom(corner[0], pointer.y)],
|
||||
direction: "vertical",
|
||||
});
|
||||
|
||||
@@ -1347,7 +1371,7 @@ export const getSnapLinesAtPointer = (
|
||||
|
||||
horizontalSnapLines.push({
|
||||
type: "pointer",
|
||||
points: [corner, point(pointer.x, corner[1])],
|
||||
points: [corner, pointFrom(pointer.x, corner[1])],
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user