diff --git a/.env.development b/.env.development index 85eb32533..2086b1a4b 100644 --- a/.env.development +++ b/.env.development @@ -17,8 +17,6 @@ VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","a # put these in your .env.local, or make sure you don't commit! # must be lowercase `true` when turned on # -# whether to enable Service Workers in development -VITE_APP_DEV_ENABLE_SW= # whether to disable live reload / HMR. Usuaully what you want to do when # debugging Service Workers. VITE_APP_DEV_DISABLE_LIVE_RELOAD= diff --git a/.eslintignore b/.eslintignore index 8578fb7d4..8b4f458de 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,4 @@ public/workbox packages/excalidraw/types examples/**/public dev-dist +coverage diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index 05367267c..5b684e32b 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -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" diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 04bddedef..fd8d779a7 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{ /> )} + diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index 00376c48f..b610ab7b5 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -1,7 +1,6 @@ import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; import { type AppState } from "../../packages/excalidraw/types"; import { throttleRAF } from "../../packages/excalidraw/utils"; -import type { LineSegment } from "../../packages/utils"; import { bootstrapCanvas, getNormalizedCanvasDimensions, @@ -13,12 +12,16 @@ import { TrashIcon, } from "../../packages/excalidraw/components/icons"; import { STORAGE_KEYS } from "../app_constants"; -import { isLineSegment } from "../../packages/excalidraw/element/typeChecks"; +import { + isLineSegment, + type GlobalPoint, + type LineSegment, +} from "../../packages/math"; const renderLine = ( context: CanvasRenderingContext2D, zoom: number, - segment: LineSegment, + segment: LineSegment, color: string, ) => { context.save(); @@ -47,10 +50,15 @@ const render = ( context: CanvasRenderingContext2D, appState: AppState, ) => { - frame.forEach((el) => { + frame.forEach((el: DebugElement) => { switch (true) { case isLineSegment(el.data): - renderLine(context, appState.zoom.value, el.data, el.color); + renderLine( + context, + appState.zoom.value, + el.data as LineSegment, + el.color, + ); break; } }); diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 468126b2b..df753c89b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -20,6 +20,7 @@ import { get, } from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import { SEARCH_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 +67,19 @@ const saveDataStateToLocalStorage = ( appState: AppState, ) => { try { + const _appState = clearAppStateForLocalStorage(appState); + + if (_appState.openSidebar?.name === SEARCH_SIDEBAR.name) { + _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) { diff --git a/package.json b/package.json index f4a23235a..be906c840 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "excalidraw-app", "packages/excalidraw", "packages/utils", + "packages/math", "examples/excalidraw", "examples/excalidraw/*" ], @@ -20,8 +21,8 @@ "@types/react-dom": "18.2.0", "@types/socket.io-client": "3.0.0", "@vitejs/plugin-react": "3.1.0", - "@vitest/coverage-v8": "0.33.0", - "@vitest/ui": "0.32.2", + "@vitest/coverage-v8": "2.0.5", + "@vitest/ui": "2.0.5", "chai": "4.3.6", "dotenv": "16.0.1", "eslint-config-prettier": "8.5.0", @@ -35,13 +36,13 @@ "prettier": "2.6.2", "rewire": "6.0.0", "typescript": "4.9.4", - "vite": "5.0.12", - "vite-plugin-checker": "0.6.1", + "vite": "5.4.2", + "vite-plugin-checker": "0.7.2", "vite-plugin-ejs": "1.7.0", "vite-plugin-pwa": "0.17.4", - "vite-plugin-svgr": "2.4.0", - "vitest": "1.6.0", - "vitest-canvas-mock": "0.3.2" + "vite-plugin-svgr": "4.2.0", + "vitest": "2.0.5", + "vitest-canvas-mock": "0.3.3" }, "engines": { "node": "18.0.0 - 20.x.x" @@ -82,6 +83,7 @@ "clean-install": "yarn rm:node_modules && yarn install" }, "resolutions": { - "@types/react": "18.2.0" + "@types/react": "18.2.0", + "strip-ansi": "6.0.1" } } diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index c7ff38e3f..35fabcaf9 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -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 } from "../../math"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index eb81e715d..d19bfa59d 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({ perform: (elements, appState, formData, app) => { // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints( - appState, - app.scene.getNonDeletedElementsMap(), - ); + // TODO: Invariants should be checked here instead of duplicateSelectedPoints() + try { + const newAppState = LinearElementEditor.duplicateSelectedPoints( + appState, + app.scene.getNonDeletedElementsMap(), + ); - if (!ret) { + return { + elements, + appState: newAppState, + storeAction: StoreAction.CAPTURE, + }; + } catch { return false; } - - return { - elements, - appState: ret.appState, - storeAction: StoreAction.CAPTURE, - }; } return { diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 95cc19c96..f19ab981f 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -6,7 +6,6 @@ import { done } from "../components/icons"; import { t } from "../i18n"; import { register } from "./register"; import { mutateElement } from "../element/mutateElement"; -import { isPathALoop } from "../math"; import { LinearElementEditor } from "../element/linearElementEditor"; import { maybeBindLinearElement, @@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks"; import type { AppState } from "../types"; import { resetCursor } from "../cursor"; import { StoreAction } from "../store"; +import { point } from "../../math"; +import { isPathALoop } from "../shapes"; export const actionFinalize = register({ name: "finalize", @@ -112,10 +113,10 @@ export const actionFinalize = register({ const linePoints = multiPointElement.points; const firstPoint = linePoints[0]; mutateElement(multiPointElement, { - points: linePoints.map((point, index) => + points: linePoints.map((p, index) => index === linePoints.length - 1 - ? ([firstPoint[0], firstPoint[1]] as const) - : point, + ? point(firstPoint[0], firstPoint[1]) + : p, ), }); } diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 6bcf24972..0fa705f23 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { AppClassProperties, AppState, Point, Primitive } from "../types"; +import type { AppClassProperties, AppState, Primitive } from "../types"; import type { StoreActionType } from "../store"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, @@ -115,6 +115,8 @@ import { } from "../element/binding"; import { mutateElbowArrow } from "../element/routing"; import { LinearElementEditor } from "../element/linearElementEditor"; +import type { LocalPoint } from "../../math"; +import { point, vector } from "../../math"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({ newElement, elementsMap, [finalStartPoint, finalEndPoint].map( - (point) => - [point[0] - newElement.x, point[1] - newElement.y] as Point, + (p): LocalPoint => + point(p[0] - newElement.x, p[1] - newElement.y), ), - [0, 0], + vector(0, 0), { ...(startElement && newElement.startBinding ? { diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts new file mode 100644 index 000000000..6072fd30c --- /dev/null +++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts @@ -0,0 +1,51 @@ +import { KEYS } from "../keys"; +import { register } from "./register"; +import type { AppState } from "../types"; +import { searchIcon } from "../components/icons"; +import { StoreAction } from "../store"; +import { CLASSES, SEARCH_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 === SEARCH_SIDEBAR.name) { + const searchInput = + app.excalidrawContainerValue.container?.querySelector( + `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`, + ); + + if (searchInput?.matches(":focus")) { + return { + appState: { ...appState, openSidebar: null }, + storeAction: StoreAction.NONE, + }; + } + + searchInput?.focus(); + return false; + } + + return { + appState: { + ...appState, + openSidebar: { name: SEARCH_SIDEBAR.name }, + 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, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 092060425..eff5de297 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; + +export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index a5c3bad66..025d91037 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -51,7 +51,8 @@ export type ShortcutName = > | "saveScene" | "imageExport" - | "commandPalette"; + | "commandPalette" + | "searchMenu"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -112,6 +113,7 @@ const shortcutMap: Record = { saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], toggleShortcuts: [getShortcutKey("?")], + searchMenu: [getShortcutKey("CtrlOrCmd+F")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 2d0275bb3..15364d21f 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -137,7 +137,8 @@ export type ActionName = | "wrapTextInContainer" | "commandPalette" | "autoResize" - | "elementStats"; + | "elementStats" + | "searchMenu"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -191,7 +192,8 @@ export interface Action { | "history" | "menu" | "collab" - | "hyperlink"; + | "hyperlink" + | "search_menu"; action?: string; predicate?: ( appState: Readonly, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index faad34057..cb80c6cd8 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit< objectsSnapModeEnabled: false, userToFollow: null, followedBy: new Set(), + searchMatches: [], }; }; @@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (< objectsSnapModeEnabled: { browser: true, export: false, server: false }, userToFollow: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false }, + searchMatches: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 2a80a4ba7..87d2b34fe 100644 --- a/packages/excalidraw/charts.ts +++ b/packages/excalidraw/charts.ts @@ -1,3 +1,5 @@ +import type { Radians } from "../math"; +import { point } from "../math"; import { COLOR_PALETTE, DEFAULT_CHART_COLOR_INDEX, @@ -203,7 +205,7 @@ const chartXLabels = ( x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2, y: y + BAR_GAP / 2, width: BAR_WIDTH, - angle: 5.87, + angle: 5.87 as Radians, fontSize: 16, textAlign: "center", verticalAlign: "top", @@ -258,10 +260,7 @@ const chartLines = ( x, y, width: chartWidth, - points: [ - [0, 0], - [chartWidth, 0], - ], + points: [point(0, 0), point(chartWidth, 0)], }); const yLine = newLinearElement({ @@ -272,10 +271,7 @@ const chartLines = ( x, y, height: chartHeight, - points: [ - [0, 0], - [0, -chartHeight], - ], + points: [point(0, 0), point(0, -chartHeight)], }); const maxLine = newLinearElement({ @@ -288,10 +284,7 @@ const chartLines = ( strokeStyle: "dotted", width: chartWidth, opacity: GRID_OPACITY, - points: [ - [0, 0], - [chartWidth, 0], - ], + points: [point(0, 0), point(chartWidth, 0)], }); return [xLine, yLine, maxLine]; @@ -448,10 +441,7 @@ const chartTypeLine = ( height: cy, strokeStyle: "dotted", opacity: GRID_OPACITY, - points: [ - [0, 0], - [0, cy], - ], + points: [point(0, 0), point(0, cy)], }); }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6357336c1..8276b88f4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -210,12 +210,6 @@ import { isElementCompletelyInViewport, isElementInViewport, } from "../element/sizeHelpers"; -import { - distance2d, - getCornerRadius, - getGridPoint, - isPathALoop, -} from "../math"; import { calculateScrollCenter, getElementsWithinSelection, @@ -230,7 +224,13 @@ import type { ScrollBars, } from "../scene/types"; import { getStateForZoom } from "../scene/zoom"; -import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes"; +import { + findShapeByKey, + getBoundTextShape, + getCornerRadius, + getElementShape, + isPathALoop, +} from "../shapes"; import { getSelectionBoxShape } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import type { @@ -386,6 +386,7 @@ import { getReferenceSnapPoints, SnapCache, isGridModeEnabled, + getGridPoint, } from "../snapping"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; @@ -439,6 +440,9 @@ import { FlowChartNavigator, getLinkDirectionFromKey, } from "../element/flowchart"; +import { searchItemInFocusAtom } from "./SearchMenu"; +import type { LocalPoint, Radians } from "../../math"; +import { point, pointDistance, vector } from "../../math"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -545,6 +549,7 @@ class App extends React.Component { public scene: Scene; public fonts: Fonts; public renderer: Renderer; + public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -552,7 +557,7 @@ class App extends React.Component { public id: string; private store: Store; private history: History; - private excalidrawContainerValue: { + public excalidrawContainerValue: { container: HTMLDivElement | null; id: string; }; @@ -679,6 +684,7 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + this.visibleElements = []; this.store = new Store(); this.history = new History(); @@ -1477,6 +1483,7 @@ class App extends React.Component { newElementId: this.state.newElement?.id, pendingImageElementId: this.state.pendingImageElementId, }); + this.visibleElements = visibleElements; const allElementsMap = this.scene.getNonDeletedElementsMap(); @@ -2292,6 +2299,9 @@ class App extends React.Component { storeAction: StoreAction.UPDATE, }); + // clear the shape and image cache so that any images in initialData + // can be loaded fresh + this.clearImageShapeCache(); // FontFaceSet loadingdone event we listen on may not always // fire (looking at you Safari), so on init we manually load all // fonts and rerender scene text elements once done. This also @@ -2357,6 +2367,16 @@ class App extends React.Component { 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 = @@ -3671,15 +3691,7 @@ class App extends React.Component { 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(); @@ -3793,7 +3805,7 @@ class App extends React.Component { }, ); - private getEditorUIOffsets = (): { + public getEditorUIOffsets = (): { top: number; right: number; bottom: number; @@ -4844,7 +4856,7 @@ class App extends React.Component { this.getElementHitThreshold(), ); - return isPointInShape([x, y], selectionShape); + return isPointInShape(point(x, y), selectionShape); } // take bound text element into consideration for hit collision as well @@ -5035,7 +5047,7 @@ class App extends React.Component { containerId: shouldBindToContainer ? container?.id : undefined, groupIds: container?.groupIds ?? [], lineHeight, - angle: container?.angle ?? 0, + angle: container?.angle ?? (0 as Radians), frameId: topLayerFrame ? topLayerFrame.id : null, }); @@ -5203,7 +5215,7 @@ class App extends React.Component { element, this.scene.getNonDeletedElementsMap(), this.state, - [scenePointer.x, scenePointer.y], + point(scenePointer.x, scenePointer.y), this.device.editor.isMobile, ) ); @@ -5214,11 +5226,12 @@ class App extends React.Component { event: React.PointerEvent, isTouchScreen: boolean, ) => { - const draggedDistance = distance2d( - this.lastPointerDownEvent!.clientX, - this.lastPointerDownEvent!.clientY, - this.lastPointerUpEvent!.clientX, - this.lastPointerUpEvent!.clientY, + const draggedDistance = pointDistance( + point( + this.lastPointerDownEvent!.clientX, + this.lastPointerDownEvent!.clientY, + ), + point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY), ); if ( !this.hitLinkElement || @@ -5237,7 +5250,7 @@ class App extends React.Component { this.hitLinkElement, elementsMap, this.state, - [lastPointerDownCoords.x, lastPointerDownCoords.y], + point(lastPointerDownCoords.x, lastPointerDownCoords.y), this.device.editor.isMobile, ); const lastPointerUpCoords = viewportCoordsToSceneCoords( @@ -5248,7 +5261,7 @@ class App extends React.Component { this.hitLinkElement, elementsMap, this.state, - [lastPointerUpCoords.x, lastPointerUpCoords.y], + point(lastPointerUpCoords.x, lastPointerUpCoords.y), this.device.editor.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { @@ -5497,17 +5510,18 @@ class App extends React.Component { // if we haven't yet created a temp point and we're beyond commit-zone // threshold, add a point if ( - distance2d( - scenePointerX - rx, - scenePointerY - ry, - lastPoint[0], - lastPoint[1], + pointDistance( + point(scenePointerX - rx, scenePointerY - ry), + lastPoint, ) >= LINE_CONFIRM_THRESHOLD ) { mutateElement( multiElement, { - points: [...points, [scenePointerX - rx, scenePointerY - ry]], + points: [ + ...points, + point(scenePointerX - rx, scenePointerY - ry), + ], }, false, ); @@ -5519,11 +5533,9 @@ class App extends React.Component { } else if ( points.length > 2 && lastCommittedPoint && - distance2d( - scenePointerX - rx, - scenePointerY - ry, - lastCommittedPoint[0], - lastCommittedPoint[1], + pointDistance( + point(scenePointerX - rx, scenePointerY - ry), + lastCommittedPoint, ) < LINE_CONFIRM_THRESHOLD ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); @@ -5570,10 +5582,10 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), [ ...points.slice(0, -1), - [ + point( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, - ], + ), ], undefined, undefined, @@ -5589,10 +5601,10 @@ class App extends React.Component { { points: [ ...points.slice(0, -1), - [ + point( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, - ], + ), ], }, false, @@ -5817,17 +5829,15 @@ class App extends React.Component { } }; - const distance = distance2d( - pointerDownState.lastCoords.x, - pointerDownState.lastCoords.y, - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y), + point(scenePointer.x, scenePointer.y), ); const threshold = this.getElementHitThreshold(); - const point = { ...pointerDownState.lastCoords }; + const p = { ...pointerDownState.lastCoords }; let samplingInterval = 0; while (samplingInterval <= distance) { - const hitElements = this.getElementsAtPosition(point.x, point.y); + const hitElements = this.getElementsAtPosition(p.x, p.y); processElements(hitElements); // Exit since we reached current point @@ -5839,12 +5849,10 @@ class App extends React.Component { samplingInterval = Math.min(samplingInterval + threshold, distance); const distanceRatio = samplingInterval / distance; - const nextX = - (1 - distanceRatio) * point.x + distanceRatio * scenePointer.x; - const nextY = - (1 - distanceRatio) * point.y + distanceRatio * scenePointer.y; - point.x = nextX; - point.y = nextY; + const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x; + const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y; + p.x = nextX; + p.y = nextY; } pointerDownState.lastCoords.x = scenePointer.x; @@ -5970,6 +5978,16 @@ class App extends React.Component { 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 @@ -6325,7 +6343,7 @@ class App extends React.Component { this.hitLinkElement, this.scene.getNonDeletedElementsMap(), this.state, - [scenePointer.x, scenePointer.y], + point(scenePointer.x, scenePointer.y), ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); @@ -6398,8 +6416,16 @@ class App extends React.Component { } 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(); } @@ -7008,7 +7034,7 @@ class App extends React.Component { simulatePressure, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, - points: [[0, 0]], + points: [point(0, 0)], pressures: simulatePressure ? [] : [event.pressure], }); @@ -7216,11 +7242,9 @@ class App extends React.Component { if ( multiElement.points.length > 1 && lastCommittedPoint && - distance2d( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - lastCommittedPoint[0], - lastCommittedPoint[1], + pointDistance( + point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry), + lastCommittedPoint, ) < LINE_CONFIRM_THRESHOLD ) { this.actionManager.executeAction(actionFinalize); @@ -7321,7 +7345,7 @@ class App extends React.Component { }; }); mutateElement(element, { - points: [...element.points, [0, 0]], + points: [...element.points, point(0, 0)], }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, @@ -7573,11 +7597,9 @@ class App extends React.Component { this.state.activeTool.type === "line") ) { if ( - distance2d( - pointerCoords.x, - pointerCoords.y, - pointerDownState.origin.x, - pointerDownState.origin.y, + pointDistance( + point(pointerCoords.x, pointerCoords.y), + point(pointerDownState.origin.x, pointerDownState.origin.y), ) < DRAGGING_THRESHOLD ) { return; @@ -7926,7 +7948,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], pressures, }, false, @@ -7955,7 +7977,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], }, false, ); @@ -7963,8 +7985,8 @@ class App extends React.Component { mutateElbowArrow( newElement, elementsMap, - [...points.slice(0, -1), [dx, dy]], - [0, 0], + [...points.slice(0, -1), point(dx, dy)], + vector(0, 0), undefined, { isDragging: true, @@ -7975,7 +7997,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points.slice(0, -1), [dx, dy]], + points: [...points.slice(0, -1), point(dx, dy)], }, false, ); @@ -8284,9 +8306,9 @@ class App extends React.Component { : [...newElement.pressures, childEvent.pressure]; mutateElement(newElement, { - points: [...points, [dx, dy]], + points: [...points, point(dx, dy)], pressures, - lastCommittedPoint: [dx, dy], + lastCommittedPoint: point(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -8333,7 +8355,10 @@ class App extends React.Component { mutateElement(newElement, { points: [ ...newElement.points, - [pointerCoords.x - newElement.x, pointerCoords.y - newElement.y], + point( + pointerCoords.x - newElement.x, + pointerCoords.y - newElement.y, + ), ], }); this.setState({ @@ -8643,11 +8668,9 @@ class App extends React.Component { if (isEraserActive(this.state) && pointerStart && pointerEnd) { this.eraserTrail.endPath(); - const draggedDistance = distance2d( - pointerStart.clientX, - pointerStart.clientY, - pointerEnd.clientX, - pointerEnd.clientY, + const draggedDistance = pointDistance( + point(pointerStart.clientX, pointerStart.clientY), + point(pointerEnd.clientX, pointerEnd.clientY), ); if (draggedDistance === 0) { diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 336906a4b..6fd99dd46 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -106,7 +106,7 @@ const ColorPickerPopupContent = ({ return ( { // refocus due to eye dropper focusPickerContent(); diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 36c9a9a68..e732acfb5 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon"; import { SHAPES } from "../../shapes"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { actionClearCanvas, actionLink } from "../../actions"; +import { + actionClearCanvas, + actionLink, + actionToggleSearchMenu, +} from "../../actions"; import { jotaiStore } from "../../jotai"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import type { CommandPaletteItem } from "./types"; @@ -382,6 +386,15 @@ function CommandPaletteInner({ } }, }, + { + label: t("search.title"), + category: DEFAULT_CATEGORIES.app, + icon: searchIcon, + viewMode: true, + perform: () => { + actionManager.executeAction(actionToggleSearchMenu); + }, + }, { label: t("labels.changeStroke"), keywords: ["color", "outline"], diff --git a/packages/excalidraw/components/DefaultSidebar.tsx b/packages/excalidraw/components/DefaultSidebar.tsx index 78b03007f..5cd588933 100644 --- a/packages/excalidraw/components/DefaultSidebar.tsx +++ b/packages/excalidraw/components/DefaultSidebar.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import { 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 +9,8 @@ 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 { t } from "../i18n"; const DefaultSidebarTrigger = withInternalFallback( "DefaultSidebarTrigger", @@ -68,8 +69,7 @@ export const DefaultSidebar = Object.assign( return ( void }) => { label={t("stats.fullTitle")} shortcuts={[getShortcutKey("Alt+/")]} /> + )} + @@ -362,16 +365,21 @@ const LayerUI = ({ const renderSidebars = () => { return ( - { - trackEvent( - "sidebar", - `toggleDock (${docked ? "dock" : "undock"})`, - `(${device.editor.isMobile ? "mobile" : "desktop"})`, - ); - }} - /> + <> + {appState.openSidebar?.name === SEARCH_SIDEBAR.name && ( + + )} + { + trackEvent( + "sidebar", + `toggleDock (${docked ? "dock" : "undock"})`, + `(${device.editor.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> + ); }; diff --git a/packages/excalidraw/components/SearchMenu.scss b/packages/excalidraw/components/SearchMenu.scss new file mode 100644 index 000000000..ae6bb5647 --- /dev/null +++ b/packages/excalidraw/components/SearchMenu.scss @@ -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); + } + } +} diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx new file mode 100644 index 000000000..58dd622dd --- /dev/null +++ b/packages/excalidraw/components/SearchMenu.tsx @@ -0,0 +1,701 @@ +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"; + +const searchQueryAtom = atom(""); +export const searchItemInFocusAtom = atom(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(null); + + const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope); + const searchQuery = inputValue.trim() as SearchQuery; + + const [isSearching, setIsSearching] = useState(false); + + const [searchMatches, setSearchMatches] = useState({ + nonce: null, + items: [], + }); + const searchedQueryRef = useRef(null); + const lastSceneNonceRef = useRef(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 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, + }); + + const isTextTiny = + match.textElement.fontSize * app.state.zoom.value < 12; + + 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[1]; + + if (isTextTiny && app.state.zoom.value >= 1) { + zoomOptions = { fitToViewport: true }; + } else if (isTextTiny || app.state.zoom.value > 1) { + zoomOptions = { fitToContent: true }; + } + + app.scrollToContent(matchAsElement, { + animate: true, + duration: 300, + ...zoomOptions, + }); + } + } + } + }, [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 ( +
+
+ { + 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 + /> +
+ +
+ {searchMatches.items.length > 0 && ( + <> + {focusIndex !== null && focusIndex > -1 ? ( +
+ {focusIndex + 1} / {matchCount} +
+ ) : ( +
{matchCount}
+ )} +
+ + +
+ + )} + + {searchMatches.items.length === 0 && + searchQuery && + searchedQueryRef.current && ( +
{t("search.noMatch")}
+ )} +
+ + +
+ ); +}; + +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 ( +
{ + if (props.highlighted) { + ref?.scrollIntoView({ behavior: "auto", block: "nearest" }); + } + }} + > +
+ {preview.flatMap((text, idx) => ( + {idx === 2 ? {text} : text} + ))} +
+
+ ); +}; + +interface MatchListProps { + matches: SearchMatches; + onItemClick: (index: number) => void; + focusIndex: number | null; + searchQuery: SearchQuery; +} + +const MatchListBase = (props: MatchListProps) => { + return ( +
+ {props.matches.items.map((searchMatch, index) => ( + props.onItemClick(index)} + /> + ))} +
+ ); +}; + +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, +); diff --git a/packages/excalidraw/components/SearchSidebar.tsx b/packages/excalidraw/components/SearchSidebar.tsx new file mode 100644 index 000000000..7cb93ac5f --- /dev/null +++ b/packages/excalidraw/components/SearchSidebar.tsx @@ -0,0 +1,29 @@ +import { SEARCH_SIDEBAR } from "../constants"; +import { t } from "../i18n"; +import { SearchMenu } from "./SearchMenu"; +import { Sidebar } from "./Sidebar/Sidebar"; + +export const SearchSidebar = () => { + return ( + + + +
+ {t("search.title")} +
+
+ +
+
+ ); +}; diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index e83c2f02d..b7a98a437 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement"; import { getBoundTextElement } from "../../element/textElement"; import { isArrowElement, isElbowArrow } from "../../element/typeChecks"; import type { ExcalidrawElement } from "../../element/types"; -import { degreeToRadian, radianToDegree } from "../../math"; import { angleIcon } from "../icons"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; +import type { Degrees } from "../../../math"; +import { degreesToRadians, radiansToDegrees } from "../../../math"; interface AngleProps { element: ExcalidrawElement; @@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType = ({ } if (nextValue !== undefined) { - const nextAngle = degreeToRadian(nextValue); + const nextAngle = degreesToRadians(nextValue as Degrees); mutateElement(latestElement, { angle: nextAngle, }); @@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType = ({ } const originalAngleInDegrees = - Math.round(radianToDegree(origElement.angle) * 100) / 100; + Math.round(radiansToDegrees(origElement.angle) * 100) / 100; const changeInDegrees = Math.round(accumulatedChange); let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360; if (shouldChangeByStepSize) { @@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType = ({ nextAngleInDegrees = nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees; - const nextAngle = degreeToRadian(nextAngleInDegrees); + const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees); mutateElement(latestElement, { angle: nextAngle, @@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => { !isInGroup(el) && isPropertyEditable(el, "angle"), ); const angles = editableLatestIndividualElements.map( - (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100, + (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100, ); const value = new Set(angles).size === 1 ? angles[0] : "Mixed"; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 516b3aaf6..f36200585 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -13,13 +13,14 @@ import type { NonDeletedSceneElementsMap, } from "../../element/types"; import type Scene from "../../scene/Scene"; -import type { AppState, Point } from "../../types"; +import type { AppState } from "../../types"; import DragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getElementsInAtomicUnit, resizeElement } from "./utils"; import type { AtomicUnit } from "./utils"; import { MIN_WIDTH_OR_HEIGHT } from "../../constants"; +import { point, type GlobalPoint } from "../../../math"; interface MultiDimensionProps { property: "width" | "height"; @@ -104,7 +105,7 @@ const resizeGroup = ( nextHeight: number, initialHeight: number, aspectRatio: number, - anchor: Point, + anchor: GlobalPoint, property: MultiDimensionProps["property"], latestElements: ExcalidrawElement[], originalElements: ExcalidrawElement[], @@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType< nextHeight, initialHeight, aspectRatio, - [x1, y1], + point(x1, y1), property, latestElements, originalElements, @@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType< nextHeight, initialHeight, aspectRatio, - [x1, y1], + point(x1, y1), property, latestElements, originalElements, diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 652a6b82f..d0f001663 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -4,7 +4,6 @@ import type { NonDeletedExcalidrawElement, NonDeletedSceneElementsMap, } from "../../element/types"; -import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; @@ -14,6 +13,7 @@ import { useMemo } from "react"; import { getElementsInAtomicUnit, moveElement } from "./utils"; import type { AtomicUnit } from "./utils"; import type { AppState } from "../../types"; +import { point, pointRotateRads } from "../../../math"; interface MultiPositionProps { property: "x" | "y"; @@ -43,11 +43,9 @@ const moveElements = ( origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -98,11 +96,9 @@ const moveGroupTo = ( latestElement.y + latestElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - latestElement.x, - latestElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(latestElement.x, latestElement.y), + point(cx, cy), latestElement.angle, ); @@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType< origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -246,7 +240,11 @@ const MultiPosition = ({ const [el] = elementsInUnit; const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2]; - const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle); + const [topLeftX, topLeftY] = pointRotateRads( + point(el.x, el.y), + point(cx, cy), + el.angle, + ); return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100; }), diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index 511aa9c24..8e7671685 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -1,10 +1,10 @@ import type { ElementsMap, ExcalidrawElement } from "../../element/types"; -import { rotate } from "../../math"; import StatsDragInput from "./DragInput"; import type { DragInputCallbackType } from "./DragInput"; import { getStepSizedValue, moveElement } from "./utils"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; +import { point, pointRotateRads } from "../../../math"; interface PositionProps { property: "x" | "y"; @@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ origElement.x + origElement.width / 2, origElement.y + origElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - origElement.x, - origElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(origElement.x, origElement.y), + point(cx, cy), origElement.angle, ); @@ -94,11 +92,9 @@ const Position = ({ scene, appState, }: PositionProps) => { - const [topLeftX, topLeftY] = rotate( - element.x, - element.y, - element.x + element.width / 2, - element.y + element.height / 2, + const [topLeftX, topLeftY] = pointRotateRads( + point(element.x, element.y), + point(element.x + element.width / 2, element.y + element.height / 2), element.angle, ); const value = diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index e2abb2fd8..f281931c8 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -19,12 +19,13 @@ import type { ExcalidrawLinearElement, ExcalidrawTextElement, } from "../../element/types"; -import { degreeToRadian, rotate } from "../../math"; import { getTextEditor, updateTextEditor } from "../../tests/queries/dom"; import { getCommonBounds, isTextElement } from "../../element"; import { API } from "../../tests/helpers/api"; import { actionGroup } from "../../actions"; import { isInGroup } from "../../groups"; +import type { Degrees } from "../../../math"; +import { degreesToRadians, point, pointRotateRads } from "../../../math"; const { h } = window; const mouse = new Pointer("mouse"); @@ -46,7 +47,9 @@ const testInputProperty = ( expect(input.value).toBe(initialValue.toString()); UI.updateInput(input, String(nextValue)); if (property === "angle") { - expect(element[property]).toBe(degreeToRadian(Number(nextValue))); + expect(element[property]).toBe( + degreesToRadians(Number(nextValue) as Degrees), + ); } else if (property === "fontSize" && isTextElement(element)) { expect(element[property]).toBe(Number(nextValue)); } else if (property !== "fontSize") { @@ -260,11 +263,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -281,11 +282,9 @@ describe("stats for a generic element", () => { testInputProperty(rectangle, "angle", "A", 0, 45); - let [newTopLeftX, newTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + let [newTopLeftX, newTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -294,11 +293,9 @@ describe("stats for a generic element", () => { testInputProperty(rectangle, "angle", "A", 45, 66); - [newTopLeftX, newTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + [newTopLeftX, newTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); expect(newTopLeftX.toString()).not.toEqual(xInput.value); @@ -313,11 +310,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); testInputProperty(rectangle, "width", "W", rectangle.width, 400); @@ -325,11 +320,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - let [currentTopLeftX, currentTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + let [currentTopLeftX, currentTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); expect(currentTopLeftX).toBeCloseTo(topLeftX, 4); @@ -340,11 +333,9 @@ describe("stats for a generic element", () => { rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2, ]; - [currentTopLeftX, currentTopLeftY] = rotate( - rectangle.x, - rectangle.y, - cx, - cy, + [currentTopLeftX, currentTopLeftY] = pointRotateRads( + point(rectangle.x, rectangle.y), + point(cx, cy), rectangle.angle, ); @@ -642,7 +633,7 @@ describe("stats for multiple elements", () => { UI.updateInput(angle, "40"); - const angleInRadian = degreeToRadian(40); + const angleInRadian = degreesToRadians(40 as Degrees); expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4); expect(text?.angle).toBeCloseTo(angleInRadian, 4); expect(frame.angle).toBe(0); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index f2e9765dc..f6cf16708 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,3 +1,5 @@ +import type { Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; import { bindOrUnbindLinearElements, updateBoundElements, @@ -30,7 +32,6 @@ import { getElementsInGroup, isInGroup, } from "../../groups"; -import { rotate } from "../../math"; import type Scene from "../../scene/Scene"; import type { AppState } from "../../types"; import { getFontString } from "../../utils"; @@ -229,23 +230,19 @@ export const moveElement = ( originalElement.x + originalElement.width / 2, originalElement.y + originalElement.height / 2, ]; - const [topLeftX, topLeftY] = rotate( - originalElement.x, - originalElement.y, - cx, - cy, + const [topLeftX, topLeftY] = pointRotateRads( + point(originalElement.x, originalElement.y), + point(cx, cy), originalElement.angle, ); const changeInX = newTopLeftX - topLeftX; const changeInY = newTopLeftY - topLeftY; - const [x, y] = rotate( - newTopLeftX, - newTopLeftY, - cx + changeInX, - cy + changeInY, - -originalElement.angle, + const [x, y] = pointRotateRads( + point(newTopLeftX, newTopLeftY), + point(cx + changeInX, cy + changeInY), + -originalElement.angle as Radians, ); mutateElement( diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx index d6192b295..f0c63770a 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx @@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types"; import { ArrowRightIcon } from "../icons"; import "./TTDDialog.scss"; -import { isFiniteNumber } from "../../utils"; import { atom, useAtom } from "jotai"; import { trackEvent } from "../../analytics"; import { InlineIcon } from "../InlineIcon"; import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut"; +import { isFiniteNumber } from "../../../math"; const MIN_PROMPT_LENGTH = 3; const MAX_PROMPT_LENGTH = 1000; diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index 952c97592..c46cd2fe8 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/packages/excalidraw/components/TextField.scss @@ -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; + } } } diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index 463ea2c2d..c5bdc8260 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -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( selectOnRender, onKeyDown, isRedacted = false, + icon, + className, ...rest }, ref, @@ -47,6 +51,8 @@ export const TextField = forwardRef( 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( return (
{ innerRef.current?.focus(); }} > -
{label}
+ {icon} + {label &&
{label}
}
{ setAppState({ showHyperlinkPopup: false }); @@ -416,7 +419,7 @@ const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [clientX, clientY]: Point, + [clientX, clientY]: GlobalPoint, ): Boolean => { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( { clientX, clientY }, diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index 88dc916ef..3f451a230 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -1,3 +1,5 @@ +import type { GlobalPoint, Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; import { MIME_TYPES } from "../../constants"; import type { Bounds } from "../../element/bounds"; import { getElementAbsoluteCoords } from "../../element/bounds"; @@ -6,9 +8,8 @@ import type { ElementsMap, NonDeletedExcalidrawElement, } from "../../element/types"; -import { rotate } from "../../math"; import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; -import type { AppState, Point, UIAppState } from "../../types"; +import type { AppState, UIAppState } from "../../types"; export const EXTERNAL_LINK_IMG = document.createElement("img"); EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( @@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( export const getLinkHandleFromCoords = ( [x1, y1, x2, y2]: Bounds, - angle: number, + angle: Radians, appState: Pick, ): Bounds => { const size = DEFAULT_LINK_SIZE; @@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = ( const x = x2 + dashedLineMargin - centeringOffset; const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; - const [rotatedX, rotatedY] = rotate( - x + linkWidth / 2, - y + linkHeight / 2, - centerX, - centerY, + const [rotatedX, rotatedY] = pointRotateRads( + point(x + linkWidth / 2, y + linkHeight / 2), + point(centerX, centerY), angle, ); return [ @@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [x, y]: Point, + [x, y]: GlobalPoint, ) => { const threshold = 4 / appState.zoom.value; const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); @@ -73,7 +72,7 @@ export const isPointHittingLink = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, - [x, y]: Point, + [x, y]: GlobalPoint, isMobile: boolean, ) => { if (!element.link || appState.selectedElementIds[element.id]) { @@ -86,5 +85,5 @@ export const isPointHittingLink = ( ) { return true; } - return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); + return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y)); }; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index f4a3a94c2..17be3e7fc 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon( , tablerIconProps, ); + +export const upIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 26ef26000..bb3059db5 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -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 ( + { + actionManager.executeAction(actionToggleSearchMenu); + }} + shortcut={getShortcutFromShortcutName("searchMenu")} + aria-label={t("search.title")} + className={opts?.className} + > + {t("search.title")} + + ); +}; +SearchMenu.displayName = "SearchMenu"; + export const Help = () => { const { t } = useI18n(); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 9601807f6..31982d4fb 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -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", }; /** @@ -382,6 +383,10 @@ export const DEFAULT_SIDEBAR = { defaultTab: LIBRARY_SIDEBAR_TAB, } as const; +export const SEARCH_SIDEBAR = { + name: "search", +}; + export const LIBRARY_DISABLED_TYPES = new Set([ "iframe", "embeddable", diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index f0c4c5d09..673106700 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -387,7 +387,7 @@ body.excalidraw-cursor-resize * { .App-menu__left { overflow-y: auto; padding: 0.75rem; - width: 200px; + width: 12.5rem; box-sizing: border-box; position: absolute; } diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 0ed6a7544..69c28afda 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -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; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 8fbe1e236..62652066f 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -40,11 +40,7 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { - getUpdatedTimestamp, - isFiniteNumber, - updateActiveTool, -} from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import type { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, getContainerElement } from "../element/textElement"; @@ -58,6 +54,8 @@ import { getNormalizedGridStep, getNormalizedZoom, } from "../scene"; +import type { LocalPoint, Radians } from "../../math"; +import { isFiniteNumber, point } from "../../math"; type RestoredAppState = Omit< AppState, @@ -152,7 +150,7 @@ const restoreElementWithProperties = < roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness, opacity: element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity, - angle: element.angle || 0, + angle: element.angle || (0 as Radians), x: extra.x ?? element.x ?? 0, y: extra.y ?? element.y ?? 0, strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor, @@ -266,10 +264,7 @@ const restoreElement = ( let y = element.y; let points = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 - ? [ - [0, 0], - [element.width, element.height], - ] + ? [point(0, 0), point(element.width, element.height)] : element.points; if (points[0][0] !== 0 || points[0][1] !== 0) { @@ -293,14 +288,11 @@ const restoreElement = ( }); case "arrow": { const { startArrowhead = null, endArrowhead = "arrow" } = element; - let x = element.x; - let y = element.y; - let points = // migrate old arrow model to new one + let x: number | undefined = element.x; + let y: number | undefined = element.y; + let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one !Array.isArray(element.points) || element.points.length < 2 - ? [ - [0, 0], - [element.width, element.height], - ] + ? [point(0, 0), point(element.width, element.height)] : element.points; if (points[0][0] !== 0 || points[0][1] !== 0) { diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index bdb37bc96..d930cb923 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -2,6 +2,7 @@ import { vi } from "vitest"; import type { ExcalidrawElementSkeleton } from "./transform"; import { convertToExcalidrawElements } from "./transform"; import type { ExcalidrawArrowElement } from "../element/types"; +import { point } from "../../math"; const opts = { regenerateIds: false }; @@ -911,10 +912,7 @@ describe("Test Transform", () => { x: 111.262, y: 57, strokeWidth: 2, - points: [ - [0, 0], - [272.985, 0], - ], + points: [point(0, 0), point(272.985, 0)], label: { text: "How are you?", fontSize: 20, @@ -937,7 +935,7 @@ describe("Test Transform", () => { x: 77.017, y: 79, strokeWidth: 2, - points: [[0, 0]], + points: [point(0, 0)], label: { text: "Friendship", fontSize: 20, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index cbddafb70..6573abd0d 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -53,6 +53,7 @@ import { randomId } from "../random"; import { syncInvalidIndices } from "../fractionalIndex"; import { getLineHeight } from "../fonts"; import { isArrowElement } from "../element/typeChecks"; +import { point, type LocalPoint } from "../../math"; export type ValidLinearElement = { type: "arrow" | "line"; @@ -417,7 +418,7 @@ const bindLinearElementToElement = ( const endPointIndex = linearElement.points.length - 1; const delta = 0.5; - const newPoints = cloneJSON(linearElement.points) as [number, number][]; + const newPoints = cloneJSON(linearElement.points); // left to right so shift the arrow towards right if ( @@ -535,10 +536,7 @@ export const convertToExcalidrawElements = ( excalidrawElement = newLinearElement({ width, height, - points: [ - [0, 0], - [width, height], - ], + points: [point(0, 0), point(width, height)], ...element, }); @@ -551,10 +549,7 @@ export const convertToExcalidrawElements = ( width, height, endArrowhead: "arrow", - points: [ - [0, 0], - [width, height], - ], + points: [point(0, 0), point(width, height)], ...element, type: "arrow", }); diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 2dae8d854..fe820723f 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -1,8 +1,8 @@ -import * as GA from "../ga"; -import * as GAPoint from "../gapoints"; -import * as GADirection from "../gadirections"; -import * as GALine from "../galines"; -import * as GATransform from "../gatransforms"; +import * as GA from "../../math/ga/ga"; +import * as GAPoint from "../../math/ga/gapoints"; +import * as GADirection from "../../math/ga/gadirections"; +import * as GALine from "../../math/ga/galines"; +import * as GATransform from "../../math/ga/gatransforms"; import type { ExcalidrawBindableElement, @@ -10,7 +10,6 @@ import type { ExcalidrawRectangleElement, ExcalidrawDiamondElement, ExcalidrawEllipseElement, - ExcalidrawFreeDrawElement, ExcalidrawImageElement, ExcalidrawFrameLikeElement, ExcalidrawIframeLikeElement, @@ -26,11 +25,12 @@ import type { ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, + ExcalidrawRectanguloidElement, } from "./types"; import type { Bounds } from "./bounds"; -import { getElementAbsoluteCoords } from "./bounds"; -import type { AppState, Point } from "../types"; +import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; +import type { AppState } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; import { @@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor"; import { arrayToMap, tupleToCoors } from "../utils"; import { KEYS } from "../keys"; import { getBoundTextElement, handleBindTextResize } from "./textElement"; -import { getElementShape } from "../shapes"; -import { - aabbForElement, - clamp, - distanceSq2d, - getCenterForBounds, - getCenterForElement, - pointInsideBounds, - pointToVector, - rotatePoint, -} from "../math"; +import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { compareHeading, HEADING_DOWN, @@ -72,7 +62,18 @@ import { vectorToHeading, type Heading, } from "./heading"; -import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry"; +import type { LocalPoint, Radians } from "../../math"; +import { + lineSegment, + point, + pointRotateRads, + type GlobalPoint, + vectorFromPoint, + pointFromPair, + pointDistanceSq, + clamp, +} from "../../math"; +import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; export type SuggestedBinding = | NonDeleted @@ -649,7 +650,7 @@ export const updateBoundElements = ( update, ): update is NonNullable<{ index: number; - point: Point; + point: LocalPoint; isDragging?: boolean; }> => update !== null, ); @@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = ( }; export const getHeadingForElbowArrowSnap = ( - point: Readonly, - otherPoint: Readonly, + p: Readonly, + otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined | null, aabb: Bounds | undefined | null, elementsMap: ElementsMap, - origPoint: Point, + origPoint: GlobalPoint, ): Heading => { - const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point)); + const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); if (!bindableElement || !aabb) { return otherPointHeading; @@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = ( if (!distance) { return vectorToHeading( - pointToVector(point, getCenterForElement(bindableElement)), + vectorFromPoint( + p, + point( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, + ), + ), ); } - const pointHeading = headingForPointFromElement(bindableElement, aabb, point); + const pointHeading = headingForPointFromElement(bindableElement, aabb, p); return pointHeading; }; const getDistanceForBinding = ( - point: Readonly, + point: Readonly, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, ) => { @@ -745,89 +752,87 @@ const getDistanceForBinding = ( }; export const bindPointToSnapToElementOutline = ( - point: Readonly, - otherPoint: Readonly, + p: Readonly, + otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined, elementsMap: ElementsMap, -): Point => { +): GlobalPoint => { const aabb = bindableElement && aabbForElement(bindableElement); if (bindableElement && aabb) { // TODO: Dirty hacks until tangents are properly calculated - const heading = headingForPointFromElement(bindableElement, aabb, point); + const heading = headingForPointFromElement(bindableElement, aabb, p); const intersections = [ - ...intersectElementWithLine( + ...(intersectElementWithLine( bindableElement, - [point[0], point[1] - 2 * bindableElement.height], - [point[0], point[1] + 2 * bindableElement.height], + point(p[0], p[1] - 2 * bindableElement.height), + point(p[0], p[1] + 2 * bindableElement.height), FIXED_BINDING_DISTANCE, elementsMap, - ), - ...intersectElementWithLine( + ) ?? []), + ...(intersectElementWithLine( bindableElement, - [point[0] - 2 * bindableElement.width, point[1]], - [point[0] + 2 * bindableElement.width, point[1]], + point(p[0] - 2 * bindableElement.width, p[1]), + point(p[0] + 2 * bindableElement.width, p[1]), FIXED_BINDING_DISTANCE, elementsMap, - ), + ) ?? []), ]; const isVertical = compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_RIGHT); const dist = Math.abs( - distanceToBindableElement(bindableElement, point, elementsMap), + distanceToBindableElement(bindableElement, p, elementsMap), ); const isInner = isVertical ? dist < bindableElement.width * -0.1 : dist < bindableElement.height * -0.1; - intersections.sort( - (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point), - ); + intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p)); return isInner ? headingToMidBindPoint(otherPoint, bindableElement, aabb) : intersections.filter((i) => isVertical - ? Math.abs(point[1] - i[1]) < 0.1 - : Math.abs(point[0] - i[0]) < 0.1, + ? Math.abs(p[1] - i[1]) < 0.1 + : Math.abs(p[0] - i[0]) < 0.1, )[0] ?? point; } - return point; + return p; }; const headingToMidBindPoint = ( - point: Point, + p: GlobalPoint, bindableElement: ExcalidrawBindableElement, aabb: Bounds, -): Point => { +): GlobalPoint => { const center = getCenterForBounds(aabb); - const heading = vectorToHeading(pointToVector(point, center)); + const heading = vectorToHeading(vectorFromPoint(p, center)); switch (true) { case compareHeading(heading, HEADING_UP): - return rotatePoint( - [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]], + return pointRotateRads( + point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]), center, bindableElement.angle, ); case compareHeading(heading, HEADING_RIGHT): - return rotatePoint( - [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1], + return pointRotateRads( + point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1), center, bindableElement.angle, ); case compareHeading(heading, HEADING_DOWN): - return rotatePoint( - [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]], + return pointRotateRads( + point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]), center, bindableElement.angle, ); default: - return rotatePoint( - [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1], + return pointRotateRads( + point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1), center, bindableElement.angle, ); @@ -836,22 +841,25 @@ const headingToMidBindPoint = ( export const avoidRectangularCorner = ( element: ExcalidrawBindableElement, - p: Point, -): Point => { - const center = getCenterForElement(element); - const nonRotatedPoint = rotatePoint(p, center, -element.angle); + p: GlobalPoint, +): GlobalPoint => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { // Top left if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) { - return rotatePoint( - [element.x - FIXED_BINDING_DISTANCE, element.y], + return pointRotateRads( + point(element.x - FIXED_BINDING_DISTANCE, element.y), center, element.angle, ); } - return rotatePoint( - [element.x, element.y - FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x, element.y - FIXED_BINDING_DISTANCE), center, element.angle, ); @@ -861,14 +869,14 @@ export const avoidRectangularCorner = ( ) { // Bottom left if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) { - return rotatePoint( - [element.x, element.y + element.height + FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE), center, element.angle, ); } - return rotatePoint( - [element.x - FIXED_BINDING_DISTANCE, element.y + element.height], + return pointRotateRads( + point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height), center, element.angle, ); @@ -881,20 +889,20 @@ export const avoidRectangularCorner = ( nonRotatedPoint[0] - element.x < element.width + FIXED_BINDING_DISTANCE ) { - return rotatePoint( - [ + return pointRotateRads( + point( element.x + element.width, element.y + element.height + FIXED_BINDING_DISTANCE, - ], + ), center, element.angle, ); } - return rotatePoint( - [ + return pointRotateRads( + point( element.x + element.width + FIXED_BINDING_DISTANCE, element.y + element.height, - ], + ), center, element.angle, ); @@ -907,14 +915,14 @@ export const avoidRectangularCorner = ( nonRotatedPoint[0] - element.x < element.width + FIXED_BINDING_DISTANCE ) { - return rotatePoint( - [element.x + element.width, element.y - FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE), center, element.angle, ); } - return rotatePoint( - [element.x + element.width + FIXED_BINDING_DISTANCE, element.y], + return pointRotateRads( + point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y), center, element.angle, ); @@ -925,12 +933,12 @@ export const avoidRectangularCorner = ( export const snapToMid = ( element: ExcalidrawBindableElement, - p: Point, + p: GlobalPoint, tolerance: number = 0.05, -): Point => { +): GlobalPoint => { const { x, y, width, height, angle } = element; - const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point; - const nonRotated = rotatePoint(p, center, -angle); + const center = point(x + width / 2 - 0.1, y + height / 2 - 0.1); + const nonRotated = pointRotateRads(p, center, -angle as Radians); // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -943,22 +951,30 @@ export const snapToMid = ( nonRotated[1] < center[1] + verticalThrehsold ) { // LEFT - return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle); + return pointRotateRads( + point(x - FIXED_BINDING_DISTANCE, center[1]), + center, + angle, + ); } else if ( nonRotated[1] <= y + height / 2 && nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] < center[0] + horizontalThrehsold ) { // TOP - return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle); + return pointRotateRads( + point(center[0], y - FIXED_BINDING_DISTANCE), + center, + angle, + ); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] < center[1] + verticalThrehsold ) { // RIGHT - return rotatePoint( - [x + width + FIXED_BINDING_DISTANCE, center[1]], + return pointRotateRads( + point(x + width + FIXED_BINDING_DISTANCE, center[1]), center, angle, ); @@ -968,8 +984,8 @@ export const snapToMid = ( nonRotated[0] < center[0] + horizontalThrehsold ) { // DOWN - return rotatePoint( - [center[0], y + height + FIXED_BINDING_DISTANCE], + return pointRotateRads( + point(center[0], y + height + FIXED_BINDING_DISTANCE), center, angle, ); @@ -984,7 +1000,7 @@ const updateBoundPoint = ( binding: PointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, -): Point | null => { +): LocalPoint | null => { if ( binding == null || // We only need to update the other end if this is a 2 point line element @@ -1006,15 +1022,15 @@ const updateBoundPoint = ( startOrEnd === "startBinding" ? "start" : "end", elementsMap, ).fixedPoint; - const globalMidPoint = [ + const globalMidPoint = point( bindableElement.x + bindableElement.width / 2, bindableElement.y + bindableElement.height / 2, - ] as Point; - const global = [ + ); + const global = point( bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.y + fixedPoint[1] * bindableElement.height, - ] as Point; - const rotatedGlobal = rotatePoint( + ); + const rotatedGlobal = pointRotateRads( global, globalMidPoint, bindableElement.angle, @@ -1040,7 +1056,7 @@ const updateBoundPoint = ( elementsMap, ); - let newEdgePoint: Point; + let newEdgePoint: GlobalPoint; // The linear element was not originally pointing inside the bound shape, // we can point directly at the focus point @@ -1054,7 +1070,7 @@ const updateBoundPoint = ( binding.gap, elementsMap, ); - if (intersections.length === 0) { + if (!intersections || intersections.length === 0) { // This should never happen, since focusPoint should always be // inside the element, but just in case, bail out newEdgePoint = focusPointAbsolute; @@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement, elementsMap, ); - const globalMidPoint = [ + const globalMidPoint = point( bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2, - ] as Point; - const nonRotatedSnappedGlobalPoint = rotatePoint( + ); + const nonRotatedSnappedGlobalPoint = pointRotateRads( snappedPoint, globalMidPoint, - -hoveredElement.angle, - ) as Point; + -hoveredElement.angle as Radians, + ); return { fixedPoint: normalizeFixedPoint([ @@ -1320,8 +1336,9 @@ export const bindingBorderTest = ( const threshold = maxBindingGap(element, element.width, element.height); const shape = getElementShape(element, elementsMap); return ( - isPointOnShape([x, y], shape, threshold) || - (fullShape === true && pointInsideBounds([x, y], aabbForElement(element))) + isPointOnShape(point(x, y), shape, threshold) || + (fullShape === true && + pointInsideBounds(point(x, y), aabbForElement(element))) ); }; @@ -1339,7 +1356,7 @@ export const maxBindingGap = ( export const distanceToBindableElement = ( element: ExcalidrawBindableElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { switch (element.type) { @@ -1359,19 +1376,13 @@ export const distanceToBindableElement = ( }; const distanceToRectangle = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawTextElement - | ExcalidrawFreeDrawElement - | ExcalidrawImageElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - point: Point, + element: ExcalidrawRectanguloidElement, + p: GlobalPoint, elementsMap: ElementsMap, ): number => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( element, - point, + p, elementsMap, ); return Math.max( @@ -1382,7 +1393,7 @@ const distanceToRectangle = ( const distanceToDiamond = ( element: ExcalidrawDiamondElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( @@ -1396,7 +1407,7 @@ const distanceToDiamond = ( const distanceToEllipse = ( element: ExcalidrawEllipseElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): number => { const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); @@ -1405,7 +1416,7 @@ const distanceToEllipse = ( const ellipseParamsForTest = ( element: ExcalidrawEllipseElement, - point: Point, + point: GlobalPoint, elementsMap: ElementsMap, ): [GA.Point, GA.Line] => { const [, pointRel, hwidth, hheight] = pointRelativeToElement( @@ -1467,7 +1478,7 @@ const ellipseParamsForTest = ( // so we only need to perform hit tests for the positive quadrant. const pointRelativeToElement = ( element: ExcalidrawElement, - pointTuple: Point, + pointTuple: GlobalPoint, elementsMap: ElementsMap, ): [GA.Point, GA.Point, number, number] => { const point = GAPoint.from(pointTuple); @@ -1516,9 +1527,9 @@ const coordsCenter = ( const determineFocusDistance = ( element: ExcalidrawBindableElement, // Point on the line, in absolute coordinates - a: Point, + a: GlobalPoint, // Another point on the line, in absolute coordinates (closer to element) - b: Point, + b: GlobalPoint, elementsMap: ElementsMap, ): number => { const relateToCenter = relativizationToElementCenter(element, elementsMap); @@ -1559,13 +1570,13 @@ const determineFocusPoint = ( // The oriented, relative distance from the center of `element` of the // returned focusPoint focus: number, - adjecentPoint: Point, + adjecentPoint: GlobalPoint, elementsMap: ElementsMap, -): Point => { +): GlobalPoint => { if (focus === 0) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); - return GAPoint.toTuple(center); + return pointFromPair(GAPoint.toTuple(center)); } const relateToCenter = relativizationToElementCenter(element, elementsMap); const adjecentPointRel = GATransform.apply( @@ -1589,7 +1600,9 @@ const determineFocusPoint = ( point = findFocusPointForEllipse(element, focus, adjecentPointRel); break; } - return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)); + return pointFromPair( + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ); }; // Returns 2 or 0 intersection points between line going through `a` and `b` @@ -1597,15 +1610,15 @@ const determineFocusPoint = ( const intersectElementWithLine = ( element: ExcalidrawBindableElement, // Point on the line, in absolute coordinates - a: Point, + a: GlobalPoint, // Another point on the line, in absolute coordinates - b: Point, + b: GlobalPoint, // If given, the element is inflated by this value gap: number = 0, elementsMap: ElementsMap, -): Point[] => { +): GlobalPoint[] | undefined => { if (isRectangularElement(element)) { - return segmentIntersectRectangleElement(element, [a, b], gap); + return segmentIntersectRectangleElement(element, lineSegment(a, b), gap); } const relateToCenter = relativizationToElementCenter(element, elementsMap); @@ -1619,8 +1632,14 @@ const intersectElementWithLine = ( aRel, gap, ); - return intersections.map((point) => - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + return intersections.map( + (point) => + pointFromPair( + GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + ), + // pointFromArray( + // , + // ), ); }; @@ -2173,12 +2192,18 @@ export class BindableElement { export const getGlobalFixedPointForBindableElement = ( fixedPointRatio: [number, number], element: ExcalidrawBindableElement, -) => { +): GlobalPoint => { const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); - return rotatePoint( - [element.x + element.width * fixedX, element.y + element.height * fixedY], - getCenterForElement(element), + return pointRotateRads( + point( + element.x + element.width * fixedX, + element.y + element.height * fixedY, + ), + point( + element.x + element.width / 2, + element.y + element.height / 2, + ), element.angle, ); }; @@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = ( const getGlobalFixedPoints = ( arrow: ExcalidrawElbowArrowElement, elementsMap: ElementsMap, -) => { +): [GlobalPoint, GlobalPoint] => { const startElement = arrow.startBinding && (elementsMap.get(arrow.startBinding.elementId) as @@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = ( (elementsMap.get(arrow.endBinding.elementId) as | ExcalidrawBindableElement | undefined); - const startPoint: Point = + const startPoint = startElement && arrow.startBinding ? getGlobalFixedPointForBindableElement( arrow.startBinding.fixedPoint, startElement as ExcalidrawBindableElement, ) - : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]]; - const endPoint: Point = + : point( + arrow.x + arrow.points[0][0], + arrow.y + arrow.points[0][1], + ); + const endPoint = endElement && arrow.endBinding ? getGlobalFixedPointForBindableElement( arrow.endBinding.fixedPoint, endElement as ExcalidrawBindableElement, ) - : [ + : point( arrow.x + arrow.points[arrow.points.length - 1][0], arrow.y + arrow.points[arrow.points.length - 1][1], - ]; + ); return [startPoint, endPoint]; }; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 3d9a4840d..f5ca0e901 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -1,3 +1,5 @@ +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; import { ROUNDNESS } from "../constants"; import { arrayToMap } from "../utils"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; @@ -123,9 +125,9 @@ describe("getElementBounds", () => { a: 0.6447741904932416, }), points: [ - [0, 0] as [number, number], - [67.33984375, 92.48828125] as [number, number], - [-102.7890625, 52.15625] as [number, number], + point(0, 0), + point(67.33984375, 92.48828125), + point(-102.7890625, 52.15625), ], } as ExcalidrawLinearElement; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 588e24eb0..16f431855 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -7,10 +7,10 @@ import type { ExcalidrawTextElementWithContainer, ElementsMap, } from "./types"; -import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; +import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Drawable, Op } from "roughjs/bin/core"; -import type { AppState, Point } from "../types"; +import type { AppState } from "../types"; import { generateRoughOptions } from "../scene/Shape"; import { isArrowElement, @@ -22,9 +22,24 @@ import { import { rescalePoints } from "../points"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; -import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; -import { arrayToMap } from "../utils"; +import { arrayToMap, invariant } from "../utils"; +import type { + Degrees, + GlobalPoint, + LineSegment, + LocalPoint, + Radians, +} from "../../math"; +import { + degreesToRadians, + lineSegment, + point, + pointDistance, + pointFromArray, + pointRotateRads, +} from "../../math"; +import type { Mutable } from "../utility-types"; export type RectangleBox = { x: number; @@ -97,7 +112,11 @@ export class ElementBounds { if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => - rotate(x, y, cx - element.x, cy - element.y, element.angle), + pointRotateRads( + point(x, y), + point(cx - element.x, cy - element.y), + element.angle, + ), ), ); @@ -110,10 +129,26 @@ export class ElementBounds { } else if (isLinearElement(element)) { bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap); } else if (element.type === "diamond") { - const [x11, y11] = rotate(cx, y1, cx, cy, element.angle); - const [x12, y12] = rotate(cx, y2, cx, cy, element.angle); - const [x22, y22] = rotate(x1, cy, cx, cy, element.angle); - const [x21, y21] = rotate(x2, cy, cx, cy, element.angle); + const [x11, y11] = pointRotateRads( + point(cx, y1), + point(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + point(cx, y2), + point(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + point(x1, cy), + point(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + point(x2, cy), + point(cx, cy), + element.angle, + ); const minX = Math.min(x11, x12, x22, x21); const minY = Math.min(y11, y12, y22, y21); const maxX = Math.max(x11, x12, x22, x21); @@ -128,10 +163,26 @@ export class ElementBounds { const hh = Math.hypot(h * cos, w * sin); bounds = [cx - ww, cy - hh, cx + ww, cy + hh]; } else { - const [x11, y11] = rotate(x1, y1, cx, cy, element.angle); - const [x12, y12] = rotate(x1, y2, cx, cy, element.angle); - const [x22, y22] = rotate(x2, y2, cx, cy, element.angle); - const [x21, y21] = rotate(x2, y1, cx, cy, element.angle); + const [x11, y11] = pointRotateRads( + point(x1, y1), + point(cx, cy), + element.angle, + ); + const [x12, y12] = pointRotateRads( + point(x1, y2), + point(cx, cy), + element.angle, + ); + const [x22, y22] = pointRotateRads( + point(x2, y2), + point(cx, cy), + element.angle, + ); + const [x21, y21] = pointRotateRads( + point(x2, y1), + point(cx, cy), + element.angle, + ); const minX = Math.min(x11, x12, x22, x21); const minY = Math.min(y11, y12, y22, y21); const maxX = Math.max(x11, x12, x22, x21); @@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = ( ? getContainerElement(element, elementsMap) : null; if (isArrowElement(container)) { - const coords = LinearElementEditor.getBoundTextElementPosition( + const { x, y } = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, elementsMap, ); return [ - coords.x, - coords.y, - coords.x + element.width, - coords.y + element.height, - coords.x + element.width / 2, - coords.y + element.height / 2, + x, + y, + x + element.width, + y + element.height, + x + element.width / 2, + y + element.height / 2, ]; } } @@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = ( export const getElementLineSegments = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): [Point, Point][] => { +): LineSegment[] => { const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( element, elementsMap, ); - const center: Point = [cx, cy]; + const center: GlobalPoint = point(cx, cy); if (isLinearElement(element) || isFreeDrawElement(element)) { - const segments: [Point, Point][] = []; + const segments: LineSegment[] = []; let i = 0; while (i < element.points.length - 1) { - segments.push([ - rotatePoint( - [ - element.points[i][0] + element.x, - element.points[i][1] + element.y, - ] as Point, - center, - element.angle, + segments.push( + lineSegment( + pointRotateRads( + point( + element.points[i][0] + element.x, + element.points[i][1] + element.y, + ), + center, + element.angle, + ), + pointRotateRads( + point( + element.points[i + 1][0] + element.x, + element.points[i + 1][1] + element.y, + ), + center, + element.angle, + ), ), - rotatePoint( - [ - element.points[i + 1][0] + element.x, - element.points[i + 1][1] + element.y, - ] as Point, - center, - element.angle, - ), - ]); + ); i++; } @@ -246,40 +299,40 @@ export const getElementLineSegments = ( [cx, y2], [x1, cy], [x2, cy], - ] as Point[] - ).map((point) => rotatePoint(point, center, element.angle)); + ] as GlobalPoint[] + ).map((point) => pointRotateRads(point, center, element.angle)); if (element.type === "diamond") { return [ - [n, w], - [n, e], - [s, w], - [s, e], + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), ]; } if (element.type === "ellipse") { return [ - [n, w], - [n, e], - [s, w], - [s, e], - [n, w], - [n, e], - [s, w], - [s, e], + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), + lineSegment(n, w), + lineSegment(n, e), + lineSegment(s, w), + lineSegment(s, e), ]; } return [ - [nw, ne], - [sw, se], - [nw, sw], - [ne, se], - [nw, e], - [sw, e], - [ne, w], - [se, w], + lineSegment(nw, ne), + lineSegment(sw, se), + lineSegment(nw, sw), + lineSegment(ne, se), + lineSegment(nw, e), + lineSegment(sw, e), + lineSegment(ne, w), + lineSegment(se, w), ]; }; @@ -386,10 +439,10 @@ const solveQuadratic = ( }; const getCubicBezierCurveBound = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, + p0: GlobalPoint, + p1: GlobalPoint, + p2: GlobalPoint, + p3: GlobalPoint, ): Bounds => { const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]); const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]); @@ -415,9 +468,9 @@ const getCubicBezierCurveBound = ( export const getMinMaxXYFromCurvePathOps = ( ops: Op[], - transformXY?: (x: number, y: number) => [number, number], + transformXY?: (p: GlobalPoint) => GlobalPoint, ): Bounds => { - let currentP: Point = [0, 0]; + let currentP: GlobalPoint = point(0, 0); const { minX, minY, maxX, maxY } = ops.reduce( (limits, { op, data }) => { @@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = ( // move, bcurveTo, lineTo, and curveTo if (op === "move") { // change starting point - currentP = data as unknown as Point; + const p: GlobalPoint | undefined = pointFromArray(data); + invariant(p != null, "Op data is not a point"); + currentP = p; // move operation does not draw anything; so, it always // returns false } else if (op === "bcurveTo") { - const _p1 = [data[0], data[1]] as Point; - const _p2 = [data[2], data[3]] as Point; - const _p3 = [data[4], data[5]] as Point; + const _p1 = point(data[0], data[1]); + const _p2 = point(data[2], data[3]); + const _p3 = point(data[4], data[5]); - const p1 = transformXY ? transformXY(..._p1) : _p1; - const p2 = transformXY ? transformXY(..._p2) : _p2; - const p3 = transformXY ? transformXY(..._p3) : _p3; + const p1 = transformXY ? transformXY(_p1) : _p1; + const p2 = transformXY ? transformXY(_p2) : _p2; + const p3 = transformXY ? transformXY(_p3) : _p3; - const p0 = transformXY ? transformXY(...currentP) : currentP; + const p0 = transformXY ? transformXY(currentP) : currentP; currentP = _p3; const [minX, minY, maxX, maxY] = getCubicBezierCurveBound( @@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => { }; /** @returns number in degrees */ -export const getArrowheadAngle = (arrowhead: Arrowhead): number => { +export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => { switch (arrowhead) { case "bar": - return 90; + return 90 as Degrees; case "arrow": - return 20; + return 20 as Degrees; default: - return 25; + return 25 as Degrees; } }; @@ -533,19 +588,24 @@ export const getArrowheadPoints = ( const index = position === "start" ? 1 : ops.length - 1; const data = ops[index].data; - const p3 = [data[4], data[5]] as Point; - const p2 = [data[2], data[3]] as Point; - const p1 = [data[0], data[1]] as Point; + + invariant(data.length === 6, "Op data length is not 6"); + + const p3 = point(data[4], data[5]); + const p2 = point(data[2], data[3]); + const p1 = point(data[0], data[1]); // We need to find p0 of the bezier curve. // It is typically the last point of the previous // curve; it can also be the position of moveTo operation. const prevOp = ops[index - 1]; - let p0: Point = [0, 0]; + let p0 = point(0, 0); if (prevOp.op === "move") { - p0 = prevOp.data as unknown as Point; + const p = pointFromArray(prevOp.data); + invariant(p != null, "Op data is not a point"); + p0 = p; } else if (prevOp.op === "bcurveTo") { - p0 = [prevOp.data[4], prevOp.data[5]]; + p0 = point(prevOp.data[4], prevOp.data[5]); } // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 @@ -610,8 +670,16 @@ export const getArrowheadPoints = ( const angle = getArrowheadAngle(arrowhead); // Return points - const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); - const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); + const [x3, y3] = pointRotateRads( + point(xs, ys), + point(x2, y2), + ((-angle * Math.PI) / 180) as Radians, + ); + const [x4, y4] = pointRotateRads( + point(xs, ys), + point(x2, y2), + degreesToRadians(angle), + ); if (arrowhead === "diamond" || arrowhead === "diamond_outline") { // point opposite to the arrowhead point @@ -621,12 +689,10 @@ export const getArrowheadPoints = ( if (position === "start") { const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0]; - [ox, oy] = rotate( - x2 + minSize * 2, - y2, - x2, - y2, - Math.atan2(py - y2, px - x2), + [ox, oy] = pointRotateRads( + point(x2 + minSize * 2, y2), + point(x2, y2), + Math.atan2(py - y2, px - x2) as Radians, ); } else { const [px, py] = @@ -634,12 +700,10 @@ export const getArrowheadPoints = ( ? element.points[element.points.length - 2] : [0, 0]; - [ox, oy] = rotate( - x2 - minSize * 2, - y2, - x2, - y2, - Math.atan2(y2 - py, x2 - px), + [ox, oy] = pointRotateRads( + point(x2 - minSize * 2, y2), + point(x2, y2), + Math.atan2(y2 - py, x2 - px) as Radians, ); } @@ -665,7 +729,10 @@ const generateLinearElementShape = ( return "linearPath"; })(); - return generator[method](element.points as Mutable[], options); + return generator[method]( + element.points as Mutable[] as RoughPoint[], + options, + ); }; const getLinearElementRotatedBounds = ( @@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = ( if (element.points.length < 2) { const [pointX, pointY] = element.points[0]; - const [x, y] = rotate( - element.x + pointX, - element.y + pointY, - cx, - cy, + const [x, y] = pointRotateRads( + point(element.x + pointX, element.y + pointY), + point(cx, cy), element.angle, ); @@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = ( const cachedShape = ShapeCache.get(element)?.[0]; const shape = cachedShape ?? generateLinearElementShape(element); const ops = getCurvePathOps(shape); - const transformXY = (x: number, y: number) => - rotate(element.x + x, element.y + y, cx, cy, element.angle); + const transformXY = ([x, y]: GlobalPoint) => + pointRotateRads( + point(element.x + x, element.y + y), + point(cx, cy), + element.angle, + ); const res = getMinMaxXYFromCurvePathOps(ops, transformXY); let coords: Bounds = [res[0], res[1], res[2], res[3]]; if (boundTextElement) { @@ -861,7 +930,10 @@ export const getClosestElementBounds = ( const elementsMap = arrayToMap(elements); elements.forEach((element) => { const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); - const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); + const distance = pointDistance( + point((x1 + x2) / 2, (y1 + y2) / 2), + point(from.x, from.y), + ); if (distance < minDistance) { minDistance = distance; @@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({ -scrollY + height / zoom.value, ]; }; + +export const getCenterForBounds = (bounds: Bounds): GlobalPoint => + point( + bounds[0] + (bounds[2] - bounds[0]) / 2, + bounds[1] + (bounds[3] - bounds[1]) / 2, + ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 954326ca0..7eafa7dfa 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,14 +1,11 @@ -import { isPathALoop, isPointWithinBounds } from "../math"; - import type { ElementsMap, ExcalidrawElement, ExcalidrawRectangleElement, } from "./types"; - import { getElementBounds } from "./bounds"; import type { FrameNameBounds } from "../types"; -import type { Polygon, GeometricShape } from "../../utils/geometry/shape"; +import type { GeometricShape } from "../../utils/geometry/shape"; import { getPolygonShape } from "../../utils/geometry/shape"; import { isPointInShape, isPointOnShape } from "../../utils/collision"; import { isTransparent } from "../utils"; @@ -18,7 +15,9 @@ import { isImageElement, isTextElement, } from "./typeChecks"; -import { getBoundTextShape } from "../shapes"; +import { getBoundTextShape, isPathALoop } from "../shapes"; +import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; +import { isPointWithinBounds, point } from "../../math"; export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { @@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => { return isDraggableFromInside || isImageElement(element); }; -export type HitTestArgs = { +export type HitTestArgs = { x: number; y: number; element: ExcalidrawElement; - shape: GeometricShape; + shape: GeometricShape; threshold?: number; frameNameBound?: FrameNameBounds | null; }; -export const hitElementItself = ({ +export const hitElementItself = ({ x, y, element, shape, threshold = 10, frameNameBound = null, -}: HitTestArgs) => { +}: HitTestArgs) => { let hit = shouldTestInside(element) ? // Since `inShape` tests STRICTLY againt the insides of a shape // we would need `onShape` as well to include the "borders" - isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold) - : isPointOnShape([x, y], shape, threshold); + isPointInShape(point(x, y), shape) || + isPointOnShape(point(x, y), shape, threshold) + : isPointOnShape(point(x, y), shape, threshold); // hit test against a frame's name if (!hit && frameNameBound) { - hit = isPointInShape([x, y], { + hit = isPointInShape(point(x, y), { type: "polygon", data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) - .data as Polygon, + .data as Polygon, }); } @@ -89,11 +89,13 @@ export const hitElementBoundingBox = ( y1 -= tolerance; x2 += tolerance; y2 += tolerance; - return isPointWithinBounds([x1, y1], [x, y], [x2, y2]); + return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2)); }; -export const hitElementBoundingBoxOnly = ( - hitArgs: HitTestArgs, +export const hitElementBoundingBoxOnly = < + Point extends GlobalPoint | LocalPoint, +>( + hitArgs: HitTestArgs, elementsMap: ElementsMap, ) => { return ( @@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = ( ); }; -export const hitElementBoundText = ( +export const hitElementBoundText = ( x: number, y: number, - textShape: GeometricShape | null, + textShape: GeometricShape | null, ): boolean => { - return !!textShape && isPointInShape([x, y], textShape); + return !!textShape && isPointInShape(point(x, y), textShape); }; diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 02288a883..18d78fdbe 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -11,7 +11,6 @@ import type { PointerDownState, } from "../types"; import { getBoundTextElement, getMinTextElementWidth } from "./textElement"; -import { getGridPoint } from "../math"; import type Scene from "../scene/Scene"; import { isArrowElement, @@ -21,6 +20,7 @@ import { } from "./typeChecks"; import { getFontString } from "../utils"; import { TEXT_AUTOWRAP_THRESHOLD } from "../constants"; +import { getGridPoint } from "../snapping"; export const dragSelectedElements = ( pointerDownState: PointerDownState, diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index 83850be82..cc174bfa9 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/packages/excalidraw/element/flowchart.ts @@ -10,7 +10,6 @@ import { import { bindLinearElement } from "./binding"; import { LinearElementEditor } from "./linearElementEditor"; import { newArrowElement, newElement } from "./newElement"; -import { aabbForElement } from "../math"; import type { ElementsMap, ExcalidrawBindableElement, @@ -20,7 +19,7 @@ import type { OrderedExcalidrawElement, } from "./types"; import { KEYS } from "../keys"; -import type { AppState, PendingExcalidrawElements, Point } from "../types"; +import type { AppState, PendingExcalidrawElements } from "../types"; import { mutateElement } from "./mutateElement"; import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame"; import { @@ -30,6 +29,8 @@ import { isFlowchartNodeElement, } from "./typeChecks"; import { invariant } from "../utils"; +import { point, type LocalPoint } from "../../math"; +import { aabbForElement } from "../shapes"; type LinkDirection = "up" | "right" | "down" | "left"; @@ -81,13 +82,14 @@ const getNodeRelatives = ( "not an ExcalidrawBindableElement", ); - const edgePoint: Point = - type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]; + const edgePoint = ( + type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] + ) as Readonly; const heading = headingForPointFromElement(node, aabbForElement(node), [ edgePoint[0] + el.x, edgePoint[1] + el.y, - ]); + ] as Readonly); acc.push({ relative, @@ -419,10 +421,7 @@ const createBindingArrow = ( strokeColor: appState.currentItemStrokeColor, strokeStyle: appState.currentItemStrokeStyle, strokeWidth: appState.currentItemStrokeWidth, - points: [ - [0, 0], - [endX, endY], - ], + points: [point(0, 0), point(endX, endY)], elbowed: true, }); diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index a8b3a3fa0..b22316c6a 100644 --- a/packages/excalidraw/element/heading.ts +++ b/packages/excalidraw/element/heading.ts @@ -1,12 +1,18 @@ -import { lineAngle } from "../../utils/geometry/geometry"; -import type { Point, Vector } from "../../utils/geometry/shape"; +import type { + LocalPoint, + GlobalPoint, + Triangle, + Vector, + Radians, +} from "../../math"; import { - getCenterForBounds, - PointInTriangle, - rotatePoint, - scalePointFromOrigin, -} from "../math"; -import type { Bounds } from "./bounds"; + point, + pointRotateRads, + pointScaleFromOrigin, + radiansToDegrees, + triangleIncludesPoint, +} from "../../math"; +import { getCenterForBounds, type Bounds } from "./bounds"; import type { ExcalidrawBindableElement } from "./types"; export const HEADING_RIGHT = [1, 0] as Heading; @@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading; export const HEADING_UP = [0, -1] as Heading; export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1]; -export const headingForDiamond = (a: Point, b: Point) => { - const angle = lineAngle([a, b]); +export const headingForDiamond = ( + a: Point, + b: Point, +) => { + const angle = radiansToDegrees( + Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians, + ); if (angle >= 315 || angle < 45) { return HEADING_UP; } else if (angle >= 45 && angle < 135) { @@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) => // Gets the heading for the point by creating a bounding box around the rotated // close fitting bounding box, then creating 4 search cones around the center of // the external bbox. -export const headingForPointFromElement = ( +export const headingForPointFromElement = < + Point extends GlobalPoint | LocalPoint, +>( element: Readonly, aabb: Readonly, - point: Readonly, + p: Readonly, ): Heading => { const SEARCH_CONE_MULTIPLIER = 2; const midPoint = getCenterForBounds(aabb); if (element.type === "diamond") { - if (point[0] < element.x) { + if (p[0] < element.x) { return HEADING_LEFT; - } else if (point[1] < element.y) { + } else if (p[1] < element.y) { return HEADING_UP; - } else if (point[0] > element.x + element.width) { + } else if (p[0] > element.x + element.width) { return HEADING_RIGHT; - } else if (point[1] > element.y + element.height) { + } else if (p[1] > element.y + element.height) { return HEADING_DOWN; } - const top = rotatePoint( - scalePointFromOrigin( - [element.x + element.width / 2, element.y], + const top = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width / 2, element.y), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const right = rotatePoint( - scalePointFromOrigin( - [element.x + element.width, element.y + element.height / 2], + const right = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width, element.y + element.height / 2), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const bottom = rotatePoint( - scalePointFromOrigin( - [element.x + element.width / 2, element.y + element.height], + const bottom = pointRotateRads( + pointScaleFromOrigin( + point(element.x + element.width / 2, element.y + element.height), midPoint, SEARCH_CONE_MULTIPLIER, ), midPoint, element.angle, ); - const left = rotatePoint( - scalePointFromOrigin( - [element.x, element.y + element.height / 2], + const left = pointRotateRads( + pointScaleFromOrigin( + point(element.x, element.y + element.height / 2), midPoint, SEARCH_CONE_MULTIPLIER, ), @@ -104,43 +117,62 @@ export const headingForPointFromElement = ( element.angle, ); - if (PointInTriangle(point, top, right, midPoint)) { + if (triangleIncludesPoint([top, right, midPoint] as Triangle, p)) { return headingForDiamond(top, right); - } else if (PointInTriangle(point, right, bottom, midPoint)) { + } else if ( + triangleIncludesPoint([right, bottom, midPoint] as Triangle, p) + ) { return headingForDiamond(right, bottom); - } else if (PointInTriangle(point, bottom, left, midPoint)) { + } else if ( + triangleIncludesPoint([bottom, left, midPoint] as Triangle, p) + ) { return headingForDiamond(bottom, left); } return headingForDiamond(left, top); } - const topLeft = scalePointFromOrigin( - [aabb[0], aabb[1]], + const topLeft = pointScaleFromOrigin( + point(aabb[0], aabb[1]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const topRight = scalePointFromOrigin( - [aabb[2], aabb[1]], + ) as Point; + const topRight = pointScaleFromOrigin( + point(aabb[2], aabb[1]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const bottomLeft = scalePointFromOrigin( - [aabb[0], aabb[3]], + ) as Point; + const bottomLeft = pointScaleFromOrigin( + point(aabb[0], aabb[3]), midPoint, SEARCH_CONE_MULTIPLIER, - ); - const bottomRight = scalePointFromOrigin( - [aabb[2], aabb[3]], + ) as Point; + const bottomRight = pointScaleFromOrigin( + point(aabb[2], aabb[3]), midPoint, SEARCH_CONE_MULTIPLIER, - ); + ) as Point; - return PointInTriangle(point, topLeft, topRight, midPoint) + return triangleIncludesPoint( + [topLeft, topRight, midPoint] as Triangle, + p, + ) ? HEADING_UP - : PointInTriangle(point, topRight, bottomRight, midPoint) + : triangleIncludesPoint( + [topRight, bottomRight, midPoint] as Triangle, + p, + ) ? HEADING_RIGHT - : PointInTriangle(point, bottomRight, bottomLeft, midPoint) + : triangleIncludesPoint( + [bottomRight, bottomLeft, midPoint] as Triangle, + p, + ) ? HEADING_DOWN : HEADING_LEFT; }; + +export const flipHeading = (h: Heading): Heading => + [ + h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1, + h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1, + ] as Heading; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index f671e2f2c..7607a2e16 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -11,19 +11,6 @@ import type { FixedPointBinding, SceneElementsMap, } from "./types"; -import { - distance2d, - rotate, - isPathALoop, - getGridPoint, - rotatePoint, - centerPoint, - getControlPointsForBezierCurve, - getBezierXY, - getBezierCurveLength, - mapIntervalToBezierT, - arePointsEqual, -} from "../math"; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import type { Bounds } from "./bounds"; import { @@ -32,7 +19,6 @@ import { getMinMaxXYFromCurvePathOps, } from "./bounds"; import type { - Point, AppState, PointerCoords, InteractiveCanvasAppState, @@ -46,7 +32,7 @@ import { getHoveredElementForBinding, isBindingEnabled, } from "./binding"; -import { toBrandedType, tupleToCoors } from "../utils"; +import { invariant, toBrandedType, tupleToCoors } from "../utils"; import { isBindingElement, isElbowArrow, @@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache"; import type { Store } from "../store"; import { mutateElbowArrow } from "./routing"; import type Scene from "../scene/Scene"; +import type { Radians } from "../../math"; +import { + pointCenter, + point, + pointRotateRads, + pointsEqual, + vector, + type GlobalPoint, + type LocalPoint, + pointDistance, +} from "../../math"; +import { + getBezierCurveLength, + getBezierXY, + getControlPointsForBezierCurve, + isPathALoop, + mapIntervalToBezierT, +} from "../shapes"; +import { getGridPoint } from "../snapping"; const editorMidPointsCache: { version: number | null; - points: (Point | null)[]; + points: (GlobalPoint | null)[]; zoom: number | null; } = { version: null, points: [], zoom: null }; export class LinearElementEditor { @@ -80,7 +85,7 @@ export class LinearElementEditor { lastClickedIsEndPoint: boolean; origin: Readonly<{ x: number; y: number }> | null; segmentMidpoint: { - value: Point | null; + value: GlobalPoint | null; index: number | null; added: boolean; }; @@ -88,7 +93,7 @@ export class LinearElementEditor { /** whether you're dragging a point */ public readonly isDragging: boolean; - public readonly lastUncommittedPoint: Point | null; + public readonly lastUncommittedPoint: LocalPoint | null; public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly startBindingElement: | ExcalidrawBindableElement @@ -96,13 +101,13 @@ export class LinearElementEditor { | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly hoverPointIndex: number; - public readonly segmentMidPointHoveredCoords: Point | null; + public readonly segmentMidPointHoveredCoords: GlobalPoint | null; constructor(element: NonDeleted) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - if (!arePointsEqual(element.points[0], [0, 0])) { + if (!pointsEqual(element.points[0], point(0, 0))) { console.error("Linear element is not normalized", Error().stack); } @@ -280,7 +285,7 @@ export class LinearElementEditor { element, elementsMap, referencePoint, - [scenePointerX, scenePointerY], + point(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); @@ -289,7 +294,10 @@ export class LinearElementEditor { [ { index: selectedIndex, - point: [width + referencePoint[0], height + referencePoint[1]], + point: point( + width + referencePoint[0], + height + referencePoint[1], + ), isDragging: selectedIndex === lastClickedPoint, }, ], @@ -310,7 +318,7 @@ export class LinearElementEditor { LinearElementEditor.movePoints( element, selectedPointsIndices.map((pointIndex) => { - const newPointPosition = + const newPointPosition: LocalPoint = pointIndex === lastClickedPoint ? LinearElementEditor.createPointAt( element, @@ -319,10 +327,10 @@ export class LinearElementEditor { scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ) - : ([ + : point( element.points[pointIndex][0] + deltaX, element.points[pointIndex][1] + deltaY, - ] as const); + ); return { index: pointIndex, point: newPointPosition, @@ -515,7 +523,7 @@ export class LinearElementEditor { ); let index = 0; - const midpoints: (Point | null)[] = []; + const midpoints: (GlobalPoint | null)[] = []; while (index < points.length - 1) { if ( LinearElementEditor.isSegmentTooShort( @@ -549,7 +557,7 @@ export class LinearElementEditor { scenePointer: { x: number; y: number }, appState: AppState, elementsMap: ElementsMap, - ) => { + ): GlobalPoint | null => { const { elementId } = linearElementEditor; const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -579,11 +587,12 @@ export class LinearElementEditor { const existingSegmentMidpointHitCoords = linearElementEditor.segmentMidPointHoveredCoords; if (existingSegmentMidpointHitCoords) { - const distance = distance2d( - existingSegmentMidpointHitCoords[0], - existingSegmentMidpointHitCoords[1], - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point( + existingSegmentMidpointHitCoords[0], + existingSegmentMidpointHitCoords[1], + ), + point(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { return existingSegmentMidpointHitCoords; @@ -594,11 +603,9 @@ export class LinearElementEditor { LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); while (index < midPoints.length) { if (midPoints[index] !== null) { - const distance = distance2d( - midPoints[index]![0], - midPoints[index]![1], - scenePointer.x, - scenePointer.y, + const distance = pointDistance( + point(midPoints[index]![0], midPoints[index]![1]), + point(scenePointer.x, scenePointer.y), ); if (distance <= threshold) { return midPoints[index]; @@ -612,15 +619,13 @@ export class LinearElementEditor { static isSegmentTooShort( element: NonDeleted, - startPoint: Point, - endPoint: Point, + startPoint: GlobalPoint | LocalPoint, + endPoint: GlobalPoint | LocalPoint, zoom: AppState["zoom"], ) { - let distance = distance2d( - startPoint[0], - startPoint[1], - endPoint[0], - endPoint[1], + let distance = pointDistance( + point(startPoint[0], startPoint[1]), + point(endPoint[0], endPoint[1]), ); if (element.points.length > 2 && element.roundness) { distance = getBezierCurveLength(element, endPoint); @@ -631,12 +636,12 @@ export class LinearElementEditor { static getSegmentMidPoint( element: NonDeleted, - startPoint: Point, - endPoint: Point, + startPoint: GlobalPoint, + endPoint: GlobalPoint, endPointIndex: number, elementsMap: ElementsMap, - ) { - let segmentMidPoint = centerPoint(startPoint, endPoint); + ): GlobalPoint { + let segmentMidPoint = pointCenter(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { const controlPoints = getControlPointsForBezierCurve( element, @@ -649,16 +654,15 @@ export class LinearElementEditor { 0.5, ); - const [tx, ty] = getBezierXY( - controlPoints[0], - controlPoints[1], - controlPoints[2], - controlPoints[3], - t, - ); segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( element, - [tx, ty], + getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ), elementsMap, ); } @@ -670,7 +674,7 @@ export class LinearElementEditor { static getSegmentMidPointIndex( linearElementEditor: LinearElementEditor, appState: AppState, - midPoint: Point, + midPoint: GlobalPoint, elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( @@ -822,11 +826,12 @@ export class LinearElementEditor { const cy = (y1 + y2) / 2; const targetPoint = clickedPointIndex > -1 && - rotate( - element.x + element.points[clickedPointIndex][0], - element.y + element.points[clickedPointIndex][1], - cx, - cy, + pointRotateRads( + point( + element.x + element.points[clickedPointIndex][0], + element.y + element.points[clickedPointIndex][1], + ), + point(cx, cy), element.angle, ); @@ -865,14 +870,17 @@ export class LinearElementEditor { return ret; } - static arePointsEqual(point1: Point | null, point2: Point | null) { + static arePointsEqual( + point1: Point | null, + point2: Point | null, + ) { if (!point1 && !point2) { return true; } if (!point1 || !point2) { return false; } - return arePointsEqual(point1, point2); + return pointsEqual(point1, point2); } static handlePointerMove( @@ -909,7 +917,7 @@ export class LinearElementEditor { }; } - let newPoint: Point; + let newPoint: LocalPoint; if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) { const lastCommittedPoint = points[points.length - 2]; @@ -918,14 +926,14 @@ export class LinearElementEditor { element, elementsMap, lastCommittedPoint, - [scenePointerX, scenePointerY], + point(scenePointerX, scenePointerY), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), ); - newPoint = [ + newPoint = point( width + lastCommittedPoint[0], height + lastCommittedPoint[1], - ]; + ); } else { newPoint = LinearElementEditor.createPointAt( element, @@ -965,30 +973,36 @@ export class LinearElementEditor { /** scene coords */ static getPointGlobalCoordinates( element: NonDeleted, - point: Point, + p: LocalPoint, elementsMap: ElementsMap, - ) { + ): GlobalPoint { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - let { x, y } = element; - [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); - return [x, y] as const; + const { x, y } = element; + return pointRotateRads( + point(x + p[0], y + p[1]), + point(cx, cy), + element.angle, + ); } /** scene coords */ static getPointsGlobalCoordinates( element: NonDeleted, elementsMap: ElementsMap, - ): Point[] { + ): GlobalPoint[] { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - return element.points.map((point) => { - let { x, y } = element; - [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle); - return [x, y] as const; + return element.points.map((p) => { + const { x, y } = element; + return pointRotateRads( + point(x + p[0], y + p[1]), + point(cx, cy), + element.angle, + ); }); } @@ -997,7 +1011,7 @@ export class LinearElementEditor { indexMaybeFromEnd: number, // -1 for last element elementsMap: ElementsMap, - ): Point { + ): GlobalPoint { const index = indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd @@ -1005,35 +1019,36 @@ export class LinearElementEditor { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - - const point = element.points[index]; + const p = element.points[index]; const { x, y } = element; - return point - ? rotate(x + point[0], y + point[1], cx, cy, element.angle) - : rotate(x, y, cx, cy, element.angle); + + return p + ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle) + : pointRotateRads(point(x, y), point(cx, cy), element.angle); } static pointFromAbsoluteCoords( element: NonDeleted, - absoluteCoords: Point, + absoluteCoords: GlobalPoint, elementsMap: ElementsMap, - ): Point { + ): LocalPoint { if (isElbowArrow(element)) { // No rotation for elbow arrows - return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y]; + return point( + absoluteCoords[0] - element.x, + absoluteCoords[1] - element.y, + ); } const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const [x, y] = rotate( - absoluteCoords[0], - absoluteCoords[1], - cx, - cy, - -element.angle, + const [x, y] = pointRotateRads( + point(absoluteCoords[0], absoluteCoords[1]), + point(cx, cy), + -element.angle as Radians, ); - return [x - element.x, y - element.y]; + return point(x - element.x, y - element.y); } static getPointIndexUnderCursor( @@ -1052,9 +1067,9 @@ export class LinearElementEditor { // points on the left, thus should take precedence when clicking, if they // overlap while (--idx > -1) { - const point = pointHandles[idx]; + const p = pointHandles[idx]; if ( - distance2d(x, y, point[0], point[1]) * zoom.value < + pointDistance(point(x, y), point(p[0], p[1])) * zoom.value < // +1px to account for outline stroke LinearElementEditor.POINT_HANDLE_SIZE + 1 ) { @@ -1070,20 +1085,18 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, gridSize: NullableGridSize, - ): Point { + ): LocalPoint { const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const [rotatedX, rotatedY] = rotate( - pointerOnGrid[0], - pointerOnGrid[1], - cx, - cy, - -element.angle, + const [rotatedX, rotatedY] = pointRotateRads( + point(pointerOnGrid[0], pointerOnGrid[1]), + point(cx, cy), + -element.angle as Radians, ); - return [rotatedX - element.x, rotatedY - element.y]; + return point(rotatedX - element.x, rotatedY - element.y); } /** @@ -1091,15 +1104,19 @@ export class LinearElementEditor { * expected in various parts of the codebase. Also returns new x/y to account * for the potential normalization. */ - static getNormalizedPoints(element: ExcalidrawLinearElement) { + static getNormalizedPoints(element: ExcalidrawLinearElement): { + points: LocalPoint[]; + x: number; + y: number; + } { const { points } = element; const offsetX = points[0][0]; const offsetY = points[0][1]; return { - points: points.map((point) => { - return [point[0] - offsetX, point[1] - offsetY] as const; + points: points.map((p) => { + return point(p[0] - offsetX, p[1] - offsetY); }), x: element.x + offsetX, y: element.y + offsetY, @@ -1116,17 +1133,23 @@ export class LinearElementEditor { static duplicateSelectedPoints( appState: AppState, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - ) { - if (!appState.editingLinearElement) { - return false; - } + ): AppState { + invariant( + appState.editingLinearElement, + "Not currently editing a linear element", + ); const { selectedPointsIndices, elementId } = appState.editingLinearElement; const element = LinearElementEditor.getElement(elementId, elementsMap); - if (!element || selectedPointsIndices === null) { - return false; - } + invariant( + element, + "The linear element does not exist in the provided Scene", + ); + invariant( + selectedPointsIndices != null, + "There are no selected points to duplicate", + ); const { points } = element; @@ -1134,9 +1157,9 @@ export class LinearElementEditor { let pointAddedToEnd = false; let indexCursor = -1; - const nextPoints = points.reduce((acc: Point[], point, index) => { + const nextPoints = points.reduce((acc: LocalPoint[], p, index) => { ++indexCursor; - acc.push(point); + acc.push(p); const isSelected = selectedPointsIndices.includes(index); if (isSelected) { @@ -1147,8 +1170,8 @@ export class LinearElementEditor { } acc.push( nextPoint - ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2] - : [point[0], point[1]], + ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2) + : point(p[0], p[1]), ); nextSelectedIndices.push(indexCursor + 1); @@ -1169,7 +1192,7 @@ export class LinearElementEditor { [ { index: element.points.length - 1, - point: [lastPoint[0] + 30, lastPoint[1] + 30], + point: point(lastPoint[0] + 30, lastPoint[1] + 30), }, ], elementsMap, @@ -1177,12 +1200,10 @@ export class LinearElementEditor { } return { - appState: { - ...appState, - editingLinearElement: { - ...appState.editingLinearElement, - selectedPointsIndices: nextSelectedIndices, - }, + ...appState, + editingLinearElement: { + ...appState.editingLinearElement, + selectedPointsIndices: nextSelectedIndices, }, }; } @@ -1209,10 +1230,10 @@ export class LinearElementEditor { } } - const nextPoints = element.points.reduce((acc: Point[], point, idx) => { + const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { if (!pointIndices.includes(idx)) { acc.push( - !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY], + !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY), ); } return acc; @@ -1229,7 +1250,7 @@ export class LinearElementEditor { static addPoints( element: NonDeleted, - targetPoints: { point: Point }[], + targetPoints: { point: LocalPoint }[], elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, ) { const offsetX = 0; @@ -1247,7 +1268,7 @@ export class LinearElementEditor { static movePoints( element: NonDeleted, - targetPoints: { index: number; point: Point; isDragging?: boolean }[], + targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[], elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, otherUpdates?: { startBinding?: PointBinding | null; @@ -1277,11 +1298,11 @@ export class LinearElementEditor { selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1]; } - const nextPoints = points.map((point, idx) => { - const selectedPointData = targetPoints.find((p) => p.index === idx); + const nextPoints: LocalPoint[] = points.map((p, idx) => { + const selectedPointData = targetPoints.find((t) => t.index === idx); if (selectedPointData) { if (selectedPointData.index === 0) { - return point; + return p; } const deltaX = @@ -1289,14 +1310,9 @@ export class LinearElementEditor { const deltaY = selectedPointData.point[1] - points[selectedPointData.index][1]; - return [ - point[0] + deltaX - offsetX, - point[1] + deltaY - offsetY, - ] as const; + return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY); } - return offsetX || offsetY - ? ([point[0] - offsetX, point[1] - offsetY] as const) - : point; + return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p; }); LinearElementEditor._updatePoints( @@ -1349,11 +1365,9 @@ export class LinearElementEditor { } const origin = linearElementEditor.pointerDownState.origin!; - const dist = distance2d( - origin.x, - origin.y, - pointerCoords.x, - pointerCoords.y, + const dist = pointDistance( + point(origin.x, origin.y), + point(pointerCoords.x, pointerCoords.y), ); if ( !appState.editingLinearElement && @@ -1418,7 +1432,7 @@ export class LinearElementEditor { private static _updatePoints( element: NonDeleted, - nextPoints: readonly Point[], + nextPoints: readonly LocalPoint[], offsetX: number, offsetY: number, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, @@ -1461,7 +1475,7 @@ export class LinearElementEditor { element, mergedElementsMap, nextPoints, - [offsetX, offsetY], + vector(offsetX, offsetY), bindings, options, ); @@ -1474,7 +1488,11 @@ export class LinearElementEditor { const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const dX = prevCenterX - nextCenterX; const dY = prevCenterY - nextCenterY; - const rotated = rotate(offsetX, offsetY, dX, dY, element.angle); + const rotated = pointRotateRads( + point(offsetX, offsetY), + point(dX, dY), + element.angle, + ); mutateElement(element, { ...otherUpdates, points: nextPoints, @@ -1487,8 +1505,8 @@ export class LinearElementEditor { private static _getShiftLockedDelta( element: NonDeleted, elementsMap: ElementsMap, - referencePoint: Point, - scenePointer: Point, + referencePoint: LocalPoint, + scenePointer: GlobalPoint, gridSize: NullableGridSize, ) { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( @@ -1517,7 +1535,11 @@ export class LinearElementEditor { gridY, ); - return rotatePoint([width, height], [0, 0], -element.angle); + return pointRotateRads( + point(width, height), + point(0, 0), + -element.angle as Radians, + ); } static getBoundTextElementPosition = ( @@ -1548,7 +1570,7 @@ export class LinearElementEditor { let midSegmentMidpoint = editorMidPointsCache.points[index]; if (element.points.length === 2) { - midSegmentMidpoint = centerPoint(points[0], points[1]); + midSegmentMidpoint = pointCenter(points[0], points[1]); } if ( !midSegmentMidpoint || @@ -1585,37 +1607,38 @@ export class LinearElementEditor { ); const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextY2 = boundTextY1 + boundTextElement.height; + const centerPoint = point(cx, cy); - const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle); - const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle); - - const counterRotateBoundTextTopLeft = rotatePoint( - [boundTextX1, boundTextY1], - - [cx, cy], - - -element.angle, + const topLeftRotatedPoint = pointRotateRads( + point(x1, y1), + centerPoint, + element.angle, ); - const counterRotateBoundTextTopRight = rotatePoint( - [boundTextX2, boundTextY1], - - [cx, cy], - - -element.angle, + const topRightRotatedPoint = pointRotateRads( + point(x2, y1), + centerPoint, + element.angle, ); - const counterRotateBoundTextBottomLeft = rotatePoint( - [boundTextX1, boundTextY2], - [cx, cy], - - -element.angle, + const counterRotateBoundTextTopLeft = pointRotateRads( + point(boundTextX1, boundTextY1), + centerPoint, + -element.angle as Radians, ); - const counterRotateBoundTextBottomRight = rotatePoint( - [boundTextX2, boundTextY2], - - [cx, cy], - - -element.angle, + const counterRotateBoundTextTopRight = pointRotateRads( + point(boundTextX2, boundTextY1), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomLeft = pointRotateRads( + point(boundTextX1, boundTextY2), + centerPoint, + -element.angle as Radians, + ); + const counterRotateBoundTextBottomRight = pointRotateRads( + point(boundTextX2, boundTextY2), + centerPoint, + -element.angle as Radians, ); if ( diff --git a/packages/excalidraw/element/mutateElement.ts b/packages/excalidraw/element/mutateElement.ts index de0adeeff..ef84854f9 100644 --- a/packages/excalidraw/element/mutateElement.ts +++ b/packages/excalidraw/element/mutateElement.ts @@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types"; import Scene from "../scene/Scene"; import { getSizeFromPoints } from "../points"; import { randomInteger } from "../random"; -import type { Point } from "../types"; import { getUpdatedTimestamp } from "../utils"; import type { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; @@ -59,8 +58,8 @@ export const mutateElement = >( let didChangePoints = false; let index = prevPoints.length; while (--index) { - const prevPoint: Point = prevPoints[index]; - const nextPoint: Point = nextPoints[index]; + const prevPoint = prevPoints[index]; + const nextPoint = nextPoints[index]; if ( prevPoint[0] !== nextPoint[0] || prevPoint[1] !== nextPoint[1] diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts index 3346218b1..770d0d987 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/packages/excalidraw/element/newElement.test.ts @@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api"; import { FONT_FAMILY, ROUNDNESS } from "../constants"; import { isPrimitive } from "../utils"; import type { ExcalidrawLinearElement } from "./types"; +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; const assertCloneObjects = (source: any, clone: any) => { for (const key in clone) { @@ -36,10 +38,7 @@ describe("duplicating single elements", () => { element.__proto__ = { hello: "world" }; mutateElement(element, { - points: [ - [1, 2], - [3, 4], - ], + points: [point(1, 2), point(3, 4)], }); const copy = duplicateElement(null, new Map(), element); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 05f722887..a3b259e36 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement"; import { getNewGroupIdsForDuplication } from "../groups"; import type { AppState } from "../types"; import { getElementAbsoluteCoords } from "."; -import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { measureText, @@ -48,6 +47,7 @@ import { } from "../constants"; import type { MarkOptional, Merge, Mutable } from "../utility-types"; import { getLineHeight } from "../fonts"; +import type { Radians } from "../../math"; export type ElementConstructorOpts = MarkOptional< Omit, @@ -88,7 +88,7 @@ const _newElementBase = ( opacity = DEFAULT_ELEMENT_PROPS.opacity, width = 0, height = 0, - angle = 0, + angle = 0 as Radians, groupIds = [], frameId = null, index = null, @@ -348,6 +348,53 @@ const getAdjustedDimensions = ( }; }; +const adjustXYWithRotation = ( + sides: { + n?: boolean; + e?: boolean; + s?: boolean; + w?: boolean; + }, + x: number, + y: number, + angle: number, + deltaX1: number, + deltaY1: number, + deltaX2: number, + deltaY2: number, +): [number, number] => { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + if (sides.e && sides.w) { + x += deltaX1 + deltaX2; + } else if (sides.e) { + x += deltaX1 * (1 + cos); + y += deltaX1 * sin; + x += deltaX2 * (1 - cos); + y += deltaX2 * -sin; + } else if (sides.w) { + x += deltaX1 * (1 - cos); + y += deltaX1 * -sin; + x += deltaX2 * (1 + cos); + y += deltaX2 * sin; + } + + if (sides.n && sides.s) { + y += deltaY1 + deltaY2; + } else if (sides.n) { + x += deltaY1 * sin; + y += deltaY1 * (1 - cos); + x += deltaY2 * -sin; + y += deltaY2 * (1 + cos); + } else if (sides.s) { + x += deltaY1 * -sin; + y += deltaY1 * (1 + cos); + x += deltaY2 * sin; + y += deltaY2 * (1 - cos); + } + return [x, y]; +}; + export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 947e4ed82..3f3f8ef1e 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -1,7 +1,5 @@ import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants"; import { rescalePoints } from "../points"; - -import { rotate, centerPoint, rotatePoint } from "../math"; import type { ExcalidrawLinearElement, ExcalidrawTextElement, @@ -38,7 +36,7 @@ import type { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import type { Point, PointerDownState } from "../types"; +import type { PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -55,16 +53,15 @@ import { import { LinearElementEditor } from "./linearElementEditor"; import { isInGroup } from "../groups"; import { mutateElbowArrow } from "./routing"; - -export const normalizeAngle = (angle: number): number => { - if (angle < 0) { - return angle + 2 * Math.PI; - } - if (angle >= 2 * Math.PI) { - return angle - 2 * Math.PI; - } - return angle; -}; +import type { GlobalPoint } from "../../math"; +import { + pointCenter, + normalizeRadians, + point, + pointFromPair, + pointRotateRads, + type Radians, +} from "../../math"; // Returns true when transform (resizing/rotation) happened export const transformElements = ( @@ -158,16 +155,17 @@ const rotateSingleElement = ( const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - let angle: number; + let angle: Radians; if (isFrameLikeElement(element)) { - angle = 0; + angle = 0 as Radians; } else { - angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); + angle = ((5 * Math.PI) / 2 + + Math.atan2(pointerY - cy, pointerX - cx)) as Radians; if (shouldRotateWithDiscreteAngle) { - angle += SHIFT_LOCKING_ANGLE / 2; - angle -= angle % SHIFT_LOCKING_ANGLE; + angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians; + angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians; } - angle = normalizeAngle(angle); + angle = normalizeRadians(angle as Radians); } const boundTextElementId = getBoundTextElementId(element); @@ -240,12 +238,10 @@ const resizeSingleTextElement = ( elementsMap, ); // rotation pointer with reverse angle - const [rotatedX, rotatedY] = rotate( - pointerX, - pointerY, - cx, - cy, - -element.angle, + const [rotatedX, rotatedY] = pointRotateRads( + point(pointerX, pointerY), + point(cx, cy), + -element.angle as Radians, ); let scaleX = 0; let scaleY = 0; @@ -279,20 +275,26 @@ const resizeSingleTextElement = ( const startBottomRight = [x2, y2]; const startCenter = [cx, cy]; - let newTopLeft = [x1, y1] as [number, number]; + let newTopLeft = point(x1, y1); if (["n", "w", "nw"].includes(transformHandleType)) { - newTopLeft = [ + newTopLeft = point( startBottomRight[0] - Math.abs(nextWidth), startBottomRight[1] - Math.abs(nextHeight), - ]; + ); } if (transformHandleType === "ne") { const bottomLeft = [startTopLeft[0], startBottomRight[1]]; - newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)]; + newTopLeft = point( + bottomLeft[0], + bottomLeft[1] - Math.abs(nextHeight), + ); } if (transformHandleType === "sw") { const topRight = [startBottomRight[0], startTopLeft[1]]; - newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]]; + newTopLeft = point( + topRight[0] - Math.abs(nextWidth), + topRight[1], + ); } if (["s", "n"].includes(transformHandleType)) { @@ -308,13 +310,17 @@ const resizeSingleTextElement = ( } const angle = element.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle); + const newCenter = point( newTopLeft[0] + Math.abs(nextWidth) / 2, newTopLeft[1] + Math.abs(nextHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); const [nextX, nextY] = newTopLeft; mutateElement(element, { @@ -334,14 +340,14 @@ const resizeSingleTextElement = ( stateAtResizeStart.height, true, ); - const startTopLeft: Point = [x1, y1]; - const startBottomRight: Point = [x2, y2]; - const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + const startTopLeft = point(x1, y1); + const startBottomRight = point(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); - const rotatedPointer = rotatePoint( - [pointerX, pointerY], + const rotatedPointer = pointRotateRads( + point(pointerX, pointerY), startCenter, - -stateAtResizeStart.angle, + -stateAtResizeStart.angle as Radians, ); const [esx1, , esx2] = getResizedElementAbsoluteCoords( @@ -407,13 +413,21 @@ const resizeSingleTextElement = ( // adjust topLeft to new rotation point const angle = stateAtResizeStart.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads( + pointFromPair(newTopLeft), + startCenter, + angle, + ); + const newCenter = point( newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); const resizedElement: Partial = { width: Math.abs(newWidth), @@ -446,15 +460,15 @@ export const resizeSingleElement = ( stateAtResizeStart.height, true, ); - const startTopLeft: Point = [x1, y1]; - const startBottomRight: Point = [x2, y2]; - const startCenter: Point = centerPoint(startTopLeft, startBottomRight); + const startTopLeft = point(x1, y1); + const startBottomRight = point(x2, y2); + const startCenter = pointCenter(startTopLeft, startBottomRight); // Calculate new dimensions based on cursor position - const rotatedPointer = rotatePoint( - [pointerX, pointerY], + const rotatedPointer = pointRotateRads( + point(pointerX, pointerY), startCenter, - -stateAtResizeStart.angle, + -stateAtResizeStart.angle as Radians, ); // Get bounds corners rendered on screen @@ -628,13 +642,21 @@ export const resizeSingleElement = ( // adjust topLeft to new rotation point const angle = stateAtResizeStart.angle; - const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); - const newCenter: Point = [ + const rotatedTopLeft = pointRotateRads( + pointFromPair(newTopLeft), + startCenter, + angle, + ); + const newCenter = point( newTopLeft[0] + Math.abs(newBoundsWidth) / 2, newTopLeft[1] + Math.abs(newBoundsHeight) / 2, - ]; - const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); - newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + ); + const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle); + newTopLeft = pointRotateRads( + rotatedTopLeft, + rotatedNewCenter, + -angle as Radians, + ); // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner // So we need to readjust (x,y) to be where the first point should be @@ -793,21 +815,21 @@ export const resizeMultipleElements = ( const direction = transformHandleType; - const anchorsMap: Record = { - ne: [minX, maxY], - se: [minX, minY], - sw: [maxX, minY], - nw: [maxX, maxY], - e: [minX, minY + height / 2], - w: [maxX, minY + height / 2], - n: [minX + width / 2, maxY], - s: [minX + width / 2, minY], + const anchorsMap: Record = { + ne: point(minX, maxY), + se: point(minX, minY), + sw: point(maxX, minY), + nw: point(maxX, maxY), + e: point(minX, minY + height / 2), + w: point(maxX, minY + height / 2), + n: point(minX + width / 2, maxY), + s: point(minX + width / 2, minY), }; // anchor point must be on the opposite side of the dragged selection handle // or be the center of the selection if shouldResizeFromCenter - const [anchorX, anchorY]: Point = shouldResizeFromCenter - ? [midX, midY] + const [anchorX, anchorY] = shouldResizeFromCenter + ? point(midX, midY) : anchorsMap[direction]; const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1; @@ -898,7 +920,9 @@ export const resizeMultipleElements = ( const width = orig.width * scaleX; const height = orig.height * scaleY; - const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY); + const angle = normalizeRadians( + (orig.angle * flipFactorX * flipFactorY) as Radians, + ); const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig); const offsetX = orig.x - anchorX; @@ -1029,12 +1053,10 @@ const rotateMultipleElements = ( const cy = (y1 + y2) / 2; const origAngle = originalElements.get(element.id)?.angle ?? element.angle; - const [rotatedCX, rotatedCY] = rotate( - cx, - cy, - centerX, - centerY, - centerAngle + origAngle - element.angle, + const [rotatedCX, rotatedCY] = pointRotateRads( + point(cx, cy), + point(centerX, centerY), + (centerAngle + origAngle - element.angle) as Radians, ); if (isArrowElement(element) && isElbowArrow(element)) { @@ -1046,7 +1068,7 @@ const rotateMultipleElements = ( { x: element.x + (rotatedCX - cx), y: element.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), + angle: normalizeRadians((centerAngle + origAngle) as Radians), }, false, ); @@ -1063,7 +1085,7 @@ const rotateMultipleElements = ( { x: boundText.x + (rotatedCX - cx), y: boundText.y + (rotatedCY - cy), - angle: normalizeAngle(centerAngle + origAngle), + angle: normalizeRadians((centerAngle + origAngle) as Radians), }, false, ); @@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = ( : getCommonBounds(selectedElements); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; - const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0; - [x, y] = rotate(x, y, cx, cy, -angle); + const angle = ( + selectedElements.length === 1 ? selectedElements[0].angle : 0 + ) as Radians; + [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians); switch (transformHandleType) { case "n": - return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle); + return pointRotateRads( + point(x - (x1 + x2) / 2, y - y1), + point(0, 0), + angle, + ); case "s": - return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle); + return pointRotateRads( + point(x - (x1 + x2) / 2, y - y2), + point(0, 0), + angle, + ); case "w": - return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle); + return pointRotateRads( + point(x - x1, y - (y1 + y2) / 2), + point(0, 0), + angle, + ); case "e": - return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle); + return pointRotateRads( + point(x - x2, y - (y1 + y2) / 2), + point(0, 0), + angle, + ); case "nw": - return rotate(x - x1, y - y1, 0, 0, angle); + return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle); case "ne": - return rotate(x - x2, y - y1, 0, 0, angle); + return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle); case "sw": - return rotate(x - x1, y - y2, 0, 0, angle); + return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle); case "se": - return rotate(x - x2, y - y2, 0, 0, angle); + return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle); default: return [0, 0]; } diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 74ebd8e5d..c363f6180 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types"; import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; import { SIDE_RESIZING_THRESHOLD } from "../constants"; -import { - angleToDegrees, - pointOnLine, - pointRotate, -} from "../../utils/geometry/geometry"; -import type { Line, Point } from "../../utils/geometry/shape"; import { isLinearElement } from "./typeChecks"; +import type { GlobalPoint, LineSegment, LocalPoint } from "../../math"; +import { + point, + pointOnLineSegment, + pointRotateRads, + type Radians, +} from "../../math"; const isInsideTransformHandle = ( transformHandle: TransformHandle, @@ -38,7 +39,7 @@ const isInsideTransformHandle = ( y >= transformHandle[1] && y <= transformHandle[1] + transformHandle[3]; -export const resizeTest = ( +export const resizeTest = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, @@ -91,15 +92,17 @@ export const resizeTest = ( if (!(isLinearElement(element) && element.points.length <= 2)) { const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( - [x1 - SPACING, y1 - SPACING], - [x2 + SPACING, y2 + SPACING], - [cx, cy], - angleToDegrees(element.angle), + point(x1 - SPACING, y1 - SPACING), + point(x2 + SPACING, y2 + SPACING), + point(cx, cy), + element.angle, ); for (const [dir, side] of Object.entries(sides)) { // test to see if x, y are on the line segment - if (pointOnLine([x, y], side as Line, SPACING)) { + if ( + pointOnLineSegment(point(x, y), side as LineSegment, SPACING) + ) { return dir as TransformHandleType; } } @@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = ( }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null); }; -export const getTransformHandleTypeFromCoords = ( +export const getTransformHandleTypeFromCoords = < + Point extends GlobalPoint | LocalPoint, +>( [x1, y1, x2, y2]: Bounds, scenePointerX: number, scenePointerY: number, @@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = ( ): MaybeTransformHandleType => { const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, zoom, pointerType, getOmitSidesForDevice(device), @@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = ( const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value; const sides = getSelectionBorders( - [x1 - SPACING, y1 - SPACING], - [x2 + SPACING, y2 + SPACING], - [cx, cy], - angleToDegrees(0), + point(x1 - SPACING, y1 - SPACING), + point(x2 + SPACING, y2 + SPACING), + point(cx, cy), + 0 as Radians, ); for (const [dir, side] of Object.entries(sides)) { // test to see if x, y are on the line segment - if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) { + if ( + pointOnLineSegment( + point(scenePointerX, scenePointerY), + side as LineSegment, + SPACING, + ) + ) { return dir as TransformHandleType; } } @@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: { return cursor ? `${cursor}-resize` : ""; }; -const getSelectionBorders = ( +const getSelectionBorders = ( [x1, y1]: Point, [x2, y2]: Point, center: Point, - angleInDegrees: number, + angle: Radians, ) => { - const topLeft = pointRotate([x1, y1], angleInDegrees, center); - const topRight = pointRotate([x2, y1], angleInDegrees, center); - const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); - const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + const topLeft = pointRotateRads(point(x1, y1), center, angle); + const topRight = pointRotateRads(point(x2, y1), center, angle); + const bottomLeft = pointRotateRads(point(x1, y2), center, angle); + const bottomRight = pointRotateRads(point(x2, y2), center, angle); return { n: [topLeft, topRight], diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx index f00a52a57..9381541a5 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/packages/excalidraw/element/routing.test.tsx @@ -17,6 +17,7 @@ import type { ExcalidrawElbowArrowElement, } from "./types"; import { ARROW_TYPE } from "../constants"; +import { point } from "../../math"; const { h } = window; @@ -31,8 +32,8 @@ describe("elbow arrow routing", () => { }) as ExcalidrawElbowArrowElement; scene.insertElement(arrow); mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [ - [-45 - arrow.x, -100.1 - arrow.y], - [45 - arrow.x, 99.9 - arrow.y], + point(-45 - arrow.x, -100.1 - arrow.y), + point(45 - arrow.x, 99.9 - arrow.y), ]); expect(arrow.points).toEqual([ [0, 0], @@ -68,10 +69,7 @@ describe("elbow arrow routing", () => { y: -100.1, width: 90, height: 200, - points: [ - [0, 0], - [90, 200], - ], + points: [point(0, 0), point(90, 200)], }) as ExcalidrawElbowArrowElement; scene.insertElement(rectangle1); scene.insertElement(rectangle2); @@ -83,10 +81,7 @@ describe("elbow arrow routing", () => { expect(arrow.startBinding).not.toBe(null); expect(arrow.endBinding).not.toBe(null); - mutateElbowArrow(arrow, elementsMap, [ - [0, 0], - [90, 200], - ]); + mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]); expect(arrow.points).toEqual([ [0, 0], diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index bba7f5a9f..07f62ca82 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -1,16 +1,19 @@ -import { cross } from "../../utils/geometry/geometry"; -import BinaryHeap from "../binaryheap"; +import type { Radians } from "../../math"; import { - aabbForElement, - arePointsEqual, - pointInsideBounds, - pointToVector, - scalePointFromOrigin, - scaleVector, - translatePoint, -} from "../math"; + point, + pointScaleFromOrigin, + pointTranslate, + vector, + vectorCross, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, + type Vector, +} from "../../math"; +import BinaryHeap from "../binaryheap"; import { getSizeFromPoints } from "../points"; -import type { Point } from "../types"; +import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import { bindPointToSnapToElementOutline, @@ -25,6 +28,8 @@ import { import type { Bounds } from "./bounds"; import type { Heading } from "./heading"; import { + compareHeading, + flipHeading, HEADING_DOWN, HEADING_LEFT, HEADING_RIGHT, @@ -41,6 +46,8 @@ import type { } from "./types"; import type { ElementsMap, ExcalidrawBindableElement } from "./types"; +type GridAddress = [number, number] & { _brand: "gridaddress" }; + type Node = { f: number; g: number; @@ -48,8 +55,8 @@ type Node = { closed: boolean; visited: boolean; parent: Node | null; - pos: Point; - addr: [number, number]; + pos: GlobalPoint; + addr: GridAddress; }; type Grid = { @@ -63,8 +70,8 @@ const BASE_PADDING = 40; export const mutateElbowArrow = ( arrow: ExcalidrawElbowArrowElement, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, - nextPoints: readonly Point[], - offset?: Point, + nextPoints: readonly LocalPoint[], + offset?: Vector, otherUpdates?: { startBinding?: FixedPointBinding | null; endBinding?: FixedPointBinding | null; @@ -75,14 +82,20 @@ export const mutateElbowArrow = ( informMutation?: boolean; }, ) => { - const origStartGlobalPoint = translatePoint(nextPoints[0], [ - arrow.x + (offset ? offset[0] : 0), - arrow.y + (offset ? offset[1] : 0), - ]); - const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [ - arrow.x + (offset ? offset[0] : 0), - arrow.y + (offset ? offset[1] : 0), - ]); + const origStartGlobalPoint: GlobalPoint = pointTranslate( + pointTranslate( + nextPoints[0], + vector(arrow.x, arrow.y), + ), + offset, + ); + const origEndGlobalPoint: GlobalPoint = pointTranslate( + pointTranslate( + nextPoints[nextPoints.length - 1], + vector(arrow.x, arrow.y), + ), + offset, + ); const startElement = arrow.startBinding && @@ -275,7 +288,10 @@ export const mutateElbowArrow = ( ); if (path) { - const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[]; + const points = path.map((node) => [ + node.pos[0], + node.pos[1], + ]) as GlobalPoint[]; startDongle && points.unshift(startGlobalPoint); endDongle && points.push(endGlobalPoint); @@ -284,7 +300,7 @@ export const mutateElbowArrow = ( { ...otherUpdates, ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0), - angle: 0, + angle: 0 as Radians, }, options?.informMutation, ); @@ -363,7 +379,7 @@ const astar = ( } // Intersect - const neighborHalfPoint = scalePointFromOrigin( + const neighborHalfPoint = pointScaleFromOrigin( neighbor.pos, current.pos, 0.5, @@ -380,17 +396,17 @@ const astar = ( // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet. const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3); const previousDirection = current.parent - ? vectorToHeading(pointToVector(current.pos, current.parent.pos)) + ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos)) : startHeading; // Do not allow going in reverse - const reverseHeading = scaleVector(previousDirection, -1); + const reverseHeading = flipHeading(previousDirection); const neighborIsReverseRoute = - arePointsEqual(reverseHeading, neighborHeading) || - (arePointsEqual(start.addr, neighbor.addr) && - arePointsEqual(neighborHeading, startHeading)) || - (arePointsEqual(end.addr, neighbor.addr) && - arePointsEqual(neighborHeading, endHeading)); + compareHeading(reverseHeading, neighborHeading) || + (gridAddressesEqual(start.addr, neighbor.addr) && + compareHeading(neighborHeading, startHeading)) || + (gridAddressesEqual(end.addr, neighbor.addr) && + compareHeading(neighborHeading, endHeading)); if (neighborIsReverseRoute) { continue; } @@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => { return path; }; -const m_dist = (a: Point, b: Point) => +const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) => Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); /** @@ -541,7 +557,12 @@ const generateDynamicAABBs = ( const cX = first[2] + (second[0] - first[2]) / 2; const cY = second[3] + (first[1] - second[3]) / 2; - if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [first[0], first[1], cX, first[3]], [cX, second[1], second[2], second[3]], @@ -557,7 +578,12 @@ const generateDynamicAABBs = ( const cX = first[2] + (second[0] - first[2]) / 2; const cY = first[3] + (second[1] - first[3]) / 2; - if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [first[0], first[1], first[2], cY], [second[0], cY, second[2], second[3]], @@ -573,7 +599,12 @@ const generateDynamicAABBs = ( const cX = second[2] + (first[0] - second[2]) / 2; const cY = first[3] + (second[1] - first[3]) / 2; - if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[2] - endCenterX, a[1] - endCenterY), + vector(a[0] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [cX, first[1], first[2], first[3]], [second[0], second[1], cX, second[3]], @@ -589,7 +620,12 @@ const generateDynamicAABBs = ( const cX = second[2] + (first[0] - second[2]) / 2; const cY = second[3] + (first[1] - second[3]) / 2; - if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) { + if ( + vectorCross( + vector(a[0] - endCenterX, a[1] - endCenterY), + vector(a[2] - endCenterX, a[3] - endCenterY), + ) > 0 + ) { return [ [cX, first[1], first[2], first[3]], [second[0], second[1], cX, second[3]], @@ -615,9 +651,9 @@ const generateDynamicAABBs = ( */ const calculateGrid = ( aabbs: Bounds[], - start: Point, + start: GlobalPoint, startHeading: Heading, - end: Point, + end: GlobalPoint, endHeading: Heading, common: Bounds, ): Grid => { @@ -662,8 +698,8 @@ const calculateGrid = ( closed: false, visited: false, parent: null, - addr: [col, row] as [number, number], - pos: [x, y] as Point, + addr: [col, row] as GridAddress, + pos: [x, y] as GlobalPoint, }), ), ), @@ -673,17 +709,17 @@ const calculateGrid = ( const getDonglePosition = ( bounds: Bounds, heading: Heading, - point: Point, -): Point => { + p: GlobalPoint, +): GlobalPoint => { switch (heading) { case HEADING_UP: - return [point[0], bounds[1]]; + return point(p[0], bounds[1]); case HEADING_RIGHT: - return [bounds[2], point[1]]; + return point(bounds[2], p[1]); case HEADING_DOWN: - return [point[0], bounds[3]]; + return point(p[0], bounds[3]); } - return [bounds[0], point[1]]; + return point(bounds[0], p[1]); }; const estimateSegmentCount = ( @@ -826,7 +862,7 @@ const gridNodeFromAddr = ( /** * Get node for global point on canvas (if exists) */ -const pointToGridNode = (point: Point, grid: Grid): Node | null => { +const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => { for (let col = 0; col < grid.col; col++) { for (let row = 0; row < grid.row; row++) { const candidate = gridNodeFromAddr([col, row], grid); @@ -865,15 +901,24 @@ const getBindableElementForId = ( }; const normalizedArrowElementUpdate = ( - global: Point[], + global: GlobalPoint[], externalOffsetX?: number, externalOffsetY?: number, -) => { +): { + points: LocalPoint[]; + x: number; + y: number; + width: number; + height: number; +} => { const offsetX = global[0][0]; const offsetY = global[0][1]; - const points = global.map( - (point) => [point[0] - offsetX, point[1] - offsetY] as const, + const points = global.map((p) => + pointTranslate( + p, + vectorScale(vectorFromPoint(global[0]), -1), + ), ); return { @@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = ( }; /// If last and current segments have the same heading, skip the middle point -const simplifyElbowArrowPoints = (points: Point[]): Point[] => +const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] => points .slice(2) .reduce( - (result, point) => - arePointsEqual( + (result, p) => + compareHeading( vectorToHeading( - pointToVector(result[result.length - 1], result[result.length - 2]), + vectorFromPoint( + result[result.length - 1], + result[result.length - 2], + ), ), - vectorToHeading(pointToVector(point, result[result.length - 1])), + vectorToHeading(vectorFromPoint(p, result[result.length - 1])), ) - ? [...result.slice(0, -1), point] - : [...result, point], + ? [...result.slice(0, -1), p] + : [...result, p], [points[0] ?? [0, 0], points[1] ?? [1, 0]], ); @@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => { const getGlobalPoint = ( fixedPointRatio: [number, number] | undefined | null, - initialPoint: Point, - otherPoint: Point, + initialPoint: GlobalPoint, + otherPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, boundElement?: ExcalidrawBindableElement | null, hoveredElement?: ExcalidrawBindableElement | null, isDragging?: boolean, -): Point => { +): GlobalPoint => { if (isDragging) { if (hoveredElement) { const snapPoint = getSnapPoint( @@ -956,36 +1004,34 @@ const getGlobalPoint = ( }; const getSnapPoint = ( - point: Point, - otherPoint: Point, + p: GlobalPoint, + otherPoint: GlobalPoint, element: ExcalidrawBindableElement, elementsMap: ElementsMap, ) => bindPointToSnapToElementOutline( - isRectanguloidElement(element) - ? avoidRectangularCorner(element, point) - : point, + isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, otherPoint, element, elementsMap, ); const getBindPointHeading = ( - point: Point, - otherPoint: Point, + p: GlobalPoint, + otherPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, hoveredElement: ExcalidrawBindableElement | null | undefined, - origPoint: Point, + origPoint: GlobalPoint, ) => getHeadingForElbowArrowSnap( - point, + p, otherPoint, hoveredElement, hoveredElement && aabbForElement( hoveredElement, Array(4).fill( - distanceToBindableElement(hoveredElement, point, elementsMap), + distanceToBindableElement(hoveredElement, p, elementsMap), ) as [number, number, number, number], ), elementsMap, @@ -993,8 +1039,8 @@ const getBindPointHeading = ( ); const getHoveredElements = ( - origStartGlobalPoint: Point, - origEndGlobalPoint: Point, + origStartGlobalPoint: GlobalPoint, + origEndGlobalPoint: GlobalPoint, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, ) => { // TODO: Might be a performance bottleneck and the Map type @@ -1018,3 +1064,6 @@ const getHoveredElements = ( ), ]; }; + +const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean => + a[0] === b[0] && a[1] === b[1]; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9fb4766b6..9abebc356 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -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 }; }; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 7c80e5b54..98063f05b 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -19,6 +19,7 @@ import type { import { API } from "../tests/helpers/api"; import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; +import { point } from "../../math"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -41,10 +42,7 @@ describe("textWysiwyg", () => { type: "line", width: 100, height: 0, - points: [ - [0, 0], - [100, 0], - ], + points: [point(0, 0), point(100, 0)], }); const textSize = 20; const text = API.createElement({ diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 698e0227e..173c9fdc9 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -7,7 +7,6 @@ import type { import type { Bounds } from "./bounds"; import { getElementAbsoluteCoords } from "./bounds"; -import { rotate } from "../math"; import type { Device, InteractiveCanvasAppState, Zoom } from "../types"; import { isElbowArrow, @@ -19,6 +18,8 @@ import { isAndroid, isIOS, } from "../constants"; +import type { Radians } from "../../math"; +import { point, pointRotateRads } from "../../math"; export type TransformHandleDirection = | "n" @@ -91,9 +92,13 @@ const generateTransformHandle = ( height: number, cx: number, cy: number, - angle: number, + angle: Radians, ): TransformHandle => { - const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle); + const [xx, yy] = pointRotateRads( + point(x + width / 2, y + height / 2), + point(cx, cy), + angle, + ); return [xx - width / 2, yy - height / 2, width, height]; }; @@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => { export const getTransformHandlesFromCoords = ( [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number], - angle: number, + angle: Radians, zoom: Zoom, pointerType: PointerType, omitSides: { [T in TransformHandleType]?: boolean } = {}, diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 78f1a458a..5ba089ab0 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -1,6 +1,5 @@ -import type { LineSegment } from "../../utils"; import { ROUNDNESS } from "../constants"; -import type { ElementOrToolType, Point } from "../types"; +import type { ElementOrToolType } from "../types"; import type { MarkNonNullable } from "../utility-types"; import { assertNever } from "../utils"; import type { Bounds } from "./bounds"; @@ -191,7 +190,8 @@ export const isRectangularElement = ( element.type === "iframe" || element.type === "embeddable" || element.type === "frame" || - element.type === "magicframe") + element.type === "magicframe" || + element.type === "freedraw") ); }; @@ -325,10 +325,6 @@ export const isFixedPointBinding = ( return binding.fixedPoint != null; }; -// TODO: Move this to @excalidraw/math -export const isPoint = (point: unknown): point is Point => - Array.isArray(point) && point.length === 2; - // TODO: Move this to @excalidraw/math export const isBounds = (box: unknown): box is Bounds => Array.isArray(box) && @@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds => typeof box[1] === "number" && typeof box[2] === "number" && typeof box[3] === "number"; - -// TODO: Move this to @excalidraw/math -export const isLineSegment = (segment: unknown): segment is LineSegment => - Array.isArray(segment) && - segment.length === 2 && - isPoint(segment[0]) && - isPoint(segment[0]); diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 4322cf851..9b0925427 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -1,4 +1,4 @@ -import type { Point } from "../types"; +import type { LocalPoint, Radians } from "../../math"; import type { FONT_FAMILY, ROUNDNESS, @@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{ opacity: number; width: number; height: number; - angle: number; + angle: Radians; /** Random integer used to seed shape generation so that the roughjs shape doesn't differ across renders. */ seed: number; @@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement = | ExcalidrawDiamondElement | ExcalidrawEllipseElement; +export type ExcalidrawRectanguloidElement = + | ExcalidrawRectangleElement + | ExcalidrawImageElement + | ExcalidrawTextElement + | ExcalidrawFreeDrawElement + | ExcalidrawIframeLikeElement + | ExcalidrawFrameLikeElement + | ExcalidrawEmbeddableElement; + /** * ExcalidrawElement should be JSON serializable and (eventually) contain * no computed data. The list of all ExcalidrawElements should be shareable @@ -283,8 +292,8 @@ export type Arrowhead = export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ type: "line" | "arrow"; - points: readonly Point[]; - lastCommittedPoint: Point | null; + points: readonly LocalPoint[]; + lastCommittedPoint: LocalPoint | null; startBinding: PointBinding | null; endBinding: PointBinding | null; startArrowhead: Arrowhead | null; @@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge< export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & Readonly<{ type: "freedraw"; - points: readonly Point[]; + points: readonly LocalPoint[]; pressures: readonly number[]; simulatePressure: boolean; - lastCommittedPoint: Point | null; + lastCommittedPoint: LocalPoint | null; }>; export type FileId = string & { _brand: "FileId" }; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 469a360ea..fb9a45820 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -11,7 +11,6 @@ import type { NonDeleted, NonDeletedExcalidrawElement, } from "./element/types"; -import { isPointWithinBounds } from "./math"; import { getBoundTextElement, getContainerElement, @@ -30,6 +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"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( @@ -159,9 +159,9 @@ export const isCursorInFrame = ( const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap); return isPointWithinBounds( - [fx1, fy1], - [cursorCoords.x, cursorCoords.y], - [fx2, fy2], + point(fx1, fy1), + point(cursorCoords.x, cursorCoords.y), + point(fx2, fy2), ); }; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ebf9ff872..ff1fa2026 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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..." + }, "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.", diff --git a/packages/excalidraw/math.test.ts b/packages/excalidraw/math.test.ts deleted file mode 100644 index 0d2342838..000000000 --- a/packages/excalidraw/math.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - isPointOnSymmetricArc, - rangeIntersection, - rangesOverlap, - rotate, -} from "./math"; - -describe("rotate", () => { - it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { - const x1 = 10; - const y1 = 20; - const x2 = 20; - const y2 = 30; - const angle = Math.PI / 2; - const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle); - expect([rotatedX, rotatedY]).toEqual([30, 20]); - const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle); - expect(res2).toEqual([x1, x2]); - }); -}); - -describe("range overlap", () => { - it("should overlap when range a contains range b", () => { - expect(rangesOverlap([1, 4], [2, 3])).toBe(true); - expect(rangesOverlap([1, 4], [1, 4])).toBe(true); - expect(rangesOverlap([1, 4], [1, 3])).toBe(true); - expect(rangesOverlap([1, 4], [2, 4])).toBe(true); - }); - - it("should overlap when range b contains range a", () => { - expect(rangesOverlap([2, 3], [1, 4])).toBe(true); - expect(rangesOverlap([1, 3], [1, 4])).toBe(true); - expect(rangesOverlap([2, 4], [1, 4])).toBe(true); - }); - - it("should overlap when range a and b intersect", () => { - expect(rangesOverlap([1, 4], [2, 5])).toBe(true); - }); -}); - -describe("range intersection", () => { - it("should intersect completely with itself", () => { - expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]); - }); - - it("should intersect irrespective of order", () => { - expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]); - expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]); - expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]); - expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]); - }); - - it("should intersect at the edge", () => { - expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]); - }); - - it("should not intersect", () => { - expect(rangeIntersection([1, 4], [5, 7])).toEqual(null); - }); -}); - -describe("point on arc", () => { - it("should detect point on simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [0.92291667, 0.385], - ), - ).toBe(true); - }); - it("should not detect point outside of a simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [-0.92291667, 0.385], - ), - ).toBe(false); - }); - it("should not detect point with good angle but incorrect radius", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - [-0.5, 0.5], - ), - ).toBe(false); - }); -}); diff --git a/packages/excalidraw/math.ts b/packages/excalidraw/math.ts deleted file mode 100644 index 32414efb1..000000000 --- a/packages/excalidraw/math.ts +++ /dev/null @@ -1,715 +0,0 @@ -import type { - NormalizedZoomValue, - NullableGridSize, - Point, - Zoom, -} from "./types"; -import { - DEFAULT_ADAPTIVE_RADIUS, - LINE_CONFIRM_THRESHOLD, - DEFAULT_PROPORTIONAL_RADIUS, - ROUNDNESS, -} from "./constants"; -import type { - ExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, -} from "./element/types"; -import type { Bounds } from "./element/bounds"; -import { getCurvePathOps } from "./element/bounds"; -import type { Mutable } from "./utility-types"; -import { ShapeCache } from "./scene/ShapeCache"; -import type { Vector } from "../utils/geometry/shape"; - -export const rotate = ( - // target point to rotate - x: number, - y: number, - // point to rotate against - cx: number, - cy: number, - angle: number, -): [number, number] => - // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥 - // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦. - // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line - [ - (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, - (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, - ]; - -export const rotatePoint = ( - point: Point, - center: Point, - angle: number, -): [number, number] => rotate(point[0], point[1], center[0], center[1], angle); - -export const adjustXYWithRotation = ( - sides: { - n?: boolean; - e?: boolean; - s?: boolean; - w?: boolean; - }, - x: number, - y: number, - angle: number, - deltaX1: number, - deltaY1: number, - deltaX2: number, - deltaY2: number, -): [number, number] => { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - if (sides.e && sides.w) { - x += deltaX1 + deltaX2; - } else if (sides.e) { - x += deltaX1 * (1 + cos); - y += deltaX1 * sin; - x += deltaX2 * (1 - cos); - y += deltaX2 * -sin; - } else if (sides.w) { - x += deltaX1 * (1 - cos); - y += deltaX1 * -sin; - x += deltaX2 * (1 + cos); - y += deltaX2 * sin; - } - - if (sides.n && sides.s) { - y += deltaY1 + deltaY2; - } else if (sides.n) { - x += deltaY1 * sin; - y += deltaY1 * (1 - cos); - x += deltaY2 * -sin; - y += deltaY2 * (1 + cos); - } else if (sides.s) { - x += deltaY1 * -sin; - y += deltaY1 * (1 + cos); - x += deltaY2 * sin; - y += deltaY2 * (1 - cos); - } - return [x, y]; -}; - -export const getPointOnAPath = (point: Point, path: Point[]) => { - const [px, py] = point; - const [start, ...other] = path; - let [lastX, lastY] = start; - let kLine: number = 0; - let idx: number = 0; - - // if any item in the array is true, it means that a point is - // on some segment of a line based path - const retVal = other.some(([x2, y2], i) => { - // we always take a line when dealing with line segments - const x1 = lastX; - const y1 = lastY; - - lastX = x2; - lastY = y2; - - // if a point is not within the domain of the line segment - // it is not on the line segment - if (px < x1 || px > x2) { - return false; - } - - // check if all points lie on the same line - // y1 = kx1 + b, y2 = kx2 + b - // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1) - - // coefficient for the line (p0, p1) - const kL = (y2 - y1) / (x2 - x1); - - // coefficient for the line segment (p0, point) - const kP1 = (py - y1) / (px - x1); - - // coefficient for the line segment (point, p1) - const kP2 = (py - y2) / (px - x2); - - // because we are basing both lines from the same starting point - // the only option for collinearity is having same coefficients - - // using it for floating point comparisons - const epsilon = 0.3; - - // if coefficient is more than an arbitrary epsilon, - // these lines are nor collinear - if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) { - return false; - } - - // store the coefficient because we are goint to need it - kLine = kL; - idx = i; - - return true; - }); - - // Return a coordinate that is always on the line segment - if (retVal === true) { - return { x: point[0], y: kLine * point[0], segment: idx }; - } - - return null; -}; - -export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { - const xd = x2 - x1; - const yd = y2 - y1; - return Math.hypot(xd, yd); -}; - -export const distanceSq2d = (p1: Point, p2: Point) => { - const xd = p2[0] - p1[0]; - const yd = p2[1] - p1[1]; - return xd * xd + yd * yd; -}; - -export const centerPoint = (a: Point, b: Point): Point => { - return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; -}; - -// Checks if the first and last point are close enough -// to be considered a loop -export const isPathALoop = ( - points: ExcalidrawLinearElement["points"], - /** supply if you want the loop detection to account for current zoom */ - zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, -): boolean => { - if (points.length >= 3) { - const [first, last] = [points[0], points[points.length - 1]]; - const distance = distance2d(first[0], first[1], last[0], last[1]); - - // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in - // really close we make the threshold smaller, and vice versa. - return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; - } - return false; -}; - -// Draw a line from the point to the right till infiinty -// Check how many lines of the polygon does this infinite line intersects with -// If the number of intersections is odd, point is in the polygon -export const isPointInPolygon = ( - points: Point[], - x: number, - y: number, -): boolean => { - const vertices = points.length; - - // There must be at least 3 vertices in polygon - if (vertices < 3) { - return false; - } - const extreme: Point = [Number.MAX_SAFE_INTEGER, y]; - const p: Point = [x, y]; - let count = 0; - for (let i = 0; i < vertices; i++) { - const current = points[i]; - const next = points[(i + 1) % vertices]; - if (doSegmentsIntersect(current, next, p, extreme)) { - if (orderedColinearOrientation(current, p, next) === 0) { - return isPointWithinBounds(current, p, next); - } - count++; - } - } - // true if count is off - return count % 2 === 1; -}; - -// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. -// This is an approximation to "does `q` lie on a segment `pr`" check. -export const isPointWithinBounds = (p: Point, q: Point, r: Point) => { - return ( - q[0] <= Math.max(p[0], r[0]) && - q[0] >= Math.min(p[0], r[0]) && - q[1] <= Math.max(p[1], r[1]) && - q[1] >= Math.min(p[1], r[1]) - ); -}; - -// For the ordered points p, q, r, return -// 0 if p, q, r are colinear -// 1 if Clockwise -// 2 if counterclickwise -const orderedColinearOrientation = (p: Point, q: Point, r: Point) => { - const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]); - if (val === 0) { - return 0; - } - return val > 0 ? 1 : 2; -}; - -// Check is p1q1 intersects with p2q2 -const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => { - const o1 = orderedColinearOrientation(p1, q1, p2); - const o2 = orderedColinearOrientation(p1, q1, q2); - const o3 = orderedColinearOrientation(p2, q2, p1); - const o4 = orderedColinearOrientation(p2, q2, q1); - - if (o1 !== o2 && o3 !== o4) { - return true; - } - - // p1, q1 and p2 are colinear and p2 lies on segment p1q1 - if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) { - return true; - } - - // p1, q1 and p2 are colinear and q2 lies on segment p1q1 - if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) { - return true; - } - - // p2, q2 and p1 are colinear and p1 lies on segment p2q2 - if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) { - return true; - } - - // p2, q2 and q1 are colinear and q1 lies on segment p2q2 - if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) { - return true; - } - - return false; -}; - -// TODO: Rounding this point causes some shake when free drawing -export const getGridPoint = ( - x: number, - y: number, - gridSize: NullableGridSize, -): [number, number] => { - if (gridSize) { - return [ - Math.round(x / gridSize) * gridSize, - Math.round(y / gridSize) * gridSize, - ]; - } - return [x, y]; -}; - -export const getCornerRadius = (x: number, element: ExcalidrawElement) => { - if ( - element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || - element.roundness?.type === ROUNDNESS.LEGACY - ) { - return x * DEFAULT_PROPORTIONAL_RADIUS; - } - - if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { - const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; - - const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; - - if (x <= CUTOFF_SIZE) { - return x * DEFAULT_PROPORTIONAL_RADIUS; - } - - return fixedRadiusSize; - } - - return 0; -}; - -export const getControlPointsForBezierCurve = ( - element: NonDeleted, - endPoint: Point, -) => { - const shape = ShapeCache.generateElementShape(element, null); - if (!shape) { - return null; - } - - const ops = getCurvePathOps(shape[0]); - let currentP: Mutable = [0, 0]; - let index = 0; - let minDistance = Infinity; - let controlPoints: Mutable[] | null = null; - - while (index < ops.length) { - const { op, data } = ops[index]; - if (op === "move") { - currentP = data as unknown as Mutable; - } - if (op === "bcurveTo") { - const p0 = currentP; - const p1 = [data[0], data[1]] as Mutable; - const p2 = [data[2], data[3]] as Mutable; - const p3 = [data[4], data[5]] as Mutable; - const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]); - if (distance < minDistance) { - minDistance = distance; - controlPoints = [p0, p1, p2, p3]; - } - currentP = p3; - } - index++; - } - - return controlPoints; -}; - -export const getBezierXY = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - t: number, -) => { - const equation = (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); - const tx = equation(t, 0); - const ty = equation(t, 1); - return [tx, ty]; -}; - -export const getPointsInBezierCurve = ( - element: NonDeleted, - endPoint: Point, -) => { - const controlPoints: Mutable[] = getControlPointsForBezierCurve( - element, - endPoint, - )!; - if (!controlPoints) { - return []; - } - const pointsOnCurve: Mutable[] = []; - let t = 1; - // Take 20 points on curve for better accuracy - while (t > 0) { - const point = getBezierXY( - controlPoints[0], - controlPoints[1], - controlPoints[2], - controlPoints[3], - t, - ); - pointsOnCurve.push([point[0], point[1]]); - t -= 0.05; - } - if (pointsOnCurve.length) { - if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) { - pointsOnCurve.push([endPoint[0], endPoint[1]]); - } - } - return pointsOnCurve; -}; - -export const getBezierCurveArcLengths = ( - element: NonDeleted, - endPoint: Point, -) => { - const arcLengths: number[] = []; - arcLengths[0] = 0; - const points = getPointsInBezierCurve(element, endPoint); - let index = 0; - let distance = 0; - while (index < points.length - 1) { - const segmentDistance = distance2d( - points[index][0], - points[index][1], - points[index + 1][0], - points[index + 1][1], - ); - distance += segmentDistance; - arcLengths.push(distance); - index++; - } - - return arcLengths; -}; - -export const getBezierCurveLength = ( - element: NonDeleted, - endPoint: Point, -) => { - const arcLengths = getBezierCurveArcLengths(element, endPoint); - return arcLengths.at(-1) as number; -}; - -// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length -export const mapIntervalToBezierT = ( - element: NonDeleted, - endPoint: Point, - interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, -) => { - const arcLengths = getBezierCurveArcLengths(element, endPoint); - const pointsCount = arcLengths.length - 1; - const curveLength = arcLengths.at(-1) as number; - const targetLength = interval * curveLength; - let low = 0; - let high = pointsCount; - let index = 0; - // Doing a binary search to find the largest length that is less than the target length - while (low < high) { - index = Math.floor(low + (high - low) / 2); - if (arcLengths[index] < targetLength) { - low = index + 1; - } else { - high = index; - } - } - if (arcLengths[index] > targetLength) { - index--; - } - if (arcLengths[index] === targetLength) { - return index / pointsCount; - } - - return ( - 1 - - (index + - (targetLength - arcLengths[index]) / - (arcLengths[index + 1] - arcLengths[index])) / - pointsCount - ); -}; - -export const arePointsEqual = (p1: Point, p2: Point) => { - return p1[0] === p2[0] && p1[1] === p2[1]; -}; - -export const isRightAngle = (angle: number) => { - // if our angles were mathematically accurate, we could just check - // - // angle % (Math.PI / 2) === 0 - // - // but since we're in floating point land, we need to round. - // - // Below, after dividing by Math.PI, a multiple of 0.5 indicates a right - // angle, which we can check with modulo after rounding. - return Math.round((angle / Math.PI) * 10000) % 5000 === 0; -}; - -export const radianToDegree = (r: number) => { - return (r * 180) / Math.PI; -}; - -export const degreeToRadian = (d: number) => { - return (d / 180) * Math.PI; -}; - -// Given two ranges, return if the two ranges overlap with each other -// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5] -export const rangesOverlap = ( - [a0, a1]: [number, number], - [b0, b1]: [number, number], -) => { - if (a0 <= b0) { - return a1 >= b0; - } - - if (a0 >= b0) { - return b1 >= a0; - } - - return false; -}; - -// Given two ranges,return ther intersection of the two ranges if any -// e.g. the intersection of [1, 3] and [2, 4] is [2, 3] -export const rangeIntersection = ( - rangeA: [number, number], - rangeB: [number, number], -): [number, number] | null => { - const rangeStart = Math.max(rangeA[0], rangeB[0]); - const rangeEnd = Math.min(rangeA[1], rangeB[1]); - - if (rangeStart <= rangeEnd) { - return [rangeStart, rangeEnd]; - } - - return null; -}; - -export const isValueInRange = (value: number, min: number, max: number) => { - return value >= min && value <= max; -}; - -export const translatePoint = (p: Point, v: Vector): Point => [ - p[0] + v[0], - p[1] + v[1], -]; - -export const scaleVector = (v: Vector, scalar: number): Vector => [ - v[0] * scalar, - v[1] * scalar, -]; - -export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [ - p[0] - origin[0], - p[1] - origin[1], -]; - -export const scalePointFromOrigin = ( - p: Point, - mid: Point, - multiplier: number, -) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier)); - -const triangleSign = (p1: Point, p2: Point, p3: Point): number => - (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]); - -export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => { - const d1 = triangleSign(pt, v1, v2); - const d2 = triangleSign(pt, v2, v3); - const d3 = triangleSign(pt, v3, v1); - - const has_neg = d1 < 0 || d2 < 0 || d3 < 0; - const has_pos = d1 > 0 || d2 > 0 || d3 > 0; - - return !(has_neg && has_pos); -}; - -export const magnitudeSq = (vector: Vector) => - vector[0] * vector[0] + vector[1] * vector[1]; - -export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector)); - -export const normalize = (vector: Vector): Vector => { - const m = magnitude(vector); - - return [vector[0] / m, vector[1] / m]; -}; - -export const addVectors = ( - vec1: Readonly, - vec2: Readonly, -): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]]; - -export const subtractVectors = ( - vec1: Readonly, - vec2: Readonly, -): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]]; - -export const pointInsideBounds = (p: Point, bounds: Bounds): boolean => - p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; - -/** - * Get the axis-aligned bounding box for a given element - */ -export const aabbForElement = ( - element: Readonly, - offset?: [number, number, number, number], -) => { - const bbox = { - minX: element.x, - minY: element.y, - maxX: element.x + element.width, - maxY: element.y + element.height, - midX: element.x + element.width / 2, - midY: element.y + element.height / 2, - }; - - const center = [bbox.midX, bbox.midY] as Point; - const [topLeftX, topLeftY] = rotatePoint( - [bbox.minX, bbox.minY], - center, - element.angle, - ); - const [topRightX, topRightY] = rotatePoint( - [bbox.maxX, bbox.minY], - center, - element.angle, - ); - const [bottomRightX, bottomRightY] = rotatePoint( - [bbox.maxX, bbox.maxY], - center, - element.angle, - ); - const [bottomLeftX, bottomLeftY] = rotatePoint( - [bbox.minX, bbox.maxY], - center, - element.angle, - ); - - const bounds = [ - Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), - Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), - Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), - Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), - ] as Bounds; - - if (offset) { - const [topOffset, rightOffset, downOffset, leftOffset] = offset; - return [ - bounds[0] - leftOffset, - bounds[1] - topOffset, - bounds[2] + rightOffset, - bounds[3] + downOffset, - ] as Bounds; - } - - return bounds; -}; - -type PolarCoords = [number, number]; - -/** - * Return the polar coordinates for the given carthesian point represented by - * (x, y) for the center point 0,0 where the first number returned is the radius, - * the second is the angle in radians. - */ -export const carthesian2Polar = ([x, y]: Point): PolarCoords => [ - Math.hypot(x, y), - Math.atan2(y, x), -]; - -/** - * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle - * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right". - */ -type SymmetricArc = { radius: number; startAngle: number; endAngle: number }; - -/** - * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which - * is part of a circle contour centered on 0, 0. - */ -export const isPointOnSymmetricArc = ( - { radius: arcRadius, startAngle, endAngle }: SymmetricArc, - point: Point, -): boolean => { - const [radius, angle] = carthesian2Polar(point); - - return startAngle < endAngle - ? Math.abs(radius - arcRadius) < 0.0000001 && - startAngle <= angle && - endAngle >= angle - : startAngle <= angle || endAngle >= angle; -}; - -export const getCenterForBounds = (bounds: Bounds): Point => [ - bounds[0] + (bounds[2] - bounds[0]) / 2, - bounds[1] + (bounds[3] - bounds[1]) / 2, -]; - -export const getCenterForElement = (element: ExcalidrawElement): Point => [ - element.x + element.width / 2, - element.y + element.height / 2, -]; - -export const aabbsOverlapping = (a: Bounds, b: Bounds) => - pointInsideBounds([a[0], a[1]], b) || - pointInsideBounds([a[2], a[1]], b) || - pointInsideBounds([a[2], a[3]], b) || - pointInsideBounds([a[0], a[3]], b) || - pointInsideBounds([b[0], b[1]], a) || - pointInsideBounds([b[2], b[1]], a) || - pointInsideBounds([b[2], b[3]], a) || - pointInsideBounds([b[0], b[3]], a); - -export const clamp = (value: number, min: number, max: number) => { - return Math.min(Math.max(value, min), max); -}; - -export const round = (value: number, precision: number) => { - const multiplier = Math.pow(10, precision); - return Math.round((value + Number.EPSILON) * multiplier) / multiplier; -}; diff --git a/packages/excalidraw/points.ts b/packages/excalidraw/points.ts index e7ff2c47a..5f9480120 100644 --- a/packages/excalidraw/points.ts +++ b/packages/excalidraw/points.ts @@ -1,6 +1,8 @@ -import type { Point } from "./types"; +import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math"; -export const getSizeFromPoints = (points: readonly Point[]) => { +export const getSizeFromPoints = ( + points: readonly (GlobalPoint | LocalPoint)[], +) => { const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); return { @@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => { }; /** @arg dimension, 0 for rescaling only x, 1 for y */ -export const rescalePoints = ( +export const rescalePoints = ( dimension: 0 | 1, newSize: number, points: readonly Point[], @@ -31,7 +33,7 @@ export const rescalePoints = ( if (newCoordinate < nextMinCoordinate) { nextMinCoordinate = newCoordinate; } - return newPoint as unknown as Point; + return newPoint as Point; }); if (!normalize) { @@ -45,11 +47,13 @@ export const rescalePoints = ( const translation = minCoordinate - nextMinCoordinate; - const nextPoints = scaledPoints.map( - (scaledPoint) => + const nextPoints = scaledPoints.map((scaledPoint) => + pointFromPair( scaledPoint.map((value, currentDimension) => { return currentDimension === dimension ? value + translation : value; }) as [number, number], + ), ); + return nextPoints; }; diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 597ab0696..0d03b0f5a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -30,8 +30,12 @@ import { shouldShowBoundingBox, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import type { InteractiveCanvasAppState, Point } 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"; @@ -69,7 +73,8 @@ import type { InteractiveSceneRenderConfig, RenderableElementsMap, } from "../scene/types"; -import { getCornerRadius } from "../math"; +import type { GlobalPoint, LocalPoint, Radians } from "../../math"; +import { getCornerRadius } from "../shapes"; const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, @@ -101,7 +106,7 @@ const renderLinearElementPointHighlight = ( context.restore(); }; -const highlightPoint = ( +const highlightPoint = ( point: Point, context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -168,7 +173,7 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const renderSingleLinearPoint = ( +const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, point: Point, @@ -499,7 +504,7 @@ const renderLinearPointHandles = ( element, elementsMap, appState, - ).filter((midPoint) => midPoint !== null) as Point[]; + ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null); midPoints.forEach((segmentMidPoint) => { if ( @@ -931,7 +936,7 @@ const _renderInteractiveScene = ({ context.setLineDash(initialLineDash); const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, appState.zoom, "mouse", isFrameSelected @@ -951,9 +956,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({ diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 42473fcdc..9995c748a 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -27,7 +27,6 @@ import type { InteractiveCanvasRenderConfig, } from "../scene/types"; import { distance, getFontString, isRTL } from "../utils"; -import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import type { AppState, @@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; import { ShapeCache } from "../scene/ShapeCache"; import { getVerticalOffset } from "../fonts"; +import { isRightAngleRads } from "../../math"; +import { getCornerRadius } from "../shapes"; // 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 @@ -907,7 +908,8 @@ export const renderElement = ( (!element.angle || // or check if angle is a right angle in which case we can still // disable smoothing without adversely affecting the result - isRightAngle(element.angle)) + // We need less-than comparison because of FP artihmetic + isRightAngleRads(element.angle)) ) { // Disabling smoothing makes output much sharper, especially for // text. Unless for non-right angles, where the aliasing is really diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts index 190a72904..33b57ce68 100644 --- a/packages/excalidraw/renderer/renderSnaps.ts +++ b/packages/excalidraw/renderer/renderSnaps.ts @@ -1,6 +1,7 @@ +import { point, type GlobalPoint, type LocalPoint } from "../../math"; import { THEME } from "../constants"; import type { PointSnapLine, PointerSnapLine } from "../snapping"; -import type { InteractiveCanvasAppState, Point } from "../types"; +import type { InteractiveCanvasAppState } from "../types"; const SNAP_COLOR_LIGHT = "#ff6b6b"; const SNAP_COLOR_DARK = "#ff0000"; @@ -85,7 +86,7 @@ const drawPointerSnapLine = ( } }; -const drawCross = ( +const drawCross = ( [x, y]: Point, appState: InteractiveCanvasAppState, context: CanvasRenderingContext2D, @@ -106,18 +107,18 @@ const drawCross = ( context.restore(); }; -const drawLine = ( +const drawLine = ( from: Point, to: Point, context: CanvasRenderingContext2D, ) => { context.beginPath(); - context.lineTo(...from); - context.lineTo(...to); + context.lineTo(from[0], from[1]); + context.lineTo(to[0], to[1]); context.stroke(); }; -const drawGapLine = ( +const drawGapLine = ( from: Point, to: Point, direction: "horizontal" | "vertical", @@ -138,24 +139,28 @@ const drawGapLine = ( const halfPoint = [(from[0] + to[0]) / 2, from[1]]; // (1) if (!appState.zenModeEnabled) { - drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context); + drawLine( + point(from[0], from[1] - FULL), + point(from[0], from[1] + FULL), + context, + ); } // (3) drawLine( - [halfPoint[0] - QUARTER, halfPoint[1] - HALF], - [halfPoint[0] - QUARTER, halfPoint[1] + HALF], + point(halfPoint[0] - QUARTER, halfPoint[1] - HALF), + point(halfPoint[0] - QUARTER, halfPoint[1] + HALF), context, ); drawLine( - [halfPoint[0] + QUARTER, halfPoint[1] - HALF], - [halfPoint[0] + QUARTER, halfPoint[1] + HALF], + point(halfPoint[0] + QUARTER, halfPoint[1] - HALF), + point(halfPoint[0] + QUARTER, halfPoint[1] + HALF), context, ); if (!appState.zenModeEnabled) { // (4) - drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context); + drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context); // (2) drawLine(from, to, context); @@ -164,24 +169,28 @@ const drawGapLine = ( const halfPoint = [from[0], (from[1] + to[1]) / 2]; // (1) if (!appState.zenModeEnabled) { - drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context); + drawLine( + point(from[0] - FULL, from[1]), + point(from[0] + FULL, from[1]), + context, + ); } // (3) drawLine( - [halfPoint[0] - HALF, halfPoint[1] - QUARTER], - [halfPoint[0] + HALF, halfPoint[1] - QUARTER], + point(halfPoint[0] - HALF, halfPoint[1] - QUARTER), + point(halfPoint[0] + HALF, halfPoint[1] - QUARTER), context, ); drawLine( - [halfPoint[0] - HALF, halfPoint[1] + QUARTER], - [halfPoint[0] + HALF, halfPoint[1] + QUARTER], + point(halfPoint[0] - HALF, halfPoint[1] + QUARTER), + point(halfPoint[0] + HALF, halfPoint[1] + QUARTER), context, ); if (!appState.zenModeEnabled) { // (4) - drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context); + drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context); // (2) drawLine(from, to, context); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index 19c48ee05..19169d4a9 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -30,13 +30,13 @@ import type { NonDeletedExcalidrawElement, } from "../element/types"; import { getContainingFrame } from "../frame"; -import { getCornerRadius, isPathALoop } from "../math"; import { ShapeCache } from "../scene/ShapeCache"; import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; import type { AppState, BinaryFiles } from "../types"; import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getVerticalOffset } from "../fonts"; +import { getCornerRadius, isPathALoop } from "../shapes"; const roughSVGDrawWithPrecision = ( rsvg: RoughSVG, diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 4bc92f9c7..fad0f4f93 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -1,3 +1,4 @@ +import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Drawable, Options } from "roughjs/bin/core"; import type { RoughGenerator } from "roughjs/bin/generator"; import { getDiamondPoints, getArrowheadPoints } from "../element"; @@ -9,7 +10,6 @@ import type { ExcalidrawLinearElement, Arrowhead, } from "../element/types"; -import { isPathALoop, getCornerRadius, distanceSq2d } from "../math"; import { generateFreeDrawShape } from "../renderer/renderElement"; import { isTransparent, assertNever } from "../utils"; import { simplify } from "points-on-curve"; @@ -23,6 +23,13 @@ import { } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; import type { EmbedsValidationStatus } from "../types"; +import { + point, + pointDistance, + type GlobalPoint, + type LocalPoint, +} from "../../math"; +import { getCornerRadius, isPathALoop } from "../shapes"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -399,12 +406,14 @@ export const _generateElementShape = ( // points array can be empty in the beginning, so it is important to add // initial position to it - const points = element.points.length ? element.points : [[0, 0]]; + const points = element.points.length + ? element.points + : [point(0, 0)]; if (isElbowArrow(element)) { shape = [ generator.path( - generateElbowArrowShape(points as [number, number][], 16), + generateElbowArrowShape(points, 16), generateRoughOptions(element, true), ), ]; @@ -412,12 +421,16 @@ export const _generateElementShape = ( // curve is always the first element // this simplifies finding the curve for an element if (options.fill) { - shape = [generator.polygon(points as [number, number][], options)]; + shape = [ + generator.polygon(points as unknown as RoughPoint[], options), + ]; } else { - shape = [generator.linearPath(points as [number, number][], options)]; + shape = [ + generator.linearPath(points as unknown as RoughPoint[], options), + ]; } } else { - shape = [generator.curve(points as [number, number][], options)]; + shape = [generator.curve(points as unknown as RoughPoint[], options)]; } // add lines only in arrow @@ -491,8 +504,8 @@ export const _generateElementShape = ( } }; -const generateElbowArrowShape = ( - points: [number, number][], +const generateElbowArrowShape = ( + points: readonly Point[], radius: number, ) => { const subpoints = [] as [number, number][]; @@ -501,8 +514,8 @@ const generateElbowArrowShape = ( const next = points[i + 1]; const corner = Math.min( radius, - Math.sqrt(distanceSq2d(points[i], next)) / 2, - Math.sqrt(distanceSq2d(points[i], prev)) / 2, + pointDistance(points[i], next) / 2, + pointDistance(points[i], prev) / 2, ); if (prev[0] < points[i][0] && prev[1] === points[i][1]) { diff --git a/packages/excalidraw/scene/normalize.ts b/packages/excalidraw/scene/normalize.ts index 11f68ca9b..a6980c91d 100644 --- a/packages/excalidraw/scene/normalize.ts +++ b/packages/excalidraw/scene/normalize.ts @@ -1,5 +1,5 @@ +import { clamp, round } from "../../math"; import { MAX_ZOOM, MIN_ZOOM } from "../constants"; -import { clamp, round } from "../math"; import type { NormalizedZoomValue } from "../types"; export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => { diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index acdca238d..2c935145c 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -1,5 +1,16 @@ +import { + isPoint, + point, + pointDistance, + pointFromPair, + pointRotateRads, + pointsEqual, + type GlobalPoint, + type LocalPoint, +} from "../math"; import { getClosedCurveShape, + getCurvePathOps, getCurveShape, getEllipseShape, getFreedrawShape, @@ -18,13 +29,27 @@ import { SelectionIcon, TextIcon, } from "./components/icons"; +import { + DEFAULT_ADAPTIVE_RADIUS, + DEFAULT_PROPORTIONAL_RADIUS, + LINE_CONFIRM_THRESHOLD, + ROUNDNESS, +} from "./constants"; import { getElementAbsoluteCoords } from "./element"; +import type { Bounds } from "./element/bounds"; import { shouldTestInside } from "./element/collision"; import { LinearElementEditor } from "./element/linearElementEditor"; import { getBoundTextElement } from "./element/textElement"; -import type { ElementsMap, ExcalidrawElement } from "./element/types"; +import type { + ElementsMap, + ExcalidrawElement, + ExcalidrawLinearElement, + NonDeleted, +} from "./element/types"; import { KEYS } from "./keys"; import { ShapeCache } from "./scene/ShapeCache"; +import type { NormalizedZoomValue, Zoom } from "./types"; +import { invariant } from "./utils"; export const SHAPES = [ { @@ -116,10 +141,10 @@ export const findShapeByKey = (key: string) => { * get the pure geometric shape of an excalidraw element * which is then used for hit detection */ -export const getElementShape = ( +export const getElementShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape => { +): GeometricShape => { switch (element.type) { case "rectangle": case "diamond": @@ -139,17 +164,19 @@ export const getElementShape = ( const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); return shouldTestInside(element) - ? getClosedCurveShape( + ? getClosedCurveShape( element, roughShape, - [element.x, element.y], + point(element.x, element.y), element.angle, - [cx, cy], + point(cx, cy), ) - : getCurveShape(roughShape, [element.x, element.y], element.angle, [ - cx, - cy, - ]); + : getCurveShape( + roughShape, + point(element.x, element.y), + element.angle, + point(cx, cy), + ); } case "ellipse": @@ -157,15 +184,19 @@ export const getElementShape = ( case "freedraw": { const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - return getFreedrawShape(element, [cx, cy], shouldTestInside(element)); + return getFreedrawShape( + element, + point(cx, cy), + shouldTestInside(element), + ); } } }; -export const getBoundTextShape = ( +export const getBoundTextShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape | null => { +): GeometricShape | null => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { @@ -189,3 +220,274 @@ export const getBoundTextShape = ( return null; }; + +export const getControlPointsForBezierCurve = < + P extends GlobalPoint | LocalPoint, +>( + element: NonDeleted, + endPoint: P, +) => { + const shape = ShapeCache.generateElementShape(element, null); + if (!shape) { + return null; + } + + const ops = getCurvePathOps(shape[0]); + let currentP = point

(0, 0); + let index = 0; + let minDistance = Infinity; + let controlPoints: P[] | null = null; + + while (index < ops.length) { + const { op, data } = ops[index]; + if (op === "move") { + invariant( + isPoint(data), + "The returned ops is not compatible with a point", + ); + currentP = pointFromPair(data); + } + if (op === "bcurveTo") { + const p0 = currentP; + const p1 = point

(data[0], data[1]); + const p2 = point

(data[2], data[3]); + const p3 = point

(data[4], data[5]); + const distance = pointDistance(p3, endPoint); + if (distance < minDistance) { + minDistance = distance; + controlPoints = [p0, p1, p2, p3]; + } + currentP = p3; + } + index++; + } + + return controlPoints; +}; + +export const getBezierXY =

( + p0: P, + p1: P, + p2: P, + p3: P, + t: number, +): P => { + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + const tx = equation(t, 0); + const ty = equation(t, 1); + return point(tx, ty); +}; + +const getPointsInBezierCurve =

( + element: NonDeleted, + endPoint: P, +) => { + const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!; + if (!controlPoints) { + return []; + } + const pointsOnCurve: P[] = []; + let t = 1; + // Take 20 points on curve for better accuracy + while (t > 0) { + const p = getBezierXY( + controlPoints[0], + controlPoints[1], + controlPoints[2], + controlPoints[3], + t, + ); + pointsOnCurve.push(point(p[0], p[1])); + t -= 0.05; + } + if (pointsOnCurve.length) { + if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) { + pointsOnCurve.push(point(endPoint[0], endPoint[1])); + } + } + return pointsOnCurve; +}; + +const getBezierCurveArcLengths =

( + element: NonDeleted, + endPoint: P, +) => { + const arcLengths: number[] = []; + arcLengths[0] = 0; + const points = getPointsInBezierCurve(element, endPoint); + let index = 0; + let distance = 0; + while (index < points.length - 1) { + const segmentDistance = pointDistance(points[index], points[index + 1]); + distance += segmentDistance; + arcLengths.push(distance); + index++; + } + + return arcLengths; +}; + +export const getBezierCurveLength =

( + element: NonDeleted, + endPoint: P, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + return arcLengths.at(-1) as number; +}; + +// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length +export const mapIntervalToBezierT =

( + element: NonDeleted, + endPoint: P, + interval: number, // The interval between 0 to 1 for which you want to find the point on the curve, +) => { + const arcLengths = getBezierCurveArcLengths(element, endPoint); + const pointsCount = arcLengths.length - 1; + const curveLength = arcLengths.at(-1) as number; + const targetLength = interval * curveLength; + let low = 0; + let high = pointsCount; + let index = 0; + // Doing a binary search to find the largest length that is less than the target length + while (low < high) { + index = Math.floor(low + (high - low) / 2); + if (arcLengths[index] < targetLength) { + low = index + 1; + } else { + high = index; + } + } + if (arcLengths[index] > targetLength) { + index--; + } + if (arcLengths[index] === targetLength) { + return index / pointsCount; + } + + return ( + 1 - + (index + + (targetLength - arcLengths[index]) / + (arcLengths[index + 1] - arcLengths[index])) / + pointsCount + ); +}; + +/** + * Get the axis-aligned bounding box for a given element + */ +export const aabbForElement = ( + element: Readonly, + offset?: [number, number, number, number], +) => { + const bbox = { + minX: element.x, + minY: element.y, + maxX: element.x + element.width, + maxY: element.y + element.height, + midX: element.x + element.width / 2, + midY: element.y + element.height / 2, + }; + + const center = point(bbox.midX, bbox.midY); + const [topLeftX, topLeftY] = pointRotateRads( + point(bbox.minX, bbox.minY), + center, + element.angle, + ); + const [topRightX, topRightY] = pointRotateRads( + point(bbox.maxX, bbox.minY), + center, + element.angle, + ); + const [bottomRightX, bottomRightY] = pointRotateRads( + point(bbox.maxX, bbox.maxY), + center, + element.angle, + ); + const [bottomLeftX, bottomLeftY] = pointRotateRads( + point(bbox.minX, bbox.maxY), + center, + element.angle, + ); + + const bounds = [ + Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY), + Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX), + Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY), + ] as Bounds; + + if (offset) { + const [topOffset, rightOffset, downOffset, leftOffset] = offset; + return [ + bounds[0] - leftOffset, + bounds[1] - topOffset, + bounds[2] + rightOffset, + bounds[3] + downOffset, + ] as Bounds; + } + + return bounds; +}; + +export const pointInsideBounds =

( + p: P, + bounds: Bounds, +): boolean => + 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); + +export const getCornerRadius = (x: number, element: ExcalidrawElement) => { + if ( + element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || + element.roundness?.type === ROUNDNESS.LEGACY + ) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { + const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; + + const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; + + if (x <= CUTOFF_SIZE) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + return fixedRadiusSize; + } + + return 0; +}; + +// Checks if the first and last point are close enough +// to be considered a loop +export const isPathALoop = ( + points: ExcalidrawLinearElement["points"], + /** supply if you want the loop detection to account for current zoom */ + zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, +): boolean => { + if (points.length >= 3) { + const [first, last] = [points[0], points[points.length - 1]]; + const distance = pointDistance(first, last); + + // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in + // really close we make the threshold smaller, and vice versa. + return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; + } + return false; +}; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index ee19c648b..9da3d74c4 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -1,3 +1,12 @@ +import type { InclusiveRange } from "../math"; +import { + point, + pointRotateRads, + rangeInclusive, + rangeIntersection, + rangesOverlap, + type GlobalPoint, +} from "../math"; import { TOOL_TYPE } from "./constants"; import type { Bounds } from "./element/bounds"; import { @@ -14,7 +23,6 @@ import type { } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; -import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; import { getSelectedElements, getVisibleAndNonSelectedElements, @@ -23,7 +31,7 @@ import type { AppClassProperties, AppState, KeyboardModifiersObject, - Point, + NullableGridSize, } from "./types"; const SNAP_DISTANCE = 8; @@ -42,7 +50,7 @@ type Vector2D = { y: number; }; -type PointPair = [Point, Point]; +type PointPair = [GlobalPoint, GlobalPoint]; export type PointSnap = { type: "point"; @@ -62,9 +70,9 @@ export type Gap = { // ↑ end side startBounds: Bounds; endBounds: Bounds; - startSide: [Point, Point]; - endSide: [Point, Point]; - overlap: [number, number]; + startSide: [GlobalPoint, GlobalPoint]; + endSide: [GlobalPoint, GlobalPoint]; + overlap: InclusiveRange; length: number; }; @@ -88,7 +96,7 @@ export type Snaps = Snap[]; export type PointSnapLine = { type: "points"; - points: Point[]; + points: GlobalPoint[]; }; export type PointerSnapLine = { @@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine; // ----------------------------------------------------------------------------- export class SnapCache { - private static referenceSnapPoints: Point[] | null = null; + private static referenceSnapPoints: GlobalPoint[] | null = null; private static visibleGaps: { verticalGaps: Gap[]; horizontalGaps: Gap[]; } | null = null; - public static setReferenceSnapPoints = (snapPoints: Point[] | null) => { + public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => { SnapCache.referenceSnapPoints = snapPoints; }; @@ -191,8 +199,8 @@ export const getElementsCorners = ( omitCenter: false, boundingBoxCorners: false, }, -): Point[] => { - let result: Point[] = []; +): GlobalPoint[] => { + let result: GlobalPoint[] = []; if (elements.length === 1) { const element = elements[0]; @@ -219,33 +227,53 @@ export const getElementsCorners = ( (element.type === "diamond" || element.type === "ellipse") && !boundingBoxCorners ) { - const leftMid = rotatePoint( - [x1, y1 + halfHeight], - [cx, cy], + const leftMid = pointRotateRads( + point(x1, y1 + halfHeight), + point(cx, cy), element.angle, ); - const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle); - const rightMid = rotatePoint( - [x2, y1 + halfHeight], - [cx, cy], + const topMid = pointRotateRads( + point(x1 + halfWidth, y1), + point(cx, cy), element.angle, ); - const bottomMid = rotatePoint( - [x1 + halfWidth, y2], - [cx, cy], + const rightMid = pointRotateRads( + point(x2, y1 + halfHeight), + point(cx, cy), element.angle, ); - const center: Point = [cx, cy]; + const bottomMid = pointRotateRads( + point(x1 + halfWidth, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [leftMid, topMid, rightMid, bottomMid] : [leftMid, topMid, rightMid, bottomMid, center]; } else { - const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle); - const topRight = rotatePoint([x2, y1], [cx, cy], element.angle); - const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle); - const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle); - const center: Point = [cx, cy]; + const topLeft = pointRotateRads( + point(x1, y1), + point(cx, cy), + element.angle, + ); + const topRight = pointRotateRads( + point(x2, y1), + point(cx, cy), + element.angle, + ); + const bottomLeft = pointRotateRads( + point(x1, y2), + point(cx, cy), + element.angle, + ); + const bottomRight = pointRotateRads( + point(x2, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] @@ -259,18 +287,18 @@ export const getElementsCorners = ( const width = maxX - minX; const height = maxY - minY; - const topLeft: Point = [minX, minY]; - const topRight: Point = [maxX, minY]; - const bottomLeft: Point = [minX, maxY]; - const bottomRight: Point = [maxX, maxY]; - const center: Point = [minX + width / 2, minY + height / 2]; + const topLeft = point(minX, minY); + const topRight = point(maxX, minY); + const bottomLeft = point(minX, maxY); + const bottomRight = point(maxX, maxY); + const center = point(minX + width / 2, minY + height / 2); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] : [topLeft, topRight, bottomLeft, bottomRight, center]; } - return result.map((point) => [round(point[0]), round(point[1])] as Point); + return result.map((p) => point(round(p[0]), round(p[1]))); }; const getReferenceElements = ( @@ -339,23 +367,20 @@ export const getVisibleGaps = ( if ( startMaxX < endMinX && - rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY]) + rangesOverlap( + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), + ) ) { horizontalGaps.push({ startBounds, endBounds, - startSide: [ - [startMaxX, startMinY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMinX, endMaxY], - ], + startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)], length: endMinX - startMaxX, overlap: rangeIntersection( - [startMinY, startMaxY], - [endMinY, endMaxY], + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), )!, }); } @@ -382,23 +407,20 @@ export const getVisibleGaps = ( if ( startMaxY < endMinY && - rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX]) + rangesOverlap( + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), + ) ) { verticalGaps.push({ startBounds, endBounds, - startSide: [ - [startMinX, startMaxY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMaxX, endMinY], - ], + startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)], length: endMinY - startMaxY, overlap: rangeIntersection( - [startMinX, startMaxX], - [endMinX, endMaxX], + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), )!, }); } @@ -441,7 +463,7 @@ const getGapSnaps = ( const centerY = (minY + maxY) / 2; for (const gap of horizontalGaps) { - if (!rangesOverlap([minY, maxY], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) { continue; } @@ -510,7 +532,7 @@ const getGapSnaps = ( } } for (const gap of verticalGaps) { - if (!rangesOverlap([minX, maxX], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) { continue; } @@ -603,7 +625,7 @@ export const getReferenceSnapPoints = ( const getPointSnaps = ( selectedElements: ExcalidrawElement[], - selectionSnapPoints: Point[], + selectionSnapPoints: GlobalPoint[], app: AppClassProperties, event: KeyboardModifiersObject, nearestSnapsX: Snaps, @@ -779,8 +801,8 @@ const round = (x: number) => { return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces; }; -const dedupePoints = (points: Point[]): Point[] => { - const map = new Map(); +const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => { + const map = new Map(); for (const point of points) { const key = point.join(","); @@ -797,8 +819,8 @@ const createPointSnapLines = ( nearestSnapsX: Snaps, nearestSnapsY: Snaps, ): PointSnapLine[] => { - const snapsX = {} as { [key: string]: Point[] }; - const snapsY = {} as { [key: string]: Point[] }; + const snapsX = {} as { [key: string]: GlobalPoint[] }; + const snapsY = {} as { [key: string]: GlobalPoint[] }; if (nearestSnapsX.length > 0) { for (const snap of nearestSnapsX) { @@ -809,8 +831,8 @@ const createPointSnapLines = ( snapsX[key] = []; } snapsX[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -826,8 +848,8 @@ const createPointSnapLines = ( snapsY[key] = []; } snapsY[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -840,8 +862,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [Number(key), point[1]] as Point; + .map((p) => { + return point(Number(key), p[1]); }) .sort((a, b) => a[1] - b[1]), ), @@ -853,8 +875,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [point[0], Number(key)] as Point; + .map((p) => { + return point(p[0], Number(key)); }) .sort((a, b) => a[0] - b[0]), ), @@ -898,12 +920,12 @@ const createGapSnapLines = ( const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds; const verticalIntersection = rangeIntersection( - [minY, maxY], + rangeInclusive(minY, maxY), gapSnap.gap.overlap, ); const horizontalGapIntersection = rangeIntersection( - [minX, maxX], + rangeInclusive(minX, maxX), gapSnap.gap.overlap, ); @@ -918,16 +940,16 @@ const createGapSnapLines = ( type: "gap", direction: "horizontal", points: [ - [gapSnap.gap.startSide[0][0], gapLineY], - [minX, gapLineY], + point(gapSnap.gap.startSide[0][0], gapLineY), + point(minX, gapLineY), ], }, { type: "gap", direction: "horizontal", points: [ - [maxX, gapLineY], - [gapSnap.gap.endSide[0][0], gapLineY], + point(maxX, gapLineY), + point(gapSnap.gap.endSide[0][0], gapLineY), ], }, ); @@ -944,16 +966,16 @@ const createGapSnapLines = ( type: "gap", direction: "vertical", points: [ - [gapLineX, gapSnap.gap.startSide[0][1]], - [gapLineX, minY], + point(gapLineX, gapSnap.gap.startSide[0][1]), + point(gapLineX, minY), ], }, { type: "gap", direction: "vertical", points: [ - [gapLineX, maxY], - [gapLineX, gapSnap.gap.endSide[0][1]], + point(gapLineX, maxY), + point(gapLineX, gapSnap.gap.endSide[0][1]), ], }, ); @@ -969,18 +991,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [endMaxX, gapLineY], - [minX, gapLineY], - ], + points: [point(endMaxX, gapLineY), point(minX, gapLineY)], }, ); } @@ -995,18 +1011,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [maxX, gapLineY], - [startMinX, gapLineY], - ], + points: [point(maxX, gapLineY), point(startMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, ); } @@ -1021,18 +1031,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, maxY], - [gapLineX, startMinY], - ], + points: [point(gapLineX, maxY), point(gapLineX, startMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, ); } @@ -1047,18 +1051,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, endMaxY], - [gapLineX, minY], - ], + points: [point(gapLineX, endMaxY), point(gapLineX, minY)], }, ); } @@ -1071,8 +1069,8 @@ const createGapSnapLines = ( gapSnapLines.map((gapSnapLine) => { return { ...gapSnapLine, - points: gapSnapLine.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + points: gapSnapLine.points.map((p) => + point(round(p[0]), round(p[1])), ) as PointPair, }; }), @@ -1117,40 +1115,40 @@ export const snapResizingElements = ( } } - const selectionSnapPoints: Point[] = []; + const selectionSnapPoints: GlobalPoint[] = []; if (transformHandle) { switch (transformHandle) { case "e": { - selectionSnapPoints.push([maxX, minY], [maxX, maxY]); + selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY)); break; } case "w": { - selectionSnapPoints.push([minX, minY], [minX, maxY]); + selectionSnapPoints.push(point(minX, minY), point(minX, maxY)); break; } case "n": { - selectionSnapPoints.push([minX, minY], [maxX, minY]); + selectionSnapPoints.push(point(minX, minY), point(maxX, minY)); break; } case "s": { - selectionSnapPoints.push([minX, maxY], [maxX, maxY]); + selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY)); break; } case "ne": { - selectionSnapPoints.push([maxX, minY]); + selectionSnapPoints.push(point(maxX, minY)); break; } case "nw": { - selectionSnapPoints.push([minX, minY]); + selectionSnapPoints.push(point(minX, minY)); break; } case "se": { - selectionSnapPoints.push([maxX, maxY]); + selectionSnapPoints.push(point(maxX, maxY)); break; } case "sw": { - selectionSnapPoints.push([minX, maxY]); + selectionSnapPoints.push(point(minX, maxY)); break; } } @@ -1192,11 +1190,11 @@ export const snapResizingElements = ( round(bound), ); - const corners: Point[] = [ - [x1, y1], - [x1, y2], - [x2, y1], - [x2, y2], + const corners: GlobalPoint[] = [ + point(x1, y1), + point(x1, y2), + point(x2, y1), + point(x2, y2), ]; getPointSnaps( @@ -1232,8 +1230,8 @@ export const snapNewElement = ( }; } - const selectionSnapPoints: Point[] = [ - [origin.x + dragOffset.x, origin.y + dragOffset.y], + const selectionSnapPoints: GlobalPoint[] = [ + point(origin.x + dragOffset.x, origin.y + dragOffset.y), ]; const snapDistance = getSnapDistance(app.state.zoom.value); @@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = ( verticalSnapLines.push({ type: "pointer", - points: [corner, [corner[0], pointer.y]], + points: [corner, point(corner[0], pointer.y)], direction: "vertical", }); @@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = ( horizontalSnapLines.push({ type: "pointer", - points: [corner, [pointer.x, corner[1]]], + points: [corner, point(pointer.x, corner[1])], direction: "horizontal", }); @@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = ( activeToolType === TOOL_TYPE.text ); }; + +// TODO: Rounding this point causes some shake when free drawing +export const getGridPoint = ( + x: number, + y: number, + gridSize: NullableGridSize, +): [number, number] => { + if (gridSize) { + return [ + Math.round(x / gridSize) * gridSize, + Math.round(y / gridSize) * gridSize, + ]; + } + return [x, y]; +}; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f9904a4d..3a5e14065 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0_copy": true, }, @@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, @@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, @@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 2994cfc3e..e5e431dfc 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -239,6 +239,55 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende Ctrl+Shift+E

+