Browse Source

merge with master

Ryan Di 11 months ago
parent
commit
7b012b1cad
100 changed files with 2747 additions and 794 deletions
  1. 1 0
      .eslintignore
  2. 1 1
      dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx
  3. 0 0
      examples/excalidraw/components/ExampleApp.scss
  4. 2 2
      examples/excalidraw/components/ExampleApp.tsx
  5. 1 1
      examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx
  6. 1 1
      examples/excalidraw/with-script-in-browser/index.tsx
  7. 6 1
      excalidraw-app/App.tsx
  8. 1 0
      excalidraw-app/components/AppMainMenu.tsx
  9. 12 2
      excalidraw-app/components/DebugCanvas.tsx
  10. 14 1
      excalidraw-app/data/LocalData.ts
  11. 0 9
      excalidraw-app/index.html
  12. 2 0
      excalidraw-app/vite.config.mts
  13. 1 1
      package.json
  14. 2 0
      packages/excalidraw/CHANGELOG.md
  15. 57 50
      packages/excalidraw/actions/actionCanvas.tsx
  16. 3 2
      packages/excalidraw/actions/actionFinalize.tsx
  17. 211 0
      packages/excalidraw/actions/actionFlip.test.tsx
  18. 69 2
      packages/excalidraw/actions/actionFlip.ts
  19. 2 15
      packages/excalidraw/actions/actionProperties.tsx
  20. 55 0
      packages/excalidraw/actions/actionToggleSearchMenu.ts
  21. 2 0
      packages/excalidraw/actions/index.ts
  22. 3 1
      packages/excalidraw/actions/shortcuts.ts
  23. 4 2
      packages/excalidraw/actions/types.ts
  24. 2 0
      packages/excalidraw/appState.ts
  25. 5 5
      packages/excalidraw/charts.ts
  26. 157 70
      packages/excalidraw/components/App.tsx
  27. 14 1
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  28. 28 25
      packages/excalidraw/components/DefaultSidebar.tsx
  29. 4 0
      packages/excalidraw/components/HelpDialog.tsx
  30. 9 0
      packages/excalidraw/components/HintViewer.tsx
  31. 4 3
      packages/excalidraw/components/LayerUI.tsx
  32. 110 0
      packages/excalidraw/components/SearchMenu.scss
  33. 718 0
      packages/excalidraw/components/SearchMenu.tsx
  34. 3 3
      packages/excalidraw/components/Stats/MultiDimension.tsx
  35. 9 9
      packages/excalidraw/components/Stats/MultiPosition.tsx
  36. 5 5
      packages/excalidraw/components/Stats/Position.tsx
  37. 13 13
      packages/excalidraw/components/Stats/stats.test.tsx
  38. 5 5
      packages/excalidraw/components/Stats/utils.ts
  39. 21 7
      packages/excalidraw/components/TextField.scss
  40. 10 2
      packages/excalidraw/components/TextField.tsx
  41. 1 0
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  42. 2 2
      packages/excalidraw/components/hyperlink/Hyperlink.tsx
  43. 9 4
      packages/excalidraw/components/hyperlink/helpers.ts
  44. 8 0
      packages/excalidraw/components/icons.tsx
  45. 23 1
      packages/excalidraw/components/main-menu/DefaultItems.tsx
  46. 2 0
      packages/excalidraw/constants.ts
  47. 3 3
      packages/excalidraw/css/theme.scss
  48. 27 27
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  49. 9 0
      packages/excalidraw/data/encode.ts
  50. 12 8
      packages/excalidraw/data/restore.ts
  51. 50 45
      packages/excalidraw/data/transform.test.ts
  52. 25 8
      packages/excalidraw/data/transform.ts
  53. 45 35
      packages/excalidraw/element/binding.ts
  54. 4 4
      packages/excalidraw/element/bounds.test.ts
  55. 46 46
      packages/excalidraw/element/bounds.ts
  56. 11 7
      packages/excalidraw/element/collision.ts
  57. 8 8
      packages/excalidraw/element/cropElement.ts
  58. 1 8
      packages/excalidraw/element/dragElements.ts
  59. 31 0
      packages/excalidraw/element/embeddable.ts
  60. 2 2
      packages/excalidraw/element/flowchart.ts
  61. 9 9
      packages/excalidraw/element/heading.ts
  62. 60 50
      packages/excalidraw/element/linearElementEditor.ts
  63. 2 2
      packages/excalidraw/element/newElement.test.ts
  64. 0 1
      packages/excalidraw/element/newElement.ts
  65. 58 56
      packages/excalidraw/element/resizeElements.ts
  66. 17 13
      packages/excalidraw/element/resizeTest.ts
  67. 18 17
      packages/excalidraw/element/routing.test.tsx
  68. 67 37
      packages/excalidraw/element/routing.ts
  69. 2 7
      packages/excalidraw/element/sizeHelpers.ts
  70. 4 3
      packages/excalidraw/element/textElement.ts
  71. 2 2
      packages/excalidraw/element/textWysiwyg.test.tsx
  72. 1 1
      packages/excalidraw/element/textWysiwyg.tsx
  73. 3 3
      packages/excalidraw/element/transformHandles.ts
  74. 5 2
      packages/excalidraw/element/typeChecks.ts
  75. 12 7
      packages/excalidraw/element/types.ts
  76. 6 5
      packages/excalidraw/fonts/ExcalidrawFont.ts
  77. BIN
      packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2
  78. BIN
      packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2
  79. BIN
      packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2
  80. BIN
      packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2
  81. BIN
      packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2
  82. BIN
      packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2
  83. BIN
      packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2
  84. 8 8
      packages/excalidraw/fonts/index.ts
  85. 4 4
      packages/excalidraw/frame.ts
  86. 8 0
      packages/excalidraw/locales/en.json
  87. 46 5
      packages/excalidraw/renderer/interactiveScene.ts
  88. 23 15
      packages/excalidraw/renderer/renderSnaps.ts
  89. 1 0
      packages/excalidraw/renderer/staticSvgScene.ts
  90. 2 2
      packages/excalidraw/scene/Shape.ts
  91. 10 0
      packages/excalidraw/scene/export.ts
  92. 17 3
      packages/excalidraw/scene/scroll.ts
  93. 26 26
      packages/excalidraw/shapes.tsx
  94. 89 65
      packages/excalidraw/snapping.ts
  95. 51 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  96. 49 0
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  97. 0 0
      packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
  98. 126 0
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  99. 126 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  100. 9 4
      packages/excalidraw/tests/binding.test.tsx

+ 1 - 0
.eslintignore

@@ -8,3 +8,4 @@ public/workbox
 packages/excalidraw/types
 examples/**/public
 dev-dist
+coverage

+ 1 - 1
dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx

@@ -20,7 +20,7 @@ exportToCanvas(&#123;<br/>&nbsp;
   getDimensions,<br/>&nbsp;
   files,<br/>&nbsp;
   exportPadding?: number;<br/>
-&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/packages/utils.ts#L21">ExportOpts</a>
+&#125;: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/utils/export.ts#L24">ExportOpts</a>
 </pre>
 
 | Name | Type | Default | Description |

+ 0 - 0
examples/excalidraw/components/App.scss → examples/excalidraw/components/ExampleApp.scss


+ 2 - 2
examples/excalidraw/components/App.tsx → examples/excalidraw/components/ExampleApp.tsx

@@ -40,7 +40,7 @@ import type {
 } from "@excalidraw/excalidraw/dist/excalidraw/element/types";
 import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types";
 
-import "./App.scss";
+import "./ExampleApp.scss";
 
 type Comment = {
   x: number;
@@ -73,7 +73,7 @@ export interface AppProps {
   excalidrawLib: typeof TExcalidraw;
 }
 
-export default function App({
+export default function ExampleApp({
   appTitle,
   useCustom,
   customArgs,

+ 1 - 1
examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx

@@ -1,7 +1,7 @@
 "use client";
 import * as excalidrawLib from "@excalidraw/excalidraw";
 import { Excalidraw } from "@excalidraw/excalidraw";
-import App from "../../components/App";
+import App from "../../components/ExampleApp";
 
 import "@excalidraw/excalidraw/index.css";
 

+ 1 - 1
examples/excalidraw/with-script-in-browser/index.tsx

@@ -1,4 +1,4 @@
-import App from "../components/App";
+import App from "../components/ExampleApp";
 import React, { StrictMode } from "react";
 import { createRoot } from "react-dom/client";
 

+ 6 - 1
excalidraw-app/App.tsx

@@ -649,7 +649,12 @@ const ExcalidrawWrapper = () => {
 
     // Render the debug scene if the debug canvas is available
     if (debugCanvasRef.current && excalidrawAPI) {
-      debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
+      debugRenderer(
+        debugCanvasRef.current,
+        appState,
+        window.devicePixelRatio,
+        () => forceRefresh((prev) => !prev),
+      );
     }
   };
 

+ 1 - 0
excalidraw-app/components/AppMainMenu.tsx

@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
         />
       )}
       <MainMenu.DefaultItems.CommandPalette className="highlighted" />
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />

+ 12 - 2
excalidraw-app/components/DebugCanvas.tsx

@@ -68,12 +68,17 @@ const _debugRenderer = (
   canvas: HTMLCanvasElement,
   appState: AppState,
   scale: number,
+  refresh: () => void,
 ) => {
   const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
     canvas,
     scale,
   );
 
+  if (appState.height !== canvas.height || appState.width !== canvas.width) {
+    refresh();
+  }
+
   const context = bootstrapCanvas({
     canvas,
     scale,
@@ -138,8 +143,13 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
 };
 
 export const debugRenderer = throttleRAF(
-  (canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
-    _debugRenderer(canvas, appState, scale);
+  (
+    canvas: HTMLCanvasElement,
+    appState: AppState,
+    scale: number,
+    refresh: () => void,
+  ) => {
+    _debugRenderer(canvas, appState, scale, refresh);
   },
   { trailing: true },
 );

+ 14 - 1
excalidraw-app/data/LocalData.ts

@@ -20,6 +20,10 @@ import {
   get,
 } from "idb-keyval";
 import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
+import {
+  CANVAS_SEARCH_TAB,
+  DEFAULT_SIDEBAR,
+} from "../../packages/excalidraw/constants";
 import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
 import type { ImportedDataState } from "../../packages/excalidraw/data/types";
 import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
@@ -66,13 +70,22 @@ const saveDataStateToLocalStorage = (
   appState: AppState,
 ) => {
   try {
+    const _appState = clearAppStateForLocalStorage(appState);
+
+    if (
+      _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+      _appState.openSidebar.tab === CANVAS_SEARCH_TAB
+    ) {
+      _appState.openSidebar = null;
+    }
+
     localStorage.setItem(
       STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
       JSON.stringify(clearElementsForLocalStorage(elements)),
     );
     localStorage.setItem(
       STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
-      JSON.stringify(clearAppStateForLocalStorage(appState)),
+      JSON.stringify(_appState),
     );
     updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
   } catch (error: any) {

+ 0 - 9
excalidraw-app/index.html

@@ -130,15 +130,6 @@
     </script>
     <% } %>
 
-    <!-- For Nunito only preload the latin range, which should be good enough for now -->
-    <link
-      rel="preload"
-      href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
-      as="font"
-      type="font/woff2"
-      crossorigin="anonymous"
-    />
-
     <!-- Register Assistant as the UI font, before the scene inits -->
     <link
       rel="stylesheet"

+ 2 - 0
excalidraw-app/vite.config.mts

@@ -48,6 +48,8 @@ export default defineConfig({
       },
     },
     sourcemap: true,
+    // don't auto-inline small assets (i.e. fonts hosted on CDN)
+    assetsInlineLimit: 0,
   },
   plugins: [
     woff2BrowserPlugin(),

+ 1 - 1
package.json

@@ -36,7 +36,7 @@
     "prettier": "2.6.2",
     "rewire": "6.0.0",
     "typescript": "4.9.4",
-    "vite": "5.4.2",
+    "vite": "5.0.12",
     "vite-plugin-checker": "0.7.2",
     "vite-plugin-ejs": "1.7.0",
     "vite-plugin-pwa": "0.17.4",

+ 2 - 0
packages/excalidraw/CHANGELOG.md

@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Prefer user defined coordinates and dimensions when creating a frame using [`convertToExcalidrawElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/excalidraw-element-skeleton#converttoexcalidrawelements) [#8517](https://github.com/excalidraw/excalidraw/pull/8517)
+
 - `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
 
 - Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)

+ 57 - 50
packages/excalidraw/actions/actionCanvas.tsx

@@ -24,7 +24,7 @@ import { CODES, KEYS } from "../keys";
 import { getNormalizedZoom } from "../scene";
 import { centerScrollOn } from "../scene/scroll";
 import { getStateForZoom } from "../scene/zoom";
-import type { AppState } from "../types";
+import type { AppState, Offsets } from "../types";
 import { getShortcutKey, updateActiveTool } from "../utils";
 import { register } from "./register";
 import { Tooltip } from "../components/Tooltip";
@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 import type { SceneBounds } from "../element/bounds";
 import { setCursor } from "../cursor";
 import { StoreAction } from "../store";
-import { clamp } from "../../math";
+import { clamp, roundToStep } from "../../math";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",
@@ -259,89 +259,85 @@ const zoomValueToFitBoundsOnViewport = (
   const adjustedZoomValue =
     smallestZoomValue * clamp(viewportZoomFactor, 0.1, 1);
 
-  const zoomAdjustedToSteps =
-    Math.floor(adjustedZoomValue / ZOOM_STEP) * ZOOM_STEP;
-
-  return getNormalizedZoom(Math.min(zoomAdjustedToSteps, 1));
+  return Math.min(adjustedZoomValue, 1);
 };
 
 export const zoomToFitBounds = ({
   bounds,
   appState,
+  canvasOffsets,
   fitToViewport = false,
   viewportZoomFactor = 1,
+  minZoom = -Infinity,
+  maxZoom = Infinity,
 }: {
   bounds: SceneBounds;
+  canvasOffsets?: Offsets;
   appState: Readonly<AppState>;
   /** whether to fit content to viewport (beyond >100%) */
   fitToViewport: boolean;
   /** zoom content to cover X of the viewport, when fitToViewport=true */
   viewportZoomFactor?: number;
+  minZoom?: number;
+  maxZoom?: number;
 }) => {
+  viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM);
+
   const [x1, y1, x2, y2] = bounds;
   const centerX = (x1 + x2) / 2;
   const centerY = (y1 + y2) / 2;
 
-  let newZoomValue;
-  let scrollX;
-  let scrollY;
+  const canvasOffsetLeft = canvasOffsets?.left ?? 0;
+  const canvasOffsetTop = canvasOffsets?.top ?? 0;
+  const canvasOffsetRight = canvasOffsets?.right ?? 0;
+  const canvasOffsetBottom = canvasOffsets?.bottom ?? 0;
+
+  const effectiveCanvasWidth =
+    appState.width - canvasOffsetLeft - canvasOffsetRight;
+  const effectiveCanvasHeight =
+    appState.height - canvasOffsetTop - canvasOffsetBottom;
+
+  let adjustedZoomValue;
 
   if (fitToViewport) {
     const commonBoundsWidth = x2 - x1;
     const commonBoundsHeight = y2 - y1;
 
-    newZoomValue =
+    adjustedZoomValue =
       Math.min(
-        appState.width / commonBoundsWidth,
-        appState.height / commonBoundsHeight,
-      ) * clamp(viewportZoomFactor, 0.1, 1);
-
-    newZoomValue = getNormalizedZoom(newZoomValue);
-
-    let appStateWidth = appState.width;
-
-    if (appState.openSidebar) {
-      const sidebarDOMElem = document.querySelector(
-        ".sidebar",
-      ) as HTMLElement | null;
-      const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0;
-      const isRTL = document.documentElement.getAttribute("dir") === "rtl";
-
-      appStateWidth = !isRTL
-        ? appState.width - sidebarWidth
-        : appState.width + sidebarWidth;
-    }
-
-    scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
-    scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
+        effectiveCanvasWidth / commonBoundsWidth,
+        effectiveCanvasHeight / commonBoundsHeight,
+      ) * viewportZoomFactor;
   } else {
-    newZoomValue = zoomValueToFitBoundsOnViewport(
+    adjustedZoomValue = zoomValueToFitBoundsOnViewport(
       bounds,
       {
-        width: appState.width,
-        height: appState.height,
+        width: effectiveCanvasWidth,
+        height: effectiveCanvasHeight,
       },
       viewportZoomFactor,
     );
+  }
 
-    const centerScroll = centerScrollOn({
-      scenePoint: { x: centerX, y: centerY },
-      viewportDimensions: {
-        width: appState.width,
-        height: appState.height,
-      },
-      zoom: { value: newZoomValue },
-    });
+  const newZoomValue = getNormalizedZoom(
+    clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom),
+  );
 
-    scrollX = centerScroll.scrollX;
-    scrollY = centerScroll.scrollY;
-  }
+  const centerScroll = centerScrollOn({
+    scenePoint: { x: centerX, y: centerY },
+    viewportDimensions: {
+      width: appState.width,
+      height: appState.height,
+    },
+    offsets: canvasOffsets,
+    zoom: { value: newZoomValue },
+  });
 
   return {
     appState: {
       ...appState,
-      scrollX,
-      scrollY,
+      scrollX: centerScroll.scrollX,
+      scrollY: centerScroll.scrollY,
       zoom: { value: newZoomValue },
     },
     storeAction: StoreAction.NONE,
@@ -349,25 +345,34 @@ export const zoomToFitBounds = ({
 };
 
 export const zoomToFit = ({
+  canvasOffsets,
   targetElements,
   appState,
   fitToViewport,
   viewportZoomFactor,
+  minZoom,
+  maxZoom,
 }: {
+  canvasOffsets?: Offsets;
   targetElements: readonly ExcalidrawElement[];
   appState: Readonly<AppState>;
   /** whether to fit content to viewport (beyond >100%) */
   fitToViewport: boolean;
   /** zoom content to cover X of the viewport, when fitToViewport=true */
   viewportZoomFactor?: number;
+  minZoom?: number;
+  maxZoom?: number;
 }) => {
   const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
 
   return zoomToFitBounds({
+    canvasOffsets,
     bounds: commonBounds,
     appState,
     fitToViewport,
     viewportZoomFactor,
+    minZoom,
+    maxZoom,
   });
 };
 
@@ -388,6 +393,7 @@ export const actionZoomToFitSelectionInViewport = register({
         userToFollow: null,
       },
       fitToViewport: false,
+      canvasOffsets: app.getEditorUIOffsets(),
     });
   },
   // NOTE shift-2 should have been assigned actionZoomToFitSelection.
@@ -413,7 +419,7 @@ export const actionZoomToFitSelection = register({
         userToFollow: null,
       },
       fitToViewport: true,
-      viewportZoomFactor: 0.7,
+      canvasOffsets: app.getEditorUIOffsets(),
     });
   },
   // NOTE this action should use shift-2 per figma, alas
@@ -430,7 +436,7 @@ export const actionZoomToFit = register({
   icon: zoomAreaIcon,
   viewMode: true,
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) =>
+  perform: (elements, appState, _, app) =>
     zoomToFit({
       targetElements: elements,
       appState: {
@@ -438,6 +444,7 @@ export const actionZoomToFit = register({
         userToFollow: null,
       },
       fitToViewport: false,
+      canvasOffsets: app.getEditorUIOffsets(),
     }),
   keyTest: (event) =>
     event.code === CODES.ONE &&

+ 3 - 2
packages/excalidraw/actions/actionFinalize.tsx

@@ -15,7 +15,7 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
 import type { AppState } from "../types";
 import { resetCursor } from "../cursor";
 import { StoreAction } from "../store";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 import { isPathALoop } from "../shapes";
 
 export const actionFinalize = register({
@@ -115,7 +115,7 @@ export const actionFinalize = register({
           mutateElement(multiPointElement, {
             points: linePoints.map((p, index) =>
               index === linePoints.length - 1
-                ? point(firstPoint[0], firstPoint[1])
+                ? pointFrom(firstPoint[0], firstPoint[1])
                 : p,
             ),
           });
@@ -217,6 +217,7 @@ export const actionFinalize = register({
       onClick={updateData}
       visible={appState.multiElement != null}
       size={data?.size || "medium"}
+      style={{ pointerEvents: "all" }}
     />
   ),
 });

+ 211 - 0
packages/excalidraw/actions/actionFlip.test.tsx

@@ -0,0 +1,211 @@
+import React from "react";
+import { Excalidraw } from "../index";
+import { render } from "../tests/test-utils";
+import { API } from "../tests/helpers/api";
+import { pointFrom } from "../../math";
+import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
+
+const { h } = window;
+
+describe("flipping re-centers selection", () => {
+  it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
+    const elements = [
+      API.createElement({
+        type: "rectangle",
+        id: "rec1",
+        x: 100,
+        y: 100,
+        width: 100,
+        height: 100,
+        boundElements: [{ id: "arr", type: "arrow" }],
+      }),
+      API.createElement({
+        type: "rectangle",
+        id: "rec2",
+        x: 220,
+        y: 250,
+        width: 100,
+        height: 100,
+        boundElements: [{ id: "arr", type: "arrow" }],
+      }),
+      API.createElement({
+        type: "arrow",
+        id: "arr",
+        x: 149.9,
+        y: 95,
+        width: 156,
+        height: 239.9,
+        startBinding: {
+          elementId: "rec1",
+          focus: 0,
+          gap: 5,
+          fixedPoint: [0.49, -0.05],
+        },
+        endBinding: {
+          elementId: "rec2",
+          focus: 0,
+          gap: 5,
+          fixedPoint: [-0.05, 0.49],
+        },
+        startArrowhead: null,
+        endArrowhead: "arrow",
+        points: [
+          pointFrom(0, 0),
+          pointFrom(0, -35),
+          pointFrom(-90.9, -35),
+          pointFrom(-90.9, 204.9),
+          pointFrom(65.1, 204.9),
+        ],
+        elbowed: true,
+      }),
+    ];
+    await render(<Excalidraw initialData={{ elements }} />);
+
+    API.setSelectedElements(elements);
+
+    expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
+
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+    API.executeAction(actionFlipHorizontal);
+
+    const rec1 = h.elements.find((el) => el.id === "rec1");
+    expect(rec1?.x).toBeCloseTo(100);
+    expect(rec1?.y).toBeCloseTo(100);
+
+    const rec2 = h.elements.find((el) => el.id === "rec2");
+    expect(rec2?.x).toBeCloseTo(220);
+    expect(rec2?.y).toBeCloseTo(250);
+  });
+});
+
+describe("flipping arrowheads", () => {
+  beforeEach(async () => {
+    await render(<Excalidraw />);
+  });
+
+  it("flipping bound arrow should flip arrowheads only", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: null,
+      endBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe(null);
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipVertical);
+    expect(API.getElement(arrow).startArrowhead).toBe(null);
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+  });
+
+  it("flipping bound arrow should flip arrowheads only 2", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const rect2 = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: "circle",
+      startBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+      endBinding: {
+        elementId: rect2.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, rect2, arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("circle");
+    expect(API.getElement(arrow).endArrowhead).toBe("arrow");
+
+    API.executeAction(actionFlipVertical);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+  });
+
+  it("flipping unbound arrow shouldn't flip arrowheads", () => {
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: "circle",
+    });
+
+    API.setElements([arrow]);
+    API.setSelectedElements([arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe("circle");
+  });
+
+  it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
+    const rect = API.createElement({
+      type: "rectangle",
+      boundElements: [{ type: "arrow", id: "arrow1" }],
+    });
+    const arrow = API.createElement({
+      type: "arrow",
+      id: "arrow1",
+      startArrowhead: "arrow",
+      endArrowhead: null,
+      endBinding: {
+        elementId: rect.id,
+        focus: 0.5,
+        gap: 5,
+      },
+    });
+
+    API.setElements([rect, arrow]);
+    API.setSelectedElements([rect, arrow]);
+
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+
+    API.executeAction(actionFlipHorizontal);
+    expect(API.getElement(arrow).startArrowhead).toBe("arrow");
+    expect(API.getElement(arrow).endArrowhead).toBe(null);
+  });
+});

+ 69 - 2
packages/excalidraw/actions/actionFlip.ts

@@ -2,6 +2,8 @@ import { register } from "./register";
 import { getSelectedElements } from "../scene";
 import { getNonDeletedElements } from "../element";
 import type {
+  ExcalidrawArrowElement,
+  ExcalidrawElbowArrowElement,
   ExcalidrawElement,
   NonDeleted,
   NonDeletedSceneElementsMap,
@@ -18,7 +20,13 @@ import {
 import { updateFrameMembershipOfSelectedElements } from "../frame";
 import { flipHorizontal, flipVertical } from "../components/icons";
 import { StoreAction } from "../store";
-import { isLinearElement } from "../element/typeChecks";
+import {
+  isArrowElement,
+  isElbowArrow,
+  isLinearElement,
+} from "../element/typeChecks";
+import { mutateElbowArrow } from "../element/routing";
+import { mutateElement, newElementWith } from "../element/mutateElement";
 
 export const actionFlipHorizontal = register({
   name: "flipHorizontal",
@@ -109,7 +117,23 @@ const flipElements = (
   flipDirection: "horizontal" | "vertical",
   app: AppClassProperties,
 ): ExcalidrawElement[] => {
-  const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
+  if (
+    selectedElements.every(
+      (element) =>
+        isArrowElement(element) && (element.startBinding || element.endBinding),
+    )
+  ) {
+    return selectedElements.map((element) => {
+      const _element = element as ExcalidrawArrowElement;
+      return newElementWith(_element, {
+        startArrowhead: _element.endArrowhead,
+        endArrowhead: _element.startArrowhead,
+      });
+    });
+  }
+
+  const { minX, minY, maxX, maxY, midX, midY } =
+    getCommonBoundingBox(selectedElements);
 
   resizeMultipleElements(
     elementsMap,
@@ -131,5 +155,48 @@ const flipElements = (
     [],
   );
 
+  // ---------------------------------------------------------------------------
+  // flipping arrow elements (and potentially other) makes the selection group
+  // "move" across the canvas because of how arrows can bump against the "wall"
+  // of the selection, so we need to center the group back to the original
+  // position so that repeated flips don't accumulate the offset
+
+  const { elbowArrows, otherElements } = selectedElements.reduce(
+    (
+      acc: {
+        elbowArrows: ExcalidrawElbowArrowElement[];
+        otherElements: ExcalidrawElement[];
+      },
+      element,
+    ) =>
+      isElbowArrow(element)
+        ? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
+        : { ...acc, otherElements: acc.otherElements.concat(element) },
+    { elbowArrows: [], otherElements: [] },
+  );
+
+  const { midX: newMidX, midY: newMidY } =
+    getCommonBoundingBox(selectedElements);
+  const [diffX, diffY] = [midX - newMidX, midY - newMidY];
+  otherElements.forEach((element) =>
+    mutateElement(element, {
+      x: element.x + diffX,
+      y: element.y + diffY,
+    }),
+  );
+  elbowArrows.forEach((element) =>
+    mutateElbowArrow(
+      element,
+      elementsMap,
+      element.points,
+      undefined,
+      undefined,
+      {
+        informMutation: false,
+      },
+    ),
+  );
+  // ---------------------------------------------------------------------------
+
   return selectedElements;
 };

+ 2 - 15
packages/excalidraw/actions/actionProperties.tsx

@@ -116,7 +116,7 @@ import {
 import { mutateElbowArrow } from "../element/routing";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import type { LocalPoint } from "../../math";
-import { point, vector } from "../../math";
+import { pointFrom, vector } from "../../math";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -1651,7 +1651,7 @@ export const actionChangeArrowType = register({
             elementsMap,
             [finalStartPoint, finalEndPoint].map(
               (p): LocalPoint =>
-                point(p[0] - newElement.x, p[1] - newElement.y),
+                pointFrom(p[0] - newElement.x, p[1] - newElement.y),
             ),
             vector(0, 0),
             {
@@ -1685,19 +1685,6 @@ export const actionChangeArrowType = register({
                 : {}),
             },
           );
-        } else {
-          mutateElement(
-            newElement,
-            {
-              startBinding: newElement.startBinding
-                ? { ...newElement.startBinding, fixedPoint: null }
-                : null,
-              endBinding: newElement.endBinding
-                ? { ...newElement.endBinding, fixedPoint: null }
-                : null,
-            },
-            false,
-          );
         }
 
         return newElement;

+ 55 - 0
packages/excalidraw/actions/actionToggleSearchMenu.ts

@@ -0,0 +1,55 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { AppState } from "../types";
+import { searchIcon } from "../components/icons";
+import { StoreAction } from "../store";
+import { CANVAS_SEARCH_TAB, CLASSES, DEFAULT_SIDEBAR } from "../constants";
+
+export const actionToggleSearchMenu = register({
+  name: "searchMenu",
+  icon: searchIcon,
+  keywords: ["search", "find"],
+  label: "search.title",
+  viewMode: true,
+  trackEvent: {
+    category: "search_menu",
+    action: "toggle",
+    predicate: (appState) => appState.gridModeEnabled,
+  },
+  perform(elements, appState, _, app) {
+    if (
+      appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+      appState.openSidebar.tab === CANVAS_SEARCH_TAB
+    ) {
+      const searchInput =
+        app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
+          `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
+        );
+
+      if (searchInput?.matches(":focus")) {
+        return {
+          appState: { ...appState, openSidebar: null },
+          storeAction: StoreAction.NONE,
+        };
+      }
+
+      searchInput?.focus();
+      searchInput?.select();
+      return false;
+    }
+
+    return {
+      appState: {
+        ...appState,
+        openSidebar: { name: DEFAULT_SIDEBAR.name, tab: CANVAS_SEARCH_TAB },
+        openDialog: null,
+      },
+      storeAction: StoreAction.NONE,
+    };
+  },
+  checked: (appState: AppState) => appState.gridModeEnabled,
+  predicate: (element, appState, props) => {
+    return props.gridModeEnabled === undefined;
+  },
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
+});

+ 2 - 0
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";

+ 3 - 1
packages/excalidraw/actions/shortcuts.ts

@@ -51,7 +51,8 @@ export type ShortcutName =
     >
   | "saveScene"
   | "imageExport"
-  | "commandPalette";
+  | "commandPalette"
+  | "searchMenu";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
   saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
   toggleShortcuts: [getShortcutKey("?")],
+  searchMenu: [getShortcutKey("CtrlOrCmd+F")],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

+ 4 - 2
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<AppState>,

+ 2 - 0
packages/excalidraw/appState.ts

@@ -118,6 +118,7 @@ export const getDefaultAppState = (): Omit<
     followedBy: new Set(),
     isCropping: false,
     croppingElement: null,
+    searchMatches: [],
   };
 };
 
@@ -240,6 +241,7 @@ const APP_STATE_STORAGE_CONF = (<
   followedBy: { browser: false, export: false, server: false },
   isCropping: { browser: false, export: false, server: false },
   croppingElement: { browser: false, export: false, server: false },
+  searchMatches: { browser: false, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 5 - 5
packages/excalidraw/charts.ts

@@ -1,5 +1,5 @@
 import type { Radians } from "../math";
-import { point } from "../math";
+import { pointFrom } from "../math";
 import {
   COLOR_PALETTE,
   DEFAULT_CHART_COLOR_INDEX,
@@ -260,7 +260,7 @@ const chartLines = (
     x,
     y,
     width: chartWidth,
-    points: [point(0, 0), point(chartWidth, 0)],
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
   });
 
   const yLine = newLinearElement({
@@ -271,7 +271,7 @@ const chartLines = (
     x,
     y,
     height: chartHeight,
-    points: [point(0, 0), point(0, -chartHeight)],
+    points: [pointFrom(0, 0), pointFrom(0, -chartHeight)],
   });
 
   const maxLine = newLinearElement({
@@ -284,7 +284,7 @@ const chartLines = (
     strokeStyle: "dotted",
     width: chartWidth,
     opacity: GRID_OPACITY,
-    points: [point(0, 0), point(chartWidth, 0)],
+    points: [pointFrom(0, 0), pointFrom(chartWidth, 0)],
   });
 
   return [xLine, yLine, maxLine];
@@ -441,7 +441,7 @@ const chartTypeLine = (
       height: cy,
       strokeStyle: "dotted",
       opacity: GRID_OPACITY,
-      points: [point(0, 0), point(0, cy)],
+      points: [pointFrom(0, 0), pointFrom(0, cy)],
     });
   });
 

+ 157 - 70
packages/excalidraw/components/App.tsx

@@ -185,6 +185,7 @@ import type {
   MagicGenerationData,
   ExcalidrawNonSelectionElement,
   ExcalidrawArrowElement,
+  NonDeletedSceneElementsMap,
 } from "../element/types";
 import { getCenter, getDistance } from "../gesture";
 import {
@@ -259,6 +260,7 @@ import type {
   ElementsPendingErasure,
   GenerateDiagramToCode,
   NullableGridSize,
+  Offsets,
 } from "../types";
 import {
   debounce,
@@ -286,6 +288,7 @@ import {
   getDateTime,
   isShallowEqual,
   arrayToMap,
+  toBrandedType,
 } from "../utils";
 import {
   createSrcDoc,
@@ -434,14 +437,15 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
 import { getVisibleSceneBounds } from "../element/bounds";
 import { isMaybeMermaidDefinition } from "../mermaid";
 import NewElementCanvas from "./canvases/NewElementCanvas";
-import { mutateElbowArrow } from "../element/routing";
+import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
 import {
   FlowChartCreator,
   FlowChartNavigator,
   getLinkDirectionFromKey,
 } from "../element/flowchart";
+import { searchItemInFocusAtom } from "./SearchMenu";
 import type { LocalPoint, Radians } from "../../math";
-import { clamp, point, pointDistance, vector } from "../../math";
+import { clamp, pointFrom, pointDistance, vector } from "../../math";
 import { cropElement } from "../element/cropElement";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
@@ -549,6 +553,7 @@ class App extends React.Component<AppProps, AppState> {
   public scene: Scene;
   public fonts: Fonts;
   public renderer: Renderer;
+  public visibleElements: readonly NonDeletedExcalidrawElement[];
   private resizeObserver: ResizeObserver | undefined;
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   public library: AppClassProperties["library"];
@@ -556,7 +561,7 @@ class App extends React.Component<AppProps, AppState> {
   public id: string;
   private store: Store;
   private history: History;
-  private excalidrawContainerValue: {
+  public excalidrawContainerValue: {
     container: HTMLDivElement | null;
     id: string;
   };
@@ -684,6 +689,7 @@ class App extends React.Component<AppProps, AppState> {
     this.canvas = document.createElement("canvas");
     this.rc = rough.canvas(this.canvas);
     this.renderer = new Renderer(this.scene);
+    this.visibleElements = [];
 
     this.store = new Store();
     this.history = new History();
@@ -1482,6 +1488,7 @@ class App extends React.Component<AppProps, AppState> {
         newElementId: this.state.newElement?.id,
         pendingImageElementId: this.state.pendingImageElementId,
       });
+    this.visibleElements = visibleElements;
 
     const allElementsMap = this.scene.getNonDeletedElementsMap();
 
@@ -2297,6 +2304,9 @@ class App extends React.Component<AppProps, AppState> {
       storeAction: StoreAction.UPDATE,
     });
 
+    // clear the shape and image cache so that any images in initialData
+    // can be loaded fresh
+    this.clearImageShapeCache();
     // FontFaceSet loadingdone event we listen on may not always
     // fire (looking at you Safari), so on init we manually load all
     // fonts and rerender scene text elements once done. This also
@@ -2362,6 +2372,16 @@ class App extends React.Component<AppProps, AppState> {
     return false;
   };
 
+  private clearImageShapeCache(filesMap?: BinaryFiles) {
+    const files = filesMap ?? this.files;
+    this.scene.getNonDeletedElements().forEach((element) => {
+      if (isInitializedImageElement(element) && files[element.fileId]) {
+        this.imageCache.delete(element.fileId);
+        ShapeCache.delete(element);
+      }
+    });
+  }
+
   public async componentDidMount() {
     this.unmounted = false;
     this.excalidrawContainerValue.container =
@@ -3093,7 +3113,45 @@ class App extends React.Component<AppProps, AppState> {
     retainSeed?: boolean;
     fitToContent?: boolean;
   }) => {
-    const elements = restoreElements(opts.elements, null, undefined);
+    let elements = opts.elements.map((el, _, elements) => {
+      if (isElbowArrow(el)) {
+        const startEndElements = [
+          el.startBinding &&
+            elements.find((l) => l.id === el.startBinding?.elementId),
+          el.endBinding &&
+            elements.find((l) => l.id === el.endBinding?.elementId),
+        ];
+        const startBinding = startEndElements[0] ? el.startBinding : null;
+        const endBinding = startEndElements[1] ? el.endBinding : null;
+        return {
+          ...el,
+          ...updateElbowArrow(
+            {
+              ...el,
+              startBinding,
+              endBinding,
+            },
+            toBrandedType<NonDeletedSceneElementsMap>(
+              new Map(
+                startEndElements
+                  .filter((x) => x != null)
+                  .map(
+                    (el) =>
+                      [el!.id, el] as [
+                        string,
+                        Ordered<NonDeletedExcalidrawElement>,
+                      ],
+                  ),
+              ),
+            ),
+            [el.points[0], el.points[el.points.length - 1]],
+          ),
+        };
+      }
+
+      return el;
+    });
+    elements = restoreElements(elements, null, undefined);
     const [minX, minY, maxX, maxY] = getCommonBounds(elements);
 
     const elementsCenterX = distance(minX, maxX) / 2;
@@ -3217,6 +3275,7 @@ class App extends React.Component<AppProps, AppState> {
     if (opts.fitToContent) {
       this.scrollToContent(newElements, {
         fitToContent: true,
+        canvasOffsets: this.getEditorUIOffsets(),
       });
     }
   };
@@ -3529,7 +3588,7 @@ class App extends React.Component<AppProps, AppState> {
     target:
       | ExcalidrawElement
       | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
-    opts?:
+    opts?: (
       | {
           fitToContent?: boolean;
           fitToViewport?: never;
@@ -3546,7 +3605,12 @@ class App extends React.Component<AppProps, AppState> {
           viewportZoomFactor?: number;
           animate?: boolean;
           duration?: number;
-        },
+        }
+    ) & {
+      minZoom?: number;
+      maxZoom?: number;
+      canvasOffsets?: Offsets;
+    },
   ) => {
     this.cancelInProgressAnimation?.();
 
@@ -3559,10 +3623,13 @@ class App extends React.Component<AppProps, AppState> {
 
     if (opts?.fitToContent || opts?.fitToViewport) {
       const { appState } = zoomToFit({
+        canvasOffsets: opts.canvasOffsets,
         targetElements,
         appState: this.state,
         fitToViewport: !!opts?.fitToViewport,
         viewportZoomFactor: opts?.viewportZoomFactor,
+        minZoom: opts?.minZoom,
+        maxZoom: opts?.maxZoom,
       });
       zoom = appState.zoom;
       scrollX = appState.scrollX;
@@ -3676,15 +3743,7 @@ class App extends React.Component<AppProps, AppState> {
 
       this.files = { ...this.files, ...Object.fromEntries(filesMap) };
 
-      this.scene.getNonDeletedElements().forEach((element) => {
-        if (
-          isInitializedImageElement(element) &&
-          filesMap.has(element.fileId)
-        ) {
-          this.imageCache.delete(element.fileId);
-          ShapeCache.delete(element);
-        }
-      });
+      this.clearImageShapeCache(Object.fromEntries(filesMap));
       this.scene.triggerUpdate();
 
       this.addNewImagesToImageCache();
@@ -3798,40 +3857,42 @@ class App extends React.Component<AppProps, AppState> {
     },
   );
 
-  private getEditorUIOffsets = (): {
-    top: number;
-    right: number;
-    bottom: number;
-    left: number;
-  } => {
+  public getEditorUIOffsets = (): Offsets => {
     const toolbarBottom =
       this.excalidrawContainerRef?.current
         ?.querySelector(".App-toolbar")
         ?.getBoundingClientRect()?.bottom ?? 0;
-    const sidebarWidth = Math.max(
-      this.excalidrawContainerRef?.current
-        ?.querySelector(".default-sidebar")
-        ?.getBoundingClientRect()?.width ?? 0,
-    );
-    const propertiesPanelWidth = Math.max(
-      this.excalidrawContainerRef?.current
-        ?.querySelector(".App-menu__left")
-        ?.getBoundingClientRect()?.width ?? 0,
-      0,
-    );
+    const sidebarRect = this.excalidrawContainerRef?.current
+      ?.querySelector(".sidebar")
+      ?.getBoundingClientRect();
+    const propertiesPanelRect = this.excalidrawContainerRef?.current
+      ?.querySelector(".App-menu__left")
+      ?.getBoundingClientRect();
+
+    const PADDING = 16;
 
     return getLanguage().rtl
       ? {
-          top: toolbarBottom,
-          right: propertiesPanelWidth,
-          bottom: 0,
-          left: sidebarWidth,
+          top: toolbarBottom + PADDING,
+          right:
+            Math.max(
+              this.state.width -
+                (propertiesPanelRect?.left ?? this.state.width),
+              0,
+            ) + PADDING,
+          bottom: PADDING,
+          left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
         }
       : {
-          top: toolbarBottom,
-          right: sidebarWidth,
-          bottom: 0,
-          left: propertiesPanelWidth,
+          top: toolbarBottom + PADDING,
+          right: Math.max(
+            this.state.width -
+              (sidebarRect?.left ?? this.state.width) +
+              PADDING,
+            0,
+          ),
+          bottom: PADDING,
+          left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
         };
   };
 
@@ -3938,7 +3999,7 @@ class App extends React.Component<AppProps, AppState> {
               animate: true,
               duration: 300,
               fitToContent: true,
-              viewportZoomFactor: 0.8,
+              canvasOffsets: this.getEditorUIOffsets(),
             });
           }
 
@@ -3994,6 +4055,7 @@ class App extends React.Component<AppProps, AppState> {
                 this.scrollToContent(nextNode, {
                   animate: true,
                   duration: 300,
+                  canvasOffsets: this.getEditorUIOffsets(),
                 });
               }
             }
@@ -4426,6 +4488,7 @@ class App extends React.Component<AppProps, AppState> {
             this.scrollToContent(firstNode, {
               animate: true,
               duration: 300,
+              canvasOffsets: this.getEditorUIOffsets(),
             });
           }
         }
@@ -4871,7 +4934,7 @@ class App extends React.Component<AppProps, AppState> {
         this.getElementHitThreshold(),
       );
 
-      return isPointInShape(point(x, y), selectionShape);
+      return isPointInShape(pointFrom(x, y), selectionShape);
     }
 
     // take bound text element into consideration for hit collision as well
@@ -5247,7 +5310,7 @@ class App extends React.Component<AppProps, AppState> {
           element,
           this.scene.getNonDeletedElementsMap(),
           this.state,
-          point(scenePointer.x, scenePointer.y),
+          pointFrom(scenePointer.x, scenePointer.y),
           this.device.editor.isMobile,
         )
       );
@@ -5259,11 +5322,14 @@ class App extends React.Component<AppProps, AppState> {
     isTouchScreen: boolean,
   ) => {
     const draggedDistance = pointDistance(
-      point(
+      pointFrom(
         this.lastPointerDownEvent!.clientX,
         this.lastPointerDownEvent!.clientY,
       ),
-      point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
+      pointFrom(
+        this.lastPointerUpEvent!.clientX,
+        this.lastPointerUpEvent!.clientY,
+      ),
     );
     if (
       !this.hitLinkElement ||
@@ -5282,7 +5348,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       elementsMap,
       this.state,
-      point(lastPointerDownCoords.x, lastPointerDownCoords.y),
+      pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
       this.device.editor.isMobile,
     );
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
@@ -5293,7 +5359,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       elementsMap,
       this.state,
-      point(lastPointerUpCoords.x, lastPointerUpCoords.y),
+      pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
       this.device.editor.isMobile,
     );
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@@ -5543,7 +5609,7 @@ class App extends React.Component<AppProps, AppState> {
         // threshold, add a point
         if (
           pointDistance(
-            point(scenePointerX - rx, scenePointerY - ry),
+            pointFrom(scenePointerX - rx, scenePointerY - ry),
             lastPoint,
           ) >= LINE_CONFIRM_THRESHOLD
         ) {
@@ -5552,7 +5618,7 @@ class App extends React.Component<AppProps, AppState> {
             {
               points: [
                 ...points,
-                point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
+                pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
               ],
             },
             false,
@@ -5566,7 +5632,7 @@ class App extends React.Component<AppProps, AppState> {
         points.length > 2 &&
         lastCommittedPoint &&
         pointDistance(
-          point(scenePointerX - rx, scenePointerY - ry),
+          pointFrom(scenePointerX - rx, scenePointerY - ry),
           lastCommittedPoint,
         ) < LINE_CONFIRM_THRESHOLD
       ) {
@@ -5614,7 +5680,7 @@ class App extends React.Component<AppProps, AppState> {
             this.scene.getNonDeletedElementsMap(),
             [
               ...points.slice(0, -1),
-              point<LocalPoint>(
+              pointFrom<LocalPoint>(
                 lastCommittedX + dxFromLastCommitted,
                 lastCommittedY + dyFromLastCommitted,
               ),
@@ -5633,7 +5699,7 @@ class App extends React.Component<AppProps, AppState> {
             {
               points: [
                 ...points.slice(0, -1),
-                point<LocalPoint>(
+                pointFrom<LocalPoint>(
                   lastCommittedX + dxFromLastCommitted,
                   lastCommittedY + dyFromLastCommitted,
                 ),
@@ -5862,8 +5928,8 @@ class App extends React.Component<AppProps, AppState> {
     };
 
     const distance = pointDistance(
-      point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
-      point(scenePointer.x, scenePointer.y),
+      pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
+      pointFrom(scenePointer.x, scenePointer.y),
     );
     const threshold = this.getElementHitThreshold();
     const p = { ...pointerDownState.lastCoords };
@@ -6010,6 +6076,16 @@ class App extends React.Component<AppProps, AppState> {
     this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
     this.maybeUnfollowRemoteUser();
 
+    if (this.state.searchMatches) {
+      this.setState((state) => ({
+        searchMatches: state.searchMatches.map((searchMatch) => ({
+          ...searchMatch,
+          focus: false,
+        })),
+      }));
+      jotaiStore.set(searchItemInFocusAtom, null);
+    }
+
     // since contextMenu options are potentially evaluated on each render,
     // and an contextMenu action may depend on selection state, we must
     // close the contextMenu before we update the selection on pointerDown
@@ -6365,7 +6441,7 @@ class App extends React.Component<AppProps, AppState> {
           this.hitLinkElement,
           this.scene.getNonDeletedElementsMap(),
           this.state,
-          point(scenePointer.x, scenePointer.y),
+          pointFrom(scenePointer.x, scenePointer.y),
         )
       ) {
         this.handleEmbeddableCenterClick(this.hitLinkElement);
@@ -6438,8 +6514,16 @@ class App extends React.Component<AppProps, AppState> {
     }
     isPanning = true;
 
+    // due to event.preventDefault below, container wouldn't get focus
+    // automatically
+    this.focusContainer();
+
+    // preventing defualt while text editing messes with cursor/focus
     if (!this.state.editingTextElement) {
-      // preventing defualt while text editing messes with cursor/focus
+      // necessary to prevent browser from scrolling the page if excalidraw
+      // not full-page #4489
+      //
+      // as such, the above is broken when panning canvas while in wysiwyg
       event.preventDefault();
     }
 
@@ -7068,7 +7152,7 @@ class App extends React.Component<AppProps, AppState> {
       simulatePressure,
       locked: false,
       frameId: topLayerFrame ? topLayerFrame.id : null,
-      points: [point<LocalPoint>(0, 0)],
+      points: [pointFrom<LocalPoint>(0, 0)],
       pressures: simulatePressure ? [] : [event.pressure],
     });
 
@@ -7277,7 +7361,10 @@ class App extends React.Component<AppProps, AppState> {
         multiElement.points.length > 1 &&
         lastCommittedPoint &&
         pointDistance(
-          point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
+          pointFrom(
+            pointerDownState.origin.x - rx,
+            pointerDownState.origin.y - ry,
+          ),
           lastCommittedPoint,
         ) < LINE_CONFIRM_THRESHOLD
       ) {
@@ -7379,7 +7466,7 @@ class App extends React.Component<AppProps, AppState> {
         };
       });
       mutateElement(element, {
-        points: [...element.points, point<LocalPoint>(0, 0)],
+        points: [...element.points, pointFrom<LocalPoint>(0, 0)],
       });
       const boundElement = getHoveredElementForBinding(
         pointerDownState.origin,
@@ -7635,8 +7722,8 @@ class App extends React.Component<AppProps, AppState> {
       ) {
         if (
           pointDistance(
-            point(pointerCoords.x, pointerCoords.y),
-            point(pointerDownState.origin.x, pointerDownState.origin.y),
+            pointFrom(pointerCoords.x, pointerCoords.y),
+            pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
           ) < DRAGGING_THRESHOLD
         ) {
           return;
@@ -8031,7 +8118,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points, point<LocalPoint>(dx, dy)],
+                points: [...points, pointFrom<LocalPoint>(dx, dy)],
                 pressures,
               },
               false,
@@ -8060,7 +8147,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points, point<LocalPoint>(dx, dy)],
+                points: [...points, pointFrom<LocalPoint>(dx, dy)],
               },
               false,
             );
@@ -8068,7 +8155,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElbowArrow(
               newElement,
               elementsMap,
-              [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
+              [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
               vector(0, 0),
               undefined,
               {
@@ -8080,7 +8167,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
+                points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
               },
               false,
             );
@@ -8394,9 +8481,9 @@ class App extends React.Component<AppProps, AppState> {
           : [...newElement.pressures, childEvent.pressure];
 
         mutateElement(newElement, {
-          points: [...points, point<LocalPoint>(dx, dy)],
+          points: [...points, pointFrom<LocalPoint>(dx, dy)],
           pressures,
-          lastCommittedPoint: point<LocalPoint>(dx, dy),
+          lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
         });
 
         this.actionManager.executeAction(actionFinalize);
@@ -8443,7 +8530,7 @@ class App extends React.Component<AppProps, AppState> {
           mutateElement(newElement, {
             points: [
               ...newElement.points,
-              point<LocalPoint>(
+              pointFrom<LocalPoint>(
                 pointerCoords.x - newElement.x,
                 pointerCoords.y - newElement.y,
               ),
@@ -8771,8 +8858,8 @@ class App extends React.Component<AppProps, AppState> {
         this.eraserTrail.endPath();
 
         const draggedDistance = pointDistance(
-          point(pointerStart.clientX, pointerStart.clientY),
-          point(pointerEnd.clientX, pointerEnd.clientY),
+          pointFrom(pointerStart.clientX, pointerStart.clientY),
+          pointFrom(pointerEnd.clientX, pointerEnd.clientY),
         );
 
         if (draggedDistance === 0) {

+ 14 - 1
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"],

+ 28 - 25
packages/excalidraw/components/DefaultSidebar.tsx

@@ -1,8 +1,11 @@
 import clsx from "clsx";
-import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
+import {
+  CANVAS_SEARCH_TAB,
+  DEFAULT_SIDEBAR,
+  LIBRARY_SIDEBAR_TAB,
+} from "../constants";
 import { useTunnels } from "../context/tunnels";
 import { useUIAppState } from "../context/ui-appState";
-import { t } from "../i18n";
 import type { MarkOptional, Merge } from "../utility-types";
 import { composeEventHandlers } from "../utils";
 import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +13,9 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
 import { LibraryMenu } from "./LibraryMenu";
 import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
 import { Sidebar } from "./Sidebar/Sidebar";
+import "../components/dropdownMenu/DropdownMenu.scss";
+import { SearchMenu } from "./SearchMenu";
+import { LibraryIcon, searchIcon } from "./icons";
 
 const DefaultSidebarTrigger = withInternalFallback(
   "DefaultSidebarTrigger",
@@ -31,14 +37,11 @@ const DefaultSidebarTrigger = withInternalFallback(
 );
 DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
 
-const DefaultTabTriggers = ({
-  children,
-  ...rest
-}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
+const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => {
   const { DefaultSidebarTabTriggersTunnel } = useTunnels();
   return (
     <DefaultSidebarTabTriggersTunnel.In>
-      <Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
+      {children}
     </DefaultSidebarTabTriggersTunnel.In>
   );
 };
@@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign(
 
       const { DefaultSidebarTabTriggersTunnel } = useTunnels();
 
+      const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB;
+
       return (
         <Sidebar
           {...rest}
           name="default"
           key="default"
           className={clsx("default-sidebar", className)}
-          docked={docked ?? appState.defaultSidebarDockedPreference}
+          docked={
+            isForceDocked || (docked ?? appState.defaultSidebarDockedPreference)
+          }
           onDock={
             // `onDock=false` disables docking.
             // if `docked` passed, but no onDock passed, disable manual docking.
-            onDock === false || (!onDock && docked != null)
+            isForceDocked || onDock === false || (!onDock && docked != null)
               ? undefined
               : // compose to allow the host app to listen on default behavior
                 composeEventHandlers(onDock, (docked) => {
@@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign(
         >
           <Sidebar.Tabs>
             <Sidebar.Header>
-              {rest.__fallback && (
-                <div
-                  style={{
-                    color: "var(--color-primary)",
-                    fontSize: "1.2em",
-                    fontWeight: "bold",
-                    textOverflow: "ellipsis",
-                    overflow: "hidden",
-                    whiteSpace: "nowrap",
-                    paddingRight: "1em",
-                  }}
-                >
-                  {t("toolBar.library")}
-                </div>
-              )}
-              <DefaultSidebarTabTriggersTunnel.Out />
+              <Sidebar.TabTriggers>
+                <Sidebar.TabTrigger tab={CANVAS_SEARCH_TAB}>
+                  {searchIcon}
+                </Sidebar.TabTrigger>
+                <Sidebar.TabTrigger tab={LIBRARY_SIDEBAR_TAB}>
+                  {LibraryIcon}
+                </Sidebar.TabTrigger>
+                <DefaultSidebarTabTriggersTunnel.Out />
+              </Sidebar.TabTriggers>
             </Sidebar.Header>
             <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
               <LibraryMenu />
             </Sidebar.Tab>
+            <Sidebar.Tab tab={CANVAS_SEARCH_TAB}>
+              <SearchMenu />
+            </Sidebar.Tab>
             {children}
           </Sidebar.Tabs>
         </Sidebar>

+ 4 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("stats.fullTitle")}
               shortcuts={[getShortcutKey("Alt+/")]}
             />
+            <Shortcut
+              label={t("search.title")}
+              shortcuts={[getShortcutFromShortcutName("searchMenu")]}
+            />
             <Shortcut
               label={t("commandPalette.title")}
               shortcuts={

+ 9 - 0
packages/excalidraw/components/HintViewer.tsx

@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
 import "./HintViewer.scss";
 import { isNodeInFlowchart } from "../element/flowchart";
 import { isGridModeEnabled } from "../snapping";
+import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "../constants";
 
 interface HintViewerProps {
   appState: UIAppState;
@@ -30,6 +31,14 @@ const getHints = ({
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
+  if (
+    appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+    appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
+    appState.searchMatches?.length
+  ) {
+    return t("hints.dismissSearch");
+  }
+
   if (appState.openSidebar && !device.editor.canFitSidebar) {
     return null;
   }

+ 4 - 3
packages/excalidraw/components/LayerUI.tsx

@@ -53,9 +53,6 @@ import { LibraryIcon } from "./icons";
 import { UIAppStateContext } from "../context/ui-appState";
 import { DefaultSidebar } from "./DefaultSidebar";
 import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
-
-import "./LayerUI.scss";
-import "./Toolbar.scss";
 import { mutateElement } from "../element/mutateElement";
 import { ShapeCache } from "../scene/ShapeCache";
 import Scene from "../scene/Scene";
@@ -64,6 +61,9 @@ import { TTDDialog } from "./TTDDialog/TTDDialog";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
 
+import "./LayerUI.scss";
+import "./Toolbar.scss";
+
 interface LayerUIProps {
   actionManager: ActionManager;
   appState: UIAppState;
@@ -99,6 +99,7 @@ const DefaultMainMenu: React.FC<{
       {UIOptions.canvasActions.saveAsImage && (
         <MainMenu.DefaultItems.SaveAsImage />
       )}
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />

+ 110 - 0
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);
+    }
+  }
+}

+ 718 - 0
packages/excalidraw/components/SearchMenu.tsx

@@ -0,0 +1,718 @@
+import { Fragment, memo, useEffect, useRef, useState } from "react";
+import { collapseDownIcon, upIcon, searchIcon } from "./icons";
+import { TextField } from "./TextField";
+import { Button } from "./Button";
+import { useApp, useExcalidrawSetAppState } from "./App";
+import { debounce } from "lodash";
+import type { AppClassProperties } from "../types";
+import { isTextElement, newTextElement } from "../element";
+import type { ExcalidrawTextElement } from "../element/types";
+import { measureText } from "../element/textElement";
+import { addEventListener, getFontString } from "../utils";
+import { KEYS } from "../keys";
+import clsx from "clsx";
+import { atom, useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import { t } from "../i18n";
+import { isElementCompletelyInViewport } from "../element/sizeHelpers";
+import { randomInteger } from "../random";
+import { CLASSES, EVENT } from "../constants";
+import { useStable } from "../hooks/useStable";
+
+import "./SearchMenu.scss";
+import { round } from "../../math";
+
+const searchQueryAtom = atom<string>("");
+export const searchItemInFocusAtom = atom<number | null>(null);
+
+const SEARCH_DEBOUNCE = 350;
+
+type SearchMatchItem = {
+  textElement: ExcalidrawTextElement;
+  searchQuery: SearchQuery;
+  index: number;
+  preview: {
+    indexInSearchQuery: number;
+    previewText: string;
+    moreBefore: boolean;
+    moreAfter: boolean;
+  };
+  matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[];
+};
+
+type SearchMatches = {
+  nonce: number | null;
+  items: SearchMatchItem[];
+};
+
+type SearchQuery = string & { _brand: "SearchQuery" };
+
+export const SearchMenu = () => {
+  const app = useApp();
+  const setAppState = useExcalidrawSetAppState();
+
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
+  const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope);
+  const searchQuery = inputValue.trim() as SearchQuery;
+
+  const [isSearching, setIsSearching] = useState(false);
+
+  const [searchMatches, setSearchMatches] = useState<SearchMatches>({
+    nonce: null,
+    items: [],
+  });
+  const searchedQueryRef = useRef<SearchQuery | null>(null);
+  const lastSceneNonceRef = useRef<number | undefined>(undefined);
+
+  const [focusIndex, setFocusIndex] = useAtom(
+    searchItemInFocusAtom,
+    jotaiScope,
+  );
+  const elementsMap = app.scene.getNonDeletedElementsMap();
+
+  useEffect(() => {
+    if (isSearching) {
+      return;
+    }
+    if (
+      searchQuery !== searchedQueryRef.current ||
+      app.scene.getSceneNonce() !== lastSceneNonceRef.current
+    ) {
+      searchedQueryRef.current = null;
+      handleSearch(searchQuery, app, (matchItems, index) => {
+        setSearchMatches({
+          nonce: randomInteger(),
+          items: matchItems,
+        });
+        searchedQueryRef.current = searchQuery;
+        lastSceneNonceRef.current = app.scene.getSceneNonce();
+        setAppState({
+          searchMatches: matchItems.map((searchMatch) => ({
+            id: searchMatch.textElement.id,
+            focus: false,
+            matchedLines: searchMatch.matchedLines,
+          })),
+        });
+      });
+    }
+  }, [
+    isSearching,
+    searchQuery,
+    elementsMap,
+    app,
+    setAppState,
+    setFocusIndex,
+    lastSceneNonceRef,
+  ]);
+
+  const goToNextItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return (focusIndex + 1) % searchMatches.items.length;
+      });
+    }
+  };
+
+  const goToPreviousItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return focusIndex - 1 < 0
+          ? searchMatches.items.length - 1
+          : focusIndex - 1;
+      });
+    }
+  };
+
+  useEffect(() => {
+    setAppState((state) => {
+      return {
+        searchMatches: state.searchMatches.map((match, index) => {
+          if (index === focusIndex) {
+            return { ...match, focus: true };
+          }
+          return { ...match, focus: false };
+        }),
+      };
+    });
+  }, [focusIndex, setAppState]);
+
+  useEffect(() => {
+    if (searchMatches.items.length > 0 && focusIndex !== null) {
+      const match = searchMatches.items[focusIndex];
+
+      if (match) {
+        const zoomValue = app.state.zoom.value;
+
+        const matchAsElement = newTextElement({
+          text: match.searchQuery,
+          x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
+          y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
+          width: match.matchedLines[0]?.width,
+          height: match.matchedLines[0]?.height,
+          fontSize: match.textElement.fontSize,
+          fontFamily: match.textElement.fontFamily,
+        });
+
+        const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
+
+        const fontSize = match.textElement.fontSize;
+        const isTextTiny =
+          fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
+
+        if (
+          !isElementCompletelyInViewport(
+            [matchAsElement],
+            app.canvas.width / window.devicePixelRatio,
+            app.canvas.height / window.devicePixelRatio,
+            {
+              offsetLeft: app.state.offsetLeft,
+              offsetTop: app.state.offsetTop,
+              scrollX: app.state.scrollX,
+              scrollY: app.state.scrollY,
+              zoom: app.state.zoom,
+            },
+            app.scene.getNonDeletedElementsMap(),
+            app.getEditorUIOffsets(),
+          ) ||
+          isTextTiny
+        ) {
+          let zoomOptions: Parameters<AppClassProperties["scrollToContent"]>[1];
+
+          if (isTextTiny) {
+            if (fontSize >= FONT_SIZE_LEGIBILITY_THRESHOLD) {
+              zoomOptions = { fitToContent: true };
+            } else {
+              zoomOptions = {
+                fitToViewport: true,
+                // calculate zoom level to make the fontSize ~equal to FONT_SIZE_THRESHOLD, rounded to nearest 10%
+                maxZoom: round(FONT_SIZE_LEGIBILITY_THRESHOLD / fontSize, 1),
+              };
+            }
+          } else {
+            zoomOptions = { fitToContent: true };
+          }
+
+          app.scrollToContent(matchAsElement, {
+            animate: true,
+            duration: 300,
+            ...zoomOptions,
+            canvasOffsets: app.getEditorUIOffsets(),
+          });
+        }
+      }
+    }
+  }, [focusIndex, searchMatches, app]);
+
+  useEffect(() => {
+    return () => {
+      setFocusIndex(null);
+      searchedQueryRef.current = null;
+      lastSceneNonceRef.current = undefined;
+      setAppState({
+        searchMatches: [],
+      });
+      setIsSearching(false);
+    };
+  }, [setAppState, setFocusIndex]);
+
+  const stableState = useStable({
+    goToNextItem,
+    goToPreviousItem,
+    searchMatches,
+  });
+
+  useEffect(() => {
+    const eventHandler = (event: KeyboardEvent) => {
+      if (
+        event.key === KEYS.ESCAPE &&
+        !app.state.openDialog &&
+        !app.state.openPopup
+      ) {
+        event.preventDefault();
+        event.stopPropagation();
+        setAppState({
+          openSidebar: null,
+        });
+        return;
+      }
+
+      if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
+        event.preventDefault();
+        event.stopPropagation();
+
+        if (!searchInputRef.current?.matches(":focus")) {
+          if (app.state.openDialog) {
+            setAppState({
+              openDialog: null,
+            });
+          }
+          searchInputRef.current?.focus();
+          searchInputRef.current?.select();
+        } else {
+          setAppState({
+            openSidebar: null,
+          });
+        }
+      }
+
+      if (
+        event.target instanceof HTMLElement &&
+        event.target.closest(".layer-ui__search")
+      ) {
+        if (stableState.searchMatches.items.length) {
+          if (event.key === KEYS.ENTER) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+
+          if (event.key === KEYS.ARROW_UP) {
+            event.stopPropagation();
+            stableState.goToPreviousItem();
+          } else if (event.key === KEYS.ARROW_DOWN) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+        }
+      }
+    };
+
+    // `capture` needed to prevent firing on initial open from App.tsx,
+    // as well as to handle events before App ones
+    return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
+      capture: true,
+    });
+  }, [setAppState, stableState, app]);
+
+  const matchCount = `${searchMatches.items.length} ${
+    searchMatches.items.length === 1
+      ? t("search.singleResult")
+      : t("search.multipleResults")
+  }`;
+
+  return (
+    <div className="layer-ui__search">
+      <div className="layer-ui__search-header">
+        <TextField
+          className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
+          value={inputValue}
+          ref={searchInputRef}
+          placeholder={t("search.placeholder")}
+          icon={searchIcon}
+          onChange={(value) => {
+            setInputValue(value);
+            setIsSearching(true);
+            const searchQuery = value.trim() as SearchQuery;
+            handleSearch(searchQuery, app, (matchItems, index) => {
+              setSearchMatches({
+                nonce: randomInteger(),
+                items: matchItems,
+              });
+              setFocusIndex(index);
+              searchedQueryRef.current = searchQuery;
+              lastSceneNonceRef.current = app.scene.getSceneNonce();
+              setAppState({
+                searchMatches: matchItems.map((searchMatch) => ({
+                  id: searchMatch.textElement.id,
+                  focus: false,
+                  matchedLines: searchMatch.matchedLines,
+                })),
+              });
+
+              setIsSearching(false);
+            });
+          }}
+          selectOnRender
+        />
+      </div>
+
+      <div className="layer-ui__search-count">
+        {searchMatches.items.length > 0 && (
+          <>
+            {focusIndex !== null && focusIndex > -1 ? (
+              <div>
+                {focusIndex + 1} / {matchCount}
+              </div>
+            ) : (
+              <div>{matchCount}</div>
+            )}
+            <div className="result-nav">
+              <Button
+                onSelect={() => {
+                  goToNextItem();
+                }}
+                className="result-nav-btn"
+              >
+                {collapseDownIcon}
+              </Button>
+              <Button
+                onSelect={() => {
+                  goToPreviousItem();
+                }}
+                className="result-nav-btn"
+              >
+                {upIcon}
+              </Button>
+            </div>
+          </>
+        )}
+
+        {searchMatches.items.length === 0 &&
+          searchQuery &&
+          searchedQueryRef.current && (
+            <div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
+          )}
+      </div>
+
+      <MatchList
+        matches={searchMatches}
+        onItemClick={setFocusIndex}
+        focusIndex={focusIndex}
+        searchQuery={searchQuery}
+      />
+    </div>
+  );
+};
+
+const ListItem = (props: {
+  preview: SearchMatchItem["preview"];
+  searchQuery: SearchQuery;
+  highlighted: boolean;
+  onClick?: () => void;
+}) => {
+  const preview = [
+    props.preview.moreBefore ? "..." : "",
+    props.preview.previewText.slice(0, props.preview.indexInSearchQuery),
+    props.preview.previewText.slice(
+      props.preview.indexInSearchQuery,
+      props.preview.indexInSearchQuery + props.searchQuery.length,
+    ),
+    props.preview.previewText.slice(
+      props.preview.indexInSearchQuery + props.searchQuery.length,
+    ),
+    props.preview.moreAfter ? "..." : "",
+  ];
+
+  return (
+    <div
+      tabIndex={-1}
+      className={clsx("layer-ui__result-item", {
+        active: props.highlighted,
+      })}
+      onClick={props.onClick}
+      ref={(ref) => {
+        if (props.highlighted) {
+          ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
+        }
+      }}
+    >
+      <div className="preview-text">
+        {preview.flatMap((text, idx) => (
+          <Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+interface MatchListProps {
+  matches: SearchMatches;
+  onItemClick: (index: number) => void;
+  focusIndex: number | null;
+  searchQuery: SearchQuery;
+}
+
+const MatchListBase = (props: MatchListProps) => {
+  return (
+    <div className="layer-ui__search-result-container">
+      {props.matches.items.map((searchMatch, index) => (
+        <ListItem
+          key={searchMatch.textElement.id + searchMatch.index}
+          searchQuery={props.searchQuery}
+          preview={searchMatch.preview}
+          highlighted={index === props.focusIndex}
+          onClick={() => props.onItemClick(index)}
+        />
+      ))}
+    </div>
+  );
+};
+
+const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
+  return (
+    prevProps.matches.nonce === nextProps.matches.nonce &&
+    prevProps.focusIndex === nextProps.focusIndex
+  );
+};
+
+const MatchList = memo(MatchListBase, areEqual);
+
+const getMatchPreview = (
+  text: string,
+  index: number,
+  searchQuery: SearchQuery,
+) => {
+  const WORDS_BEFORE = 2;
+  const WORDS_AFTER = 5;
+
+  const substrBeforeQuery = text.slice(0, index);
+  const wordsBeforeQuery = substrBeforeQuery.split(/\s+/);
+  // text = "small", query = "mall", not complete before
+  // text = "small", query = "smal", complete before
+  const isQueryCompleteBefore = substrBeforeQuery.endsWith(" ");
+  const startWordIndex =
+    wordsBeforeQuery.length -
+    WORDS_BEFORE -
+    1 -
+    (isQueryCompleteBefore ? 0 : 1);
+  let wordsBeforeAsString =
+    wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") +
+    (isQueryCompleteBefore ? " " : "");
+
+  const MAX_ALLOWED_CHARS = 20;
+
+  wordsBeforeAsString =
+    wordsBeforeAsString.length > MAX_ALLOWED_CHARS
+      ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
+      : wordsBeforeAsString;
+
+  const substrAfterQuery = text.slice(index + searchQuery.length);
+  const wordsAfter = substrAfterQuery.split(/\s+/);
+  // text = "small", query = "mall", complete after
+  // text = "small", query = "smal", not complete after
+  const isQueryCompleteAfter = !substrAfterQuery.startsWith(" ");
+  const numberOfWordsToTake = isQueryCompleteAfter
+    ? WORDS_AFTER + 1
+    : WORDS_AFTER;
+  const wordsAfterAsString =
+    (isQueryCompleteAfter ? "" : " ") +
+    wordsAfter.slice(0, numberOfWordsToTake).join(" ");
+
+  return {
+    indexInSearchQuery: wordsBeforeAsString.length,
+    previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString,
+    moreBefore: startWordIndex > 0,
+    moreAfter: wordsAfter.length > numberOfWordsToTake,
+  };
+};
+
+const normalizeWrappedText = (
+  wrappedText: string,
+  originalText: string,
+): string => {
+  const wrappedLines = wrappedText.split("\n");
+  const normalizedLines: string[] = [];
+  let originalIndex = 0;
+
+  for (let i = 0; i < wrappedLines.length; i++) {
+    let currentLine = wrappedLines[i];
+    const nextLine = wrappedLines[i + 1];
+
+    if (nextLine) {
+      const nextLineIndexInOriginal = originalText.indexOf(
+        nextLine,
+        originalIndex,
+      );
+
+      if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
+        let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
+
+        while (j > 0) {
+          currentLine += " ";
+          j--;
+        }
+      }
+    }
+
+    normalizedLines.push(currentLine);
+    originalIndex = originalIndex + currentLine.length;
+  }
+
+  return normalizedLines.join("\n");
+};
+
+const getMatchedLines = (
+  textElement: ExcalidrawTextElement,
+  searchQuery: SearchQuery,
+  index: number,
+) => {
+  const normalizedText = normalizeWrappedText(
+    textElement.text,
+    textElement.originalText,
+  );
+
+  const lines = normalizedText.split("\n");
+
+  const lineIndexRanges = [];
+  let currentIndex = 0;
+  let lineNumber = 0;
+
+  for (const line of lines) {
+    const startIndex = currentIndex;
+    const endIndex = startIndex + line.length - 1;
+
+    lineIndexRanges.push({
+      line,
+      startIndex,
+      endIndex,
+      lineNumber,
+    });
+
+    // Move to the next line's start index
+    currentIndex = endIndex + 1;
+    lineNumber++;
+  }
+
+  let startIndex = index;
+  let remainingQuery = textElement.originalText.slice(
+    index,
+    index + searchQuery.length,
+  );
+  const matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[] = [];
+
+  for (const lineIndexRange of lineIndexRanges) {
+    if (remainingQuery === "") {
+      break;
+    }
+
+    if (
+      startIndex >= lineIndexRange.startIndex &&
+      startIndex <= lineIndexRange.endIndex
+    ) {
+      const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
+      const textToStart = lineIndexRange.line.slice(
+        0,
+        startIndex - lineIndexRange.startIndex,
+      );
+
+      const matchedWord = remainingQuery.slice(0, matchCapacity);
+      remainingQuery = remainingQuery.slice(matchCapacity);
+
+      const offset = measureText(
+        textToStart,
+        getFontString(textElement),
+        textElement.lineHeight,
+        true,
+      );
+
+      // measureText returns a non-zero width for the empty string
+      // which is not what we're after here, hence the check and the correction
+      if (textToStart === "") {
+        offset.width = 0;
+      }
+
+      if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
+        const lineLength = measureText(
+          lineIndexRange.line,
+          getFontString(textElement),
+          textElement.lineHeight,
+          true,
+        );
+
+        const spaceToStart =
+          textElement.textAlign === "center"
+            ? (textElement.width - lineLength.width) / 2
+            : textElement.width - lineLength.width;
+        offset.width += spaceToStart;
+      }
+
+      const { width, height } = measureText(
+        matchedWord,
+        getFontString(textElement),
+        textElement.lineHeight,
+      );
+
+      const offsetX = offset.width;
+      const offsetY = lineIndexRange.lineNumber * offset.height;
+
+      matchedLines.push({
+        offsetX,
+        offsetY,
+        width,
+        height,
+      });
+
+      startIndex += matchCapacity;
+    }
+  }
+
+  return matchedLines;
+};
+
+const escapeSpecialCharacters = (string: string) => {
+  return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
+};
+
+const handleSearch = debounce(
+  (
+    searchQuery: SearchQuery,
+    app: AppClassProperties,
+    cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
+  ) => {
+    if (!searchQuery || searchQuery === "") {
+      cb([], null);
+      return;
+    }
+
+    const elements = app.scene.getNonDeletedElements();
+    const texts = elements.filter((el) =>
+      isTextElement(el),
+    ) as ExcalidrawTextElement[];
+
+    texts.sort((a, b) => a.y - b.y);
+
+    const matchItems: SearchMatchItem[] = [];
+
+    const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
+
+    for (const textEl of texts) {
+      let match = null;
+      const text = textEl.originalText;
+
+      while ((match = regex.exec(text)) !== null) {
+        const preview = getMatchPreview(text, match.index, searchQuery);
+        const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
+
+        if (matchedLines.length > 0) {
+          matchItems.push({
+            textElement: textEl,
+            searchQuery,
+            preview,
+            index: match.index,
+            matchedLines,
+          });
+        }
+      }
+    }
+
+    const visibleIds = new Set(
+      app.visibleElements.map((visibleElement) => visibleElement.id),
+    );
+
+    const focusIndex =
+      matchItems.findIndex((matchItem) =>
+        visibleIds.has(matchItem.textElement.id),
+      ) ?? null;
+
+    cb(matchItems, focusIndex);
+  },
+  SEARCH_DEBOUNCE,
+);

+ 3 - 3
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -20,7 +20,7 @@ import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
 import { getElementsInAtomicUnit, resizeElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
-import { point, type GlobalPoint } from "../../../math";
+import { pointFrom, type GlobalPoint } from "../../../math";
 
 interface MultiDimensionProps {
   property: "width" | "height";
@@ -182,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
           nextHeight,
           initialHeight,
           aspectRatio,
-          point(x1, y1),
+          pointFrom(x1, y1),
           property,
           latestElements,
           originalElements,
@@ -287,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
         nextHeight,
         initialHeight,
         aspectRatio,
-        point(x1, y1),
+        pointFrom(x1, y1),
         property,
         latestElements,
         originalElements,

+ 9 - 9
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -13,7 +13,7 @@ import { useMemo } from "react";
 import { getElementsInAtomicUnit, moveElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import type { AppState } from "../../types";
-import { point, pointRotateRads } from "../../../math";
+import { pointFrom, pointRotateRads } from "../../../math";
 
 interface MultiPositionProps {
   property: "x" | "y";
@@ -44,8 +44,8 @@ const moveElements = (
       origElement.y + origElement.height / 2,
     ];
     const [topLeftX, topLeftY] = pointRotateRads(
-      point(origElement.x, origElement.y),
-      point(cx, cy),
+      pointFrom(origElement.x, origElement.y),
+      pointFrom(cx, cy),
       origElement.angle,
     );
 
@@ -97,8 +97,8 @@ const moveGroupTo = (
       ];
 
       const [topLeftX, topLeftY] = pointRotateRads(
-        point(latestElement.x, latestElement.y),
-        point(cx, cy),
+        pointFrom(latestElement.x, latestElement.y),
+        pointFrom(cx, cy),
         latestElement.angle,
       );
 
@@ -171,8 +171,8 @@ const handlePositionChange: DragInputCallbackType<
             origElement.y + origElement.height / 2,
           ];
           const [topLeftX, topLeftY] = pointRotateRads(
-            point(origElement.x, origElement.y),
-            point(cx, cy),
+            pointFrom(origElement.x, origElement.y),
+            pointFrom(cx, cy),
             origElement.angle,
           );
 
@@ -241,8 +241,8 @@ const MultiPosition = ({
         const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
 
         const [topLeftX, topLeftY] = pointRotateRads(
-          point(el.x, el.y),
-          point(cx, cy),
+          pointFrom(el.x, el.y),
+          pointFrom(cx, cy),
           el.angle,
         );
 

+ 5 - 5
packages/excalidraw/components/Stats/Position.tsx

@@ -4,7 +4,7 @@ import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, moveElement } from "./utils";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
-import { point, pointRotateRads } from "../../../math";
+import { pointFrom, pointRotateRads } from "../../../math";
 
 interface PositionProps {
   property: "x" | "y";
@@ -33,8 +33,8 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
     origElement.y + origElement.height / 2,
   ];
   const [topLeftX, topLeftY] = pointRotateRads(
-    point(origElement.x, origElement.y),
-    point(cx, cy),
+    pointFrom(origElement.x, origElement.y),
+    pointFrom(cx, cy),
     origElement.angle,
   );
 
@@ -93,8 +93,8 @@ const Position = ({
   appState,
 }: PositionProps) => {
   const [topLeftX, topLeftY] = pointRotateRads(
-    point(element.x, element.y),
-    point(element.x + element.width / 2, element.y + element.height / 2),
+    pointFrom(element.x, element.y),
+    pointFrom(element.x + element.width / 2, element.y + element.height / 2),
     element.angle,
   );
   const value =

+ 13 - 13
packages/excalidraw/components/Stats/stats.test.tsx

@@ -25,7 +25,7 @@ import { API } from "../../tests/helpers/api";
 import { actionGroup } from "../../actions";
 import { isInGroup } from "../../groups";
 import type { Degrees } from "../../../math";
-import { degreesToRadians, point, pointRotateRads } from "../../../math";
+import { degreesToRadians, pointFrom, pointRotateRads } from "../../../math";
 
 const { h } = window;
 const mouse = new Pointer("mouse");
@@ -264,8 +264,8 @@ describe("stats for a generic element", () => {
       rectangle.y + rectangle.height / 2,
     ];
     const [topLeftX, topLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 
@@ -283,8 +283,8 @@ describe("stats for a generic element", () => {
     testInputProperty(rectangle, "angle", "A", 0, 45);
 
     let [newTopLeftX, newTopLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 
@@ -294,8 +294,8 @@ describe("stats for a generic element", () => {
     testInputProperty(rectangle, "angle", "A", 45, 66);
 
     [newTopLeftX, newTopLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -311,8 +311,8 @@ describe("stats for a generic element", () => {
       rectangle.y + rectangle.height / 2,
     ];
     const [topLeftX, topLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -321,8 +321,8 @@ describe("stats for a generic element", () => {
       rectangle.y + rectangle.height / 2,
     ];
     let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
     expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -334,8 +334,8 @@ describe("stats for a generic element", () => {
       rectangle.y + rectangle.height / 2,
     ];
     [currentTopLeftX, currentTopLeftY] = pointRotateRads(
-      point(rectangle.x, rectangle.y),
-      point(cx, cy),
+      pointFrom(rectangle.x, rectangle.y),
+      pointFrom(cx, cy),
       rectangle.angle,
     );
 

+ 5 - 5
packages/excalidraw/components/Stats/utils.ts

@@ -1,5 +1,5 @@
 import type { Radians } from "../../../math";
-import { point, pointRotateRads } from "../../../math";
+import { pointFrom, pointRotateRads } from "../../../math";
 import {
   bindOrUnbindLinearElements,
   updateBoundElements,
@@ -231,8 +231,8 @@ export const moveElement = (
     originalElement.y + originalElement.height / 2,
   ];
   const [topLeftX, topLeftY] = pointRotateRads(
-    point(originalElement.x, originalElement.y),
-    point(cx, cy),
+    pointFrom(originalElement.x, originalElement.y),
+    pointFrom(cx, cy),
     originalElement.angle,
   );
 
@@ -240,8 +240,8 @@ export const moveElement = (
   const changeInY = newTopLeftY - topLeftY;
 
   const [x, y] = pointRotateRads(
-    point(newTopLeftX, newTopLeftY),
-    point(cx + changeInX, cy + changeInY),
+    pointFrom(newTopLeftX, newTopLeftY),
+    pointFrom(cx + changeInX, cy + changeInY),
     -originalElement.angle as Radians,
   );
 

+ 21 - 7
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;
+    }
   }
 }

+ 10 - 2
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<HTMLInputElement, TextFieldProps>(
       selectOnRender,
       onKeyDown,
       isRedacted = false,
+      icon,
+      className,
       ...rest
     },
     ref,
@@ -47,6 +51,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
 
     useLayoutEffect(() => {
       if (selectOnRender) {
+        // focusing first is needed because vitest/jsdom
+        innerRef.current?.focus();
         innerRef.current?.select();
       }
     }, [selectOnRender]);
@@ -56,14 +62,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
 
     return (
       <div
-        className={clsx("ExcTextField", {
+        className={clsx("ExcTextField", className, {
           "ExcTextField--fullWidth": fullWidth,
+          "ExcTextField--hasIcon": !!icon,
         })}
         onClick={() => {
           innerRef.current?.focus();
         }}
       >
-        <div className="ExcTextField__label">{label}</div>
+        {icon}
+        {label && <div className="ExcTextField__label">{label}</div>}
         <div
           className={clsx("ExcTextField__input", {
             "ExcTextField__input--readonly": readonly,

+ 1 - 0
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -205,6 +205,7 @@ const getRelevantAppStateProps = (
   editingTextElement: appState.editingTextElement,
   isCropping: appState.isCropping,
   croppingElement: appState.croppingElement,
+  searchMatches: appState.searchMatches,
 });
 
 const areEqual = (

+ 2 - 2
packages/excalidraw/components/hyperlink/Hyperlink.tsx

@@ -36,7 +36,7 @@ import { trackEvent } from "../../analytics";
 import { useAppProps, useExcalidrawAppState } from "../App";
 import { isEmbeddableElement } from "../../element/typeChecks";
 import { getLinkHandleFromCoords } from "./helpers";
-import { point, type GlobalPoint } from "../../../math";
+import { pointFrom, type GlobalPoint } from "../../../math";
 
 const CONTAINER_WIDTH = 320;
 const SPACE_BOTTOM = 85;
@@ -181,7 +181,7 @@ export const Hyperlink = ({
         element,
         elementsMap,
         appState,
-        point(event.clientX, event.clientY),
+        pointFrom(event.clientX, event.clientY),
       ) as boolean;
       if (shouldHide) {
         timeoutId = window.setTimeout(() => {

+ 9 - 4
packages/excalidraw/components/hyperlink/helpers.ts

@@ -1,5 +1,5 @@
 import type { GlobalPoint, Radians } from "../../../math";
-import { point, pointRotateRads } from "../../../math";
+import { pointFrom, pointRotateRads } from "../../../math";
 import { MIME_TYPES } from "../../constants";
 import type { Bounds } from "../../element/bounds";
 import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -35,8 +35,8 @@ export const getLinkHandleFromCoords = (
   const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
 
   const [rotatedX, rotatedY] = pointRotateRads(
-    point(x + linkWidth / 2, y + linkHeight / 2),
-    point(centerX, centerY),
+    pointFrom(x + linkWidth / 2, y + linkHeight / 2),
+    pointFrom(centerX, centerY),
     angle,
   );
   return [
@@ -85,5 +85,10 @@ export const isPointHittingLink = (
   ) {
     return true;
   }
-  return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
+  return isPointHittingLinkIcon(
+    element,
+    elementsMap,
+    appState,
+    pointFrom(x, y),
+  );
 };

+ 8 - 0
packages/excalidraw/components/icons.tsx

@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const upIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 15l6 -6l6 6" />
+  </g>,
+  tablerIconProps,
+);

+ 23 - 1
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 (
+    <DropdownMenuItem
+      icon={searchIcon}
+      data-testid="search-menu-button"
+      onSelect={() => {
+        actionManager.executeAction(actionToggleSearchMenu);
+      }}
+      shortcut={getShortcutFromShortcutName("searchMenu")}
+      aria-label={t("search.title")}
+      className={opts?.className}
+    >
+      {t("search.title")}
+    </DropdownMenuItem>
+  );
+};
+SearchMenu.displayName = "SearchMenu";
+
 export const Help = () => {
   const { t } = useI18n();
 

+ 2 - 0
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",
 };
 
 /**
@@ -376,6 +377,7 @@ export const DEFAULT_ELEMENT_PROPS: {
 };
 
 export const LIBRARY_SIDEBAR_TAB = "library";
+export const CANVAS_SEARCH_TAB = "search";
 
 export const DEFAULT_SIDEBAR = {
   name: "default",

+ 3 - 3
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;

+ 27 - 27
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "backgroundColor": "#d8f5a2",
   "boundElements": [
     {
-      "id": "id45",
+      "id": "id47",
       "type": "arrow",
     },
     {
-      "id": "id46",
+      "id": "id48",
       "type": "arrow",
     },
   ],
@@ -47,7 +47,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id46",
+      "id": "id48",
       "type": "arrow",
     },
   ],
@@ -118,7 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": {
-    "elementId": "id47",
+    "elementId": "id49",
     "fixedPoint": null,
     "focus": -0.08139534883720931,
     "gap": 1,
@@ -200,7 +200,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id45",
+      "id": "id47",
       "type": "arrow",
     },
   ],
@@ -238,7 +238,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id48",
+      "id": "id50",
       "type": "arrow",
     },
   ],
@@ -284,7 +284,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id48",
+      "id": "id50",
       "type": "arrow",
     },
   ],
@@ -329,7 +329,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id49",
+      "id": "id51",
       "type": "text",
     },
   ],
@@ -392,7 +392,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "autoResize": true,
   "backgroundColor": "transparent",
   "boundElements": null,
-  "containerId": "id48",
+  "containerId": "id50",
   "customData": undefined,
   "fillStyle": "solid",
   "fontFamily": 5,
@@ -433,7 +433,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id38",
+      "id": "id40",
       "type": "text",
     },
   ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
-    "elementId": "id40",
+    "elementId": "id42",
     "fixedPoint": null,
     "focus": 0,
     "gap": 1,
@@ -472,7 +472,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": {
-    "elementId": "id39",
+    "elementId": "id41",
     "fixedPoint": null,
     "focus": 0,
     "gap": 1,
@@ -496,7 +496,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "autoResize": true,
   "backgroundColor": "transparent",
   "boundElements": null,
-  "containerId": "id37",
+  "containerId": "id39",
   "customData": undefined,
   "fillStyle": "solid",
   "fontFamily": 5,
@@ -537,7 +537,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id37",
+      "id": "id39",
       "type": "arrow",
     },
   ],
@@ -574,7 +574,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id37",
+      "id": "id39",
       "type": "arrow",
     },
   ],
@@ -611,7 +611,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id42",
+      "id": "id44",
       "type": "text",
     },
   ],
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "elbowed": false,
   "endArrowhead": "arrow",
   "endBinding": {
-    "elementId": "id44",
+    "elementId": "id46",
     "fixedPoint": null,
     "focus": 0,
     "gap": 1,
@@ -650,7 +650,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "seed": Any<Number>,
   "startArrowhead": null,
   "startBinding": {
-    "elementId": "id43",
+    "elementId": "id45",
     "fixedPoint": null,
     "focus": 0,
     "gap": 1,
@@ -674,7 +674,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "autoResize": true,
   "backgroundColor": "transparent",
   "boundElements": null,
-  "containerId": "id41",
+  "containerId": "id43",
   "customData": undefined,
   "fillStyle": "solid",
   "fontFamily": 5,
@@ -716,7 +716,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id41",
+      "id": "id43",
       "type": "arrow",
     },
   ],
@@ -762,7 +762,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id41",
+      "id": "id43",
       "type": "arrow",
     },
   ],
@@ -1303,7 +1303,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id54",
+      "id": "id56",
       "type": "text",
     },
     {
@@ -1346,7 +1346,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id55",
+      "id": "id57",
       "type": "text",
     },
   ],
@@ -1385,7 +1385,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id56",
+      "id": "id58",
       "type": "text",
     },
     {
@@ -1428,7 +1428,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id57",
+      "id": "id59",
       "type": "text",
     },
     {
@@ -1475,7 +1475,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id58",
+      "id": "id60",
       "type": "text",
     },
   ],
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "backgroundColor": "transparent",
   "boundElements": [
     {
-      "id": "id59",
+      "id": "id61",
       "type": "text",
     },
   ],

+ 9 - 0
packages/excalidraw/data/encode.ts

@@ -57,6 +57,15 @@ export const base64ToString = async (base64: string, isByteString = false) => {
     : byteStringToString(window.atob(base64));
 };
 
+export const base64ToArrayBuffer = (base64: string): ArrayBuffer => {
+  if (typeof Buffer !== "undefined") {
+    // Node.js environment
+    return Buffer.from(base64, "base64").buffer;
+  }
+  // Browser environment
+  return byteStringToArrayBuffer(atob(base64));
+};
+
 // -----------------------------------------------------------------------------
 // text encoding
 // -----------------------------------------------------------------------------

+ 12 - 8
packages/excalidraw/data/restore.ts

@@ -5,6 +5,7 @@ import type {
   ExcalidrawLinearElement,
   ExcalidrawSelectionElement,
   ExcalidrawTextElement,
+  FixedPointBinding,
   FontFamilyValues,
   OrderedExcalidrawElement,
   PointBinding,
@@ -21,6 +22,7 @@ import {
 import {
   isArrowElement,
   isElbowArrow,
+  isFixedPointBinding,
   isLinearElement,
   isTextElement,
   isUsingAdaptiveRadius,
@@ -55,7 +57,7 @@ import {
   getNormalizedZoom,
 } from "../scene";
 import type { LocalPoint, Radians } from "../../math";
-import { isFiniteNumber, point } from "../../math";
+import { isFiniteNumber, pointFrom } from "../../math";
 
 type RestoredAppState = Omit<
   AppState,
@@ -101,8 +103,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
 
 const repairBinding = (
   element: ExcalidrawLinearElement,
-  binding: PointBinding | null,
-): PointBinding | null => {
+  binding: PointBinding | FixedPointBinding | null,
+): PointBinding | FixedPointBinding | null => {
   if (!binding) {
     return null;
   }
@@ -110,9 +112,11 @@ const repairBinding = (
   return {
     ...binding,
     focus: binding.focus || 0,
-    fixedPoint: isElbowArrow(element)
-      ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
-      : null,
+    ...(isElbowArrow(element) && isFixedPointBinding(binding)
+      ? {
+          fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
+        }
+      : {}),
   };
 };
 
@@ -265,7 +269,7 @@ const restoreElement = (
       let y = element.y;
       let points = // migrate old arrow model to new one
         !Array.isArray(element.points) || element.points.length < 2
-          ? [point(0, 0), point(element.width, element.height)]
+          ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
           : element.points;
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -293,7 +297,7 @@ const restoreElement = (
       let y: number | undefined = element.y;
       let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
         !Array.isArray(element.points) || element.points.length < 2
-          ? [point(0, 0), point(element.width, element.height)]
+          ? [pointFrom(0, 0), pointFrom(element.width, element.height)]
           : element.points;
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {

+ 50 - 45
packages/excalidraw/data/transform.test.ts

@@ -2,7 +2,7 @@ import { vi } from "vitest";
 import type { ExcalidrawElementSkeleton } from "./transform";
 import { convertToExcalidrawElements } from "./transform";
 import type { ExcalidrawArrowElement } from "../element/types";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 
 const opts = { regenerateIds: false };
 
@@ -309,28 +309,32 @@ describe("Test Transform", () => {
   });
 
   describe("Test Frames", () => {
+    const elements: ExcalidrawElementSkeleton[] = [
+      {
+        type: "rectangle",
+        x: 10,
+        y: 10,
+        strokeWidth: 2,
+        id: "1",
+      },
+      {
+        type: "diamond",
+        x: 120,
+        y: 20,
+        backgroundColor: "#fff3bf",
+        strokeWidth: 2,
+        label: {
+          text: "HELLO EXCALIDRAW",
+          strokeColor: "#099268",
+          fontSize: 30,
+        },
+        id: "2",
+      },
+    ];
+
     it("should transform frames and update frame ids when regenerated", () => {
       const elementsSkeleton: ExcalidrawElementSkeleton[] = [
-        {
-          type: "rectangle",
-          x: 10,
-          y: 10,
-          strokeWidth: 2,
-          id: "1",
-        },
-        {
-          type: "diamond",
-          x: 120,
-          y: 20,
-          backgroundColor: "#fff3bf",
-          strokeWidth: 2,
-          label: {
-            text: "HELLO EXCALIDRAW",
-            strokeColor: "#099268",
-            fontSize: 30,
-          },
-          id: "2",
-        },
+        ...elements,
         {
           type: "frame",
           children: ["1", "2"],
@@ -352,28 +356,9 @@ describe("Test Transform", () => {
       });
     });
 
-    it("should consider max of calculated and frame dimensions when provided", () => {
+    it("should consider user defined frame dimensions over calculated when provided", () => {
       const elementsSkeleton: ExcalidrawElementSkeleton[] = [
-        {
-          type: "rectangle",
-          x: 10,
-          y: 10,
-          strokeWidth: 2,
-          id: "1",
-        },
-        {
-          type: "diamond",
-          x: 120,
-          y: 20,
-          backgroundColor: "#fff3bf",
-          strokeWidth: 2,
-          label: {
-            text: "HELLO EXCALIDRAW",
-            strokeColor: "#099268",
-            fontSize: 30,
-          },
-          id: "2",
-        },
+        ...elements,
         {
           type: "frame",
           children: ["1", "2"],
@@ -388,7 +373,27 @@ describe("Test Transform", () => {
       );
       const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
       expect(frame.width).toBe(800);
-      expect(frame.height).toBe(126);
+      expect(frame.height).toBe(100);
+    });
+
+    it("should consider user defined frame coordinates calculated when provided", () => {
+      const elementsSkeleton: ExcalidrawElementSkeleton[] = [
+        ...elements,
+        {
+          type: "frame",
+          children: ["1", "2"],
+          name: "My frame",
+          x: 100,
+          y: 300,
+        },
+      ];
+      const excalidrawElements = convertToExcalidrawElements(
+        elementsSkeleton,
+        opts,
+      );
+      const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
+      expect(frame.x).toBe(100);
+      expect(frame.y).toBe(300);
     });
   });
 
@@ -912,7 +917,7 @@ describe("Test Transform", () => {
         x: 111.262,
         y: 57,
         strokeWidth: 2,
-        points: [point(0, 0), point(272.985, 0)],
+        points: [pointFrom(0, 0), pointFrom(272.985, 0)],
         label: {
           text: "How are you?",
           fontSize: 20,
@@ -935,7 +940,7 @@ describe("Test Transform", () => {
         x: 77.017,
         y: 79,
         strokeWidth: 2,
-        points: [point(0, 0)],
+        points: [pointFrom(0, 0)],
         label: {
           text: "Friendship",
           fontSize: 20,

+ 25 - 8
packages/excalidraw/data/transform.ts

@@ -46,6 +46,7 @@ import {
   assertNever,
   cloneJSON,
   getFontString,
+  isDevEnv,
   toBrandedType,
 } from "../utils";
 import { getSizeFromPoints } from "../points";
@@ -53,7 +54,7 @@ import { randomId } from "../random";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getLineHeight } from "../fonts";
 import { isArrowElement } from "../element/typeChecks";
-import { point, type LocalPoint } from "../../math";
+import { pointFrom, type LocalPoint } from "../../math";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";
@@ -536,7 +537,7 @@ export const convertToExcalidrawElements = (
         excalidrawElement = newLinearElement({
           width,
           height,
-          points: [point(0, 0), point(width, height)],
+          points: [pointFrom(0, 0), pointFrom(width, height)],
           ...element,
         });
 
@@ -549,7 +550,7 @@ export const convertToExcalidrawElements = (
           width,
           height,
           endArrowhead: "arrow",
-          points: [point(0, 0), point(width, height)],
+          points: [pointFrom(0, 0), pointFrom(width, height)],
           ...element,
           type: "arrow",
         });
@@ -717,7 +718,7 @@ export const convertToExcalidrawElements = (
   }
 
   // Once all the excalidraw elements are created, we can add frames since we
-  // need to calculate coordinates and dimensions of frame which is possibe after all
+  // need to calculate coordinates and dimensions of frame which is possible after all
   // frame children are processed.
   for (const [id, element] of elementsWithIds) {
     if (element.type !== "frame" && element.type !== "magicframe") {
@@ -764,10 +765,26 @@ export const convertToExcalidrawElements = (
     maxX = maxX + PADDING;
     maxY = maxY + PADDING;
 
-    // Take the max of calculated and provided frame dimensions, whichever is higher
-    const width = Math.max(frame?.width, maxX - minX);
-    const height = Math.max(frame?.height, maxY - minY);
-    Object.assign(frame, { x: minX, y: minY, width, height });
+    const frameX = frame?.x || minX;
+    const frameY = frame?.y || minY;
+    const frameWidth = frame?.width || maxX - minX;
+    const frameHeight = frame?.height || maxY - minY;
+
+    Object.assign(frame, {
+      x: frameX,
+      y: frameY,
+      width: frameWidth,
+      height: frameHeight,
+    });
+    if (
+      isDevEnv() &&
+      element.children.length &&
+      (frame?.x || frame?.y || frame?.width || frame?.height)
+    ) {
+      console.info(
+        "User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
+      );
+    }
   }
 
   return elementStore.getElements();

+ 45 - 35
packages/excalidraw/element/binding.ts

@@ -39,6 +39,7 @@ import {
   isBindingElement,
   isBoundToContainer,
   isElbowArrow,
+  isFixedPointBinding,
   isFrameLikeElement,
   isLinearElement,
   isRectangularElement,
@@ -65,7 +66,7 @@ import {
 import type { LocalPoint, Radians } from "../../math";
 import {
   lineSegment,
-  point,
+  pointFrom,
   pointRotateRads,
   type GlobalPoint,
   vectorFromPoint,
@@ -719,7 +720,7 @@ export const getHeadingForElbowArrowSnap = (
     return vectorToHeading(
       vectorFromPoint(
         p,
-        point<GlobalPoint>(
+        pointFrom<GlobalPoint>(
           bindableElement.x + bindableElement.width / 2,
           bindableElement.y + bindableElement.height / 2,
         ),
@@ -765,15 +766,15 @@ export const bindPointToSnapToElementOutline = (
     const intersections = [
       ...(intersectElementWithLine(
         bindableElement,
-        point(p[0], p[1] - 2 * bindableElement.height),
-        point(p[0], p[1] + 2 * bindableElement.height),
+        pointFrom(p[0], p[1] - 2 * bindableElement.height),
+        pointFrom(p[0], p[1] + 2 * bindableElement.height),
         FIXED_BINDING_DISTANCE,
         elementsMap,
       ) ?? []),
       ...(intersectElementWithLine(
         bindableElement,
-        point(p[0] - 2 * bindableElement.width, p[1]),
-        point(p[0] + 2 * bindableElement.width, p[1]),
+        pointFrom(p[0] - 2 * bindableElement.width, p[1]),
+        pointFrom(p[0] + 2 * bindableElement.width, p[1]),
         FIXED_BINDING_DISTANCE,
         elementsMap,
       ) ?? []),
@@ -797,7 +798,7 @@ export const bindPointToSnapToElementOutline = (
           isVertical
             ? Math.abs(p[1] - i[1]) < 0.1
             : Math.abs(p[0] - i[0]) < 0.1,
-        )[0] ?? point;
+        )[0] ?? p;
   }
 
   return p;
@@ -814,25 +815,25 @@ const headingToMidBindPoint = (
   switch (true) {
     case compareHeading(heading, HEADING_UP):
       return pointRotateRads(
-        point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
+        pointFrom((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
         center,
         bindableElement.angle,
       );
     case compareHeading(heading, HEADING_RIGHT):
       return pointRotateRads(
-        point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
+        pointFrom(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
         center,
         bindableElement.angle,
       );
     case compareHeading(heading, HEADING_DOWN):
       return pointRotateRads(
-        point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
+        pointFrom((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
         center,
         bindableElement.angle,
       );
     default:
       return pointRotateRads(
-        point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
+        pointFrom(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
         center,
         bindableElement.angle,
       );
@@ -843,7 +844,7 @@ export const avoidRectangularCorner = (
   element: ExcalidrawBindableElement,
   p: GlobalPoint,
 ): GlobalPoint => {
-  const center = point<GlobalPoint>(
+  const center = pointFrom<GlobalPoint>(
     element.x + element.width / 2,
     element.y + element.height / 2,
   );
@@ -853,13 +854,13 @@ export const avoidRectangularCorner = (
     // Top left
     if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
       return pointRotateRads<GlobalPoint>(
-        point(element.x - FIXED_BINDING_DISTANCE, element.y),
+        pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y),
         center,
         element.angle,
       );
     }
     return pointRotateRads(
-      point(element.x, element.y - FIXED_BINDING_DISTANCE),
+      pointFrom(element.x, element.y - FIXED_BINDING_DISTANCE),
       center,
       element.angle,
     );
@@ -870,13 +871,16 @@ export const avoidRectangularCorner = (
     // Bottom left
     if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
       return pointRotateRads(
-        point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
+        pointFrom(
+          element.x,
+          element.y + element.height + FIXED_BINDING_DISTANCE,
+        ),
         center,
         element.angle,
       );
     }
     return pointRotateRads(
-      point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
+      pointFrom(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
       center,
       element.angle,
     );
@@ -890,7 +894,7 @@ export const avoidRectangularCorner = (
       element.width + FIXED_BINDING_DISTANCE
     ) {
       return pointRotateRads(
-        point(
+        pointFrom(
           element.x + element.width,
           element.y + element.height + FIXED_BINDING_DISTANCE,
         ),
@@ -899,7 +903,7 @@ export const avoidRectangularCorner = (
       );
     }
     return pointRotateRads(
-      point(
+      pointFrom(
         element.x + element.width + FIXED_BINDING_DISTANCE,
         element.y + element.height,
       ),
@@ -916,13 +920,16 @@ export const avoidRectangularCorner = (
       element.width + FIXED_BINDING_DISTANCE
     ) {
       return pointRotateRads(
-        point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
+        pointFrom(
+          element.x + element.width,
+          element.y - FIXED_BINDING_DISTANCE,
+        ),
         center,
         element.angle,
       );
     }
     return pointRotateRads(
-      point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
+      pointFrom(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
       center,
       element.angle,
     );
@@ -937,7 +944,10 @@ export const snapToMid = (
   tolerance: number = 0.05,
 ): GlobalPoint => {
   const { x, y, width, height, angle } = element;
-  const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
+  const center = pointFrom<GlobalPoint>(
+    x + width / 2 - 0.1,
+    y + height / 2 - 0.1,
+  );
   const nonRotated = pointRotateRads(p, center, -angle as Radians);
 
   // snap-to-center point is adaptive to element size, but we don't want to go
@@ -952,7 +962,7 @@ export const snapToMid = (
   ) {
     // LEFT
     return pointRotateRads(
-      point(x - FIXED_BINDING_DISTANCE, center[1]),
+      pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
       center,
       angle,
     );
@@ -963,7 +973,7 @@ export const snapToMid = (
   ) {
     // TOP
     return pointRotateRads(
-      point(center[0], y - FIXED_BINDING_DISTANCE),
+      pointFrom(center[0], y - FIXED_BINDING_DISTANCE),
       center,
       angle,
     );
@@ -974,7 +984,7 @@ export const snapToMid = (
   ) {
     // RIGHT
     return pointRotateRads(
-      point(x + width + FIXED_BINDING_DISTANCE, center[1]),
+      pointFrom(x + width + FIXED_BINDING_DISTANCE, center[1]),
       center,
       angle,
     );
@@ -985,7 +995,7 @@ export const snapToMid = (
   ) {
     // DOWN
     return pointRotateRads(
-      point(center[0], y + height + FIXED_BINDING_DISTANCE),
+      pointFrom(center[0], y + height + FIXED_BINDING_DISTANCE),
       center,
       angle,
     );
@@ -1013,7 +1023,7 @@ const updateBoundPoint = (
   const direction = startOrEnd === "startBinding" ? -1 : 1;
   const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
 
-  if (isElbowArrow(linearElement)) {
+  if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
     const fixedPoint =
       normalizeFixedPoint(binding.fixedPoint) ??
       calculateFixedPointForElbowArrowBinding(
@@ -1022,11 +1032,11 @@ const updateBoundPoint = (
         startOrEnd === "startBinding" ? "start" : "end",
         elementsMap,
       ).fixedPoint;
-    const globalMidPoint = point<GlobalPoint>(
+    const globalMidPoint = pointFrom<GlobalPoint>(
       bindableElement.x + bindableElement.width / 2,
       bindableElement.y + bindableElement.height / 2,
     );
-    const global = point<GlobalPoint>(
+    const global = pointFrom<GlobalPoint>(
       bindableElement.x + fixedPoint[0] * bindableElement.width,
       bindableElement.y + fixedPoint[1] * bindableElement.height,
     );
@@ -1117,7 +1127,7 @@ export const calculateFixedPointForElbowArrowBinding = (
     hoveredElement,
     elementsMap,
   );
-  const globalMidPoint = point(
+  const globalMidPoint = pointFrom(
     bounds[0] + (bounds[2] - bounds[0]) / 2,
     bounds[1] + (bounds[3] - bounds[1]) / 2,
   );
@@ -1336,9 +1346,9 @@ export const bindingBorderTest = (
   const threshold = maxBindingGap(element, element.width, element.height);
   const shape = getElementShape(element, elementsMap);
   return (
-    isPointOnShape(point(x, y), shape, threshold) ||
+    isPointOnShape(pointFrom(x, y), shape, threshold) ||
     (fullShape === true &&
-      pointInsideBounds(point(x, y), aabbForElement(element)))
+      pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
   );
 };
 
@@ -2196,11 +2206,11 @@ export const getGlobalFixedPointForBindableElement = (
   const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
 
   return pointRotateRads(
-    point(
+    pointFrom(
       element.x + element.width * fixedX,
       element.y + element.height * fixedY,
     ),
-    point<GlobalPoint>(
+    pointFrom<GlobalPoint>(
       element.x + element.width / 2,
       element.y + element.height / 2,
     ),
@@ -2228,7 +2238,7 @@ const getGlobalFixedPoints = (
           arrow.startBinding.fixedPoint,
           startElement as ExcalidrawBindableElement,
         )
-      : point<GlobalPoint>(
+      : pointFrom<GlobalPoint>(
           arrow.x + arrow.points[0][0],
           arrow.y + arrow.points[0][1],
         );
@@ -2238,7 +2248,7 @@ const getGlobalFixedPoints = (
           arrow.endBinding.fixedPoint,
           endElement as ExcalidrawBindableElement,
         )
-      : point<GlobalPoint>(
+      : pointFrom<GlobalPoint>(
           arrow.x + arrow.points[arrow.points.length - 1][0],
           arrow.y + arrow.points[arrow.points.length - 1][1],
         );

+ 4 - 4
packages/excalidraw/element/bounds.test.ts

@@ -1,5 +1,5 @@
 import type { LocalPoint } from "../../math";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 import { ROUNDNESS } from "../constants";
 import { arrayToMap } from "../utils";
 import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -125,9 +125,9 @@ describe("getElementBounds", () => {
         a: 0.6447741904932416,
       }),
       points: [
-        point<LocalPoint>(0, 0),
-        point<LocalPoint>(67.33984375, 92.48828125),
-        point<LocalPoint>(-102.7890625, 52.15625),
+        pointFrom<LocalPoint>(0, 0),
+        pointFrom<LocalPoint>(67.33984375, 92.48828125),
+        pointFrom<LocalPoint>(-102.7890625, 52.15625),
       ],
     } as ExcalidrawLinearElement;
 

+ 46 - 46
packages/excalidraw/element/bounds.ts

@@ -34,7 +34,7 @@ import type {
 import {
   degreesToRadians,
   lineSegment,
-  point,
+  pointFrom,
   pointDistance,
   pointFromArray,
   pointRotateRads,
@@ -113,8 +113,8 @@ export class ElementBounds {
       const [minX, minY, maxX, maxY] = getBoundsFromPoints(
         element.points.map(([x, y]) =>
           pointRotateRads(
-            point(x, y),
-            point(cx - element.x, cy - element.y),
+            pointFrom(x, y),
+            pointFrom(cx - element.x, cy - element.y),
             element.angle,
           ),
         ),
@@ -130,23 +130,23 @@ export class ElementBounds {
       bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
     } else if (element.type === "diamond") {
       const [x11, y11] = pointRotateRads(
-        point(cx, y1),
-        point(cx, cy),
+        pointFrom(cx, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x12, y12] = pointRotateRads(
-        point(cx, y2),
-        point(cx, cy),
+        pointFrom(cx, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x22, y22] = pointRotateRads(
-        point(x1, cy),
-        point(cx, cy),
+        pointFrom(x1, cy),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x21, y21] = pointRotateRads(
-        point(x2, cy),
-        point(cx, cy),
+        pointFrom(x2, cy),
+        pointFrom(cx, cy),
         element.angle,
       );
       const minX = Math.min(x11, x12, x22, x21);
@@ -164,23 +164,23 @@ export class ElementBounds {
       bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
     } else {
       const [x11, y11] = pointRotateRads(
-        point(x1, y1),
-        point(cx, cy),
+        pointFrom(x1, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x12, y12] = pointRotateRads(
-        point(x1, y2),
-        point(cx, cy),
+        pointFrom(x1, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x22, y22] = pointRotateRads(
-        point(x2, y2),
-        point(cx, cy),
+        pointFrom(x2, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
       const [x21, y21] = pointRotateRads(
-        point(x2, y1),
-        point(cx, cy),
+        pointFrom(x2, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const minX = Math.min(x11, x12, x22, x21);
@@ -255,7 +255,7 @@ export const getElementLineSegments = (
     elementsMap,
   );
 
-  const center: GlobalPoint = point(cx, cy);
+  const center: GlobalPoint = pointFrom(cx, cy);
 
   if (isLinearElement(element) || isFreeDrawElement(element)) {
     const segments: LineSegment<GlobalPoint>[] = [];
@@ -266,7 +266,7 @@ export const getElementLineSegments = (
       segments.push(
         lineSegment(
           pointRotateRads(
-            point(
+            pointFrom(
               element.points[i][0] + element.x,
               element.points[i][1] + element.y,
             ),
@@ -274,7 +274,7 @@ export const getElementLineSegments = (
             element.angle,
           ),
           pointRotateRads(
-            point(
+            pointFrom(
               element.points[i + 1][0] + element.x,
               element.points[i + 1][1] + element.y,
             ),
@@ -470,7 +470,7 @@ export const getMinMaxXYFromCurvePathOps = (
   ops: Op[],
   transformXY?: (p: GlobalPoint) => GlobalPoint,
 ): Bounds => {
-  let currentP: GlobalPoint = point(0, 0);
+  let currentP: GlobalPoint = pointFrom(0, 0);
 
   const { minX, minY, maxX, maxY } = ops.reduce(
     (limits, { op, data }) => {
@@ -484,9 +484,9 @@ export const getMinMaxXYFromCurvePathOps = (
         // move operation does not draw anything; so, it always
         // returns false
       } else if (op === "bcurveTo") {
-        const _p1 = point<GlobalPoint>(data[0], data[1]);
-        const _p2 = point<GlobalPoint>(data[2], data[3]);
-        const _p3 = point<GlobalPoint>(data[4], data[5]);
+        const _p1 = pointFrom<GlobalPoint>(data[0], data[1]);
+        const _p2 = pointFrom<GlobalPoint>(data[2], data[3]);
+        const _p3 = pointFrom<GlobalPoint>(data[4], data[5]);
 
         const p1 = transformXY ? transformXY(_p1) : _p1;
         const p2 = transformXY ? transformXY(_p2) : _p2;
@@ -591,21 +591,21 @@ export const getArrowheadPoints = (
 
   invariant(data.length === 6, "Op data length is not 6");
 
-  const p3 = point(data[4], data[5]);
-  const p2 = point(data[2], data[3]);
-  const p1 = point(data[0], data[1]);
+  const p3 = pointFrom(data[4], data[5]);
+  const p2 = pointFrom(data[2], data[3]);
+  const p1 = pointFrom(data[0], data[1]);
 
   // We need to find p0 of the bezier curve.
   // It is typically the last point of the previous
   // curve; it can also be the position of moveTo operation.
   const prevOp = ops[index - 1];
-  let p0 = point(0, 0);
+  let p0 = pointFrom(0, 0);
   if (prevOp.op === "move") {
     const p = pointFromArray(prevOp.data);
     invariant(p != null, "Op data is not a point");
     p0 = p;
   } else if (prevOp.op === "bcurveTo") {
-    p0 = point(prevOp.data[4], prevOp.data[5]);
+    p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
   }
 
   // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -671,13 +671,13 @@ export const getArrowheadPoints = (
 
   // Return points
   const [x3, y3] = pointRotateRads(
-    point(xs, ys),
-    point(x2, y2),
+    pointFrom(xs, ys),
+    pointFrom(x2, y2),
     ((-angle * Math.PI) / 180) as Radians,
   );
   const [x4, y4] = pointRotateRads(
-    point(xs, ys),
-    point(x2, y2),
+    pointFrom(xs, ys),
+    pointFrom(x2, y2),
     degreesToRadians(angle),
   );
 
@@ -690,8 +690,8 @@ export const getArrowheadPoints = (
       const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
 
       [ox, oy] = pointRotateRads(
-        point(x2 + minSize * 2, y2),
-        point(x2, y2),
+        pointFrom(x2 + minSize * 2, y2),
+        pointFrom(x2, y2),
         Math.atan2(py - y2, px - x2) as Radians,
       );
     } else {
@@ -701,8 +701,8 @@ export const getArrowheadPoints = (
           : [0, 0];
 
       [ox, oy] = pointRotateRads(
-        point(x2 - minSize * 2, y2),
-        point(x2, y2),
+        pointFrom(x2 - minSize * 2, y2),
+        pointFrom(x2, y2),
         Math.atan2(y2 - py, x2 - px) as Radians,
       );
     }
@@ -746,8 +746,8 @@ const getLinearElementRotatedBounds = (
   if (element.points.length < 2) {
     const [pointX, pointY] = element.points[0];
     const [x, y] = pointRotateRads(
-      point(element.x + pointX, element.y + pointY),
-      point(cx, cy),
+      pointFrom(element.x + pointX, element.y + pointY),
+      pointFrom(cx, cy),
       element.angle,
     );
 
@@ -775,8 +775,8 @@ const getLinearElementRotatedBounds = (
   const ops = getCurvePathOps(shape);
   const transformXY = ([x, y]: GlobalPoint) =>
     pointRotateRads<GlobalPoint>(
-      point(element.x + x, element.y + y),
-      point(cx, cy),
+      pointFrom(element.x + x, element.y + y),
+      pointFrom(cx, cy),
       element.angle,
     );
   const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
@@ -931,8 +931,8 @@ export const getClosestElementBounds = (
   elements.forEach((element) => {
     const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
     const distance = pointDistance(
-      point((x1 + x2) / 2, (y1 + y2) / 2),
-      point(from.x, from.y),
+      pointFrom((x1 + x2) / 2, (y1 + y2) / 2),
+      pointFrom(from.x, from.y),
     );
 
     if (distance < minDistance) {
@@ -990,7 +990,7 @@ export const getVisibleSceneBounds = ({
 };
 
 export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
-  point(
+  pointFrom(
     bounds[0] + (bounds[2] - bounds[0]) / 2,
     bounds[1] + (bounds[3] - bounds[1]) / 2,
   );

+ 11 - 7
packages/excalidraw/element/collision.ts

@@ -17,7 +17,7 @@ import {
 } from "./typeChecks";
 import { getBoundTextShape, isPathALoop } from "../shapes";
 import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
-import { isPointWithinBounds, point } from "../../math";
+import { isPointWithinBounds, pointFrom } from "../../math";
 
 export const shouldTestInside = (element: ExcalidrawElement) => {
   if (element.type === "arrow") {
@@ -61,13 +61,13 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
   let hit = shouldTestInside(element)
     ? // Since `inShape` tests STRICTLY againt the insides of a shape
       // we would need `onShape` as well to include the "borders"
-      isPointInShape(point(x, y), shape) ||
-      isPointOnShape(point(x, y), shape, threshold)
-    : isPointOnShape(point(x, y), shape, threshold);
+      isPointInShape(pointFrom(x, y), shape) ||
+      isPointOnShape(pointFrom(x, y), shape, threshold)
+    : isPointOnShape(pointFrom(x, y), shape, threshold);
 
   // hit test against a frame's name
   if (!hit && frameNameBound) {
-    hit = isPointInShape(point(x, y), {
+    hit = isPointInShape(pointFrom(x, y), {
       type: "polygon",
       data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
         .data as Polygon<Point>,
@@ -89,7 +89,11 @@ export const hitElementBoundingBox = (
   y1 -= tolerance;
   x2 += tolerance;
   y2 += tolerance;
-  return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
+  return isPointWithinBounds(
+    pointFrom(x1, y1),
+    pointFrom(x, y),
+    pointFrom(x2, y2),
+  );
 };
 
 export const hitElementBoundingBoxOnly = <
@@ -115,5 +119,5 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
   y: number,
   textShape: GeometricShape<Point> | null,
 ): boolean => {
-  return !!textShape && isPointInShape(point(x, y), textShape);
+  return !!textShape && isPointInShape(pointFrom(x, y), textShape);
 };

+ 8 - 8
packages/excalidraw/element/cropElement.ts

@@ -1,7 +1,7 @@
 import { type Point } from "points-on-curve";
 import {
   type Radians,
-  point,
+  pointFrom,
   pointCenter,
   pointRotateRads,
   vectorFromPoint,
@@ -64,8 +64,8 @@ const _cropElement = (
    */
 
   const rotatedPointer = pointRotateRads(
-    point(pointerX, pointerY),
-    point(element.x + element.width / 2, element.y + element.height / 2),
+    pointFrom(pointerX, pointerY),
+    pointFrom(element.x + element.width / 2, element.y + element.height / 2),
     -element.angle as Radians,
   );
 
@@ -199,8 +199,8 @@ const recomputeOrigin = (
     stateAtCropStart.height,
     true,
   );
-  const startTopLeft = point(x1, y1);
-  const startBottomRight = point(x2, y2);
+  const startTopLeft = pointFrom(x1, y1);
+  const startBottomRight = pointFrom(x2, y2);
   const startCenter: any = pointCenter(startTopLeft, startBottomRight);
 
   const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
@@ -267,16 +267,16 @@ export const getUncroppedImageElement = (
       );
 
       const topLeftVector = vectorFromPoint(
-        pointRotateRads(point(x1, y1), point(cx, cy), element.angle),
+        pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
       );
       const topRightVector = vectorFromPoint(
-        pointRotateRads(point(x2, y1), point(cx, cy), element.angle),
+        pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
       );
       const topEdgeNormalized = vectorNormalize(
         vectorSubtract(topRightVector, topLeftVector),
       );
       const bottomLeftVector = vectorFromPoint(
-        pointRotateRads(point(x1, y2), point(cx, cy), element.angle),
+        pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
       );
       const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
       const leftEdgeNormalized = vectorNormalize(leftEdgeVector);

+ 1 - 8
packages/excalidraw/element/dragElements.ts

@@ -36,7 +36,6 @@ export const dragSelectedElements = (
 ) => {
   if (
     _selectedElements.length === 1 &&
-    isArrowElement(_selectedElements[0]) &&
     isElbowArrow(_selectedElements[0]) &&
     (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
   ) {
@@ -44,13 +43,7 @@ export const dragSelectedElements = (
   }
 
   const selectedElements = _selectedElements.filter(
-    (el) =>
-      !(
-        isArrowElement(el) &&
-        isElbowArrow(el) &&
-        el.startBinding &&
-        el.endBinding
-      ),
+    (el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
   );
 
   // we do not want a frame and its elements to be selected at the same time

+ 31 - 0
packages/excalidraw/element/embeddable.ts

@@ -45,6 +45,12 @@ const RE_GENERIC_EMBED =
 const RE_GIPHY =
   /giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
 
+const RE_REDDIT =
+  /^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
+
+const RE_REDDIT_EMBED =
+  /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
+
 const ALLOWED_DOMAINS = new Set([
   "youtube.com",
   "youtu.be",
@@ -59,6 +65,7 @@ const ALLOWED_DOMAINS = new Set([
   "stackblitz.com",
   "val.town",
   "giphy.com",
+  "reddit.com",
 ]);
 
 const ALLOW_SAME_ORIGIN = new Set([
@@ -71,6 +78,7 @@ const ALLOW_SAME_ORIGIN = new Set([
   "x.com",
   "*.simplepdf.eu",
   "stackblitz.com",
+  "reddit.com",
 ]);
 
 export const createSrcDoc = (body: string) => {
@@ -218,6 +226,24 @@ export const getEmbedLink = (
     return ret;
   }
 
+  if (RE_REDDIT.test(link)) {
+    const [, page, postId, title] = link.match(RE_REDDIT)!;
+    const safeURL = sanitizeHTMLAttribute(
+      `https://reddit.com/r/${page}/comments/${postId}/${title}`,
+    );
+    const ret: IframeDataWithSandbox = {
+      type: "document",
+      srcdoc: (theme: string) =>
+        createSrcDoc(
+          `<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
+        ),
+      intrinsicSize: { w: 480, h: 480 },
+      sandbox: { allowSameOrigin },
+    };
+    embeddedLinkCache.set(originalLink, ret);
+    return ret;
+  }
+
   if (RE_GH_GIST.test(link)) {
     const [, user, gistId] = link.match(RE_GH_GIST)!;
     const safeURL = sanitizeHTMLAttribute(
@@ -361,6 +387,11 @@ export const maybeParseEmbedSrc = (str: string): string => {
     return twitterMatch[1];
   }
 
+  const redditMatch = str.match(RE_REDDIT_EMBED);
+  if (redditMatch && redditMatch.length === 2) {
+    return redditMatch[1];
+  }
+
   const gistMatch = str.match(RE_GH_GIST_EMBED);
   if (gistMatch && gistMatch.length === 2) {
     return gistMatch[1];

+ 2 - 2
packages/excalidraw/element/flowchart.ts

@@ -29,7 +29,7 @@ import {
   isFlowchartNodeElement,
 } from "./typeChecks";
 import { invariant } from "../utils";
-import { point, type LocalPoint } from "../../math";
+import { pointFrom, type LocalPoint } from "../../math";
 import { aabbForElement } from "../shapes";
 
 type LinkDirection = "up" | "right" | "down" | "left";
@@ -421,7 +421,7 @@ const createBindingArrow = (
     strokeColor: appState.currentItemStrokeColor,
     strokeStyle: appState.currentItemStrokeStyle,
     strokeWidth: appState.currentItemStrokeWidth,
-    points: [point(0, 0), point(endX, endY)],
+    points: [pointFrom(0, 0), pointFrom(endX, endY)],
     elbowed: true,
   });
 

+ 9 - 9
packages/excalidraw/element/heading.ts

@@ -6,7 +6,7 @@ import type {
   Radians,
 } from "../../math";
 import {
-  point,
+  pointFrom,
   pointRotateRads,
   pointScaleFromOrigin,
   radiansToDegrees,
@@ -82,7 +82,7 @@ export const headingForPointFromElement = <
 
     const top = pointRotateRads(
       pointScaleFromOrigin(
-        point(element.x + element.width / 2, element.y),
+        pointFrom(element.x + element.width / 2, element.y),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
@@ -91,7 +91,7 @@ export const headingForPointFromElement = <
     );
     const right = pointRotateRads(
       pointScaleFromOrigin(
-        point(element.x + element.width, element.y + element.height / 2),
+        pointFrom(element.x + element.width, element.y + element.height / 2),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
@@ -100,7 +100,7 @@ export const headingForPointFromElement = <
     );
     const bottom = pointRotateRads(
       pointScaleFromOrigin(
-        point(element.x + element.width / 2, element.y + element.height),
+        pointFrom(element.x + element.width / 2, element.y + element.height),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
@@ -109,7 +109,7 @@ export const headingForPointFromElement = <
     );
     const left = pointRotateRads(
       pointScaleFromOrigin(
-        point(element.x, element.y + element.height / 2),
+        pointFrom(element.x, element.y + element.height / 2),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
@@ -133,22 +133,22 @@ export const headingForPointFromElement = <
   }
 
   const topLeft = pointScaleFromOrigin(
-    point(aabb[0], aabb[1]),
+    pointFrom(aabb[0], aabb[1]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
   ) as Point;
   const topRight = pointScaleFromOrigin(
-    point(aabb[2], aabb[1]),
+    pointFrom(aabb[2], aabb[1]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
   ) as Point;
   const bottomLeft = pointScaleFromOrigin(
-    point(aabb[0], aabb[3]),
+    pointFrom(aabb[0], aabb[3]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
   ) as Point;
   const bottomRight = pointScaleFromOrigin(
-    point(aabb[2], aabb[3]),
+    pointFrom(aabb[2], aabb[3]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
   ) as Point;

+ 60 - 50
packages/excalidraw/element/linearElementEditor.ts

@@ -49,7 +49,7 @@ import type Scene from "../scene/Scene";
 import type { Radians } from "../../math";
 import {
   pointCenter,
-  point,
+  pointFrom,
   pointRotateRads,
   pointsEqual,
   vector,
@@ -102,12 +102,13 @@ export class LinearElementEditor {
   public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
   public readonly hoverPointIndex: number;
   public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
+  public readonly elbowed: boolean;
 
   constructor(element: NonDeleted<ExcalidrawLinearElement>) {
     this.elementId = element.id as string & {
       _brand: "excalidrawLinearElementId";
     };
-    if (!pointsEqual(element.points[0], point(0, 0))) {
+    if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
       console.error("Linear element is not normalized", Error().stack);
     }
 
@@ -131,6 +132,7 @@ export class LinearElementEditor {
     };
     this.hoverPointIndex = -1;
     this.segmentMidPointHoveredCoords = null;
+    this.elbowed = isElbowArrow(element) && element.elbowed;
   }
 
   // ---------------------------------------------------------------------------
@@ -285,7 +287,7 @@ export class LinearElementEditor {
           element,
           elementsMap,
           referencePoint,
-          point(scenePointerX, scenePointerY),
+          pointFrom(scenePointerX, scenePointerY),
           event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
         );
 
@@ -294,7 +296,7 @@ export class LinearElementEditor {
           [
             {
               index: selectedIndex,
-              point: point(
+              point: pointFrom(
                 width + referencePoint[0],
                 height + referencePoint[1],
               ),
@@ -327,7 +329,7 @@ export class LinearElementEditor {
                     scenePointerY - linearElementEditor.pointerOffset.y,
                     event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
                   )
-                : point(
+                : pointFrom(
                     element.points[pointIndex][0] + deltaX,
                     element.points[pointIndex][1] + deltaY,
                   );
@@ -588,11 +590,11 @@ export class LinearElementEditor {
       linearElementEditor.segmentMidPointHoveredCoords;
     if (existingSegmentMidpointHitCoords) {
       const distance = pointDistance(
-        point(
+        pointFrom(
           existingSegmentMidpointHitCoords[0],
           existingSegmentMidpointHitCoords[1],
         ),
-        point(scenePointer.x, scenePointer.y),
+        pointFrom(scenePointer.x, scenePointer.y),
       );
       if (distance <= threshold) {
         return existingSegmentMidpointHitCoords;
@@ -604,8 +606,8 @@ export class LinearElementEditor {
     while (index < midPoints.length) {
       if (midPoints[index] !== null) {
         const distance = pointDistance(
-          point(midPoints[index]![0], midPoints[index]![1]),
-          point(scenePointer.x, scenePointer.y),
+          pointFrom(midPoints[index]![0], midPoints[index]![1]),
+          pointFrom(scenePointer.x, scenePointer.y),
         );
         if (distance <= threshold) {
           return midPoints[index];
@@ -624,8 +626,8 @@ export class LinearElementEditor {
     zoom: AppState["zoom"],
   ) {
     let distance = pointDistance(
-      point(startPoint[0], startPoint[1]),
-      point(endPoint[0], endPoint[1]),
+      pointFrom(startPoint[0], startPoint[1]),
+      pointFrom(endPoint[0], endPoint[1]),
     );
     if (element.points.length > 2 && element.roundness) {
       distance = getBezierCurveLength(element, endPoint);
@@ -827,11 +829,11 @@ export class LinearElementEditor {
     const targetPoint =
       clickedPointIndex > -1 &&
       pointRotateRads(
-        point(
+        pointFrom(
           element.x + element.points[clickedPointIndex][0],
           element.y + element.points[clickedPointIndex][1],
         ),
-        point(cx, cy),
+        pointFrom(cx, cy),
         element.angle,
       );
 
@@ -926,11 +928,11 @@ export class LinearElementEditor {
         element,
         elementsMap,
         lastCommittedPoint,
-        point(scenePointerX, scenePointerY),
+        pointFrom(scenePointerX, scenePointerY),
         event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
       );
 
-      newPoint = point(
+      newPoint = pointFrom(
         width + lastCommittedPoint[0],
         height + lastCommittedPoint[1],
       );
@@ -982,8 +984,8 @@ export class LinearElementEditor {
 
     const { x, y } = element;
     return pointRotateRads(
-      point(x + p[0], y + p[1]),
-      point(cx, cy),
+      pointFrom(x + p[0], y + p[1]),
+      pointFrom(cx, cy),
       element.angle,
     );
   }
@@ -999,8 +1001,8 @@ export class LinearElementEditor {
     return element.points.map((p) => {
       const { x, y } = element;
       return pointRotateRads(
-        point(x + p[0], y + p[1]),
-        point(cx, cy),
+        pointFrom(x + p[0], y + p[1]),
+        pointFrom(cx, cy),
         element.angle,
       );
     });
@@ -1023,8 +1025,12 @@ export class LinearElementEditor {
     const { x, y } = element;
 
     return p
-      ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
-      : pointRotateRads(point(x, y), point(cx, cy), element.angle);
+      ? pointRotateRads(
+          pointFrom(x + p[0], y + p[1]),
+          pointFrom(cx, cy),
+          element.angle,
+        )
+      : pointRotateRads(pointFrom(x, y), pointFrom(cx, cy), element.angle);
   }
 
   static pointFromAbsoluteCoords(
@@ -1034,7 +1040,7 @@ export class LinearElementEditor {
   ): LocalPoint {
     if (isElbowArrow(element)) {
       // No rotation for elbow arrows
-      return point(
+      return pointFrom(
         absoluteCoords[0] - element.x,
         absoluteCoords[1] - element.y,
       );
@@ -1044,11 +1050,11 @@ export class LinearElementEditor {
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
     const [x, y] = pointRotateRads(
-      point(absoluteCoords[0], absoluteCoords[1]),
-      point(cx, cy),
+      pointFrom(absoluteCoords[0], absoluteCoords[1]),
+      pointFrom(cx, cy),
       -element.angle as Radians,
     );
-    return point(x - element.x, y - element.y);
+    return pointFrom(x - element.x, y - element.y);
   }
 
   static getPointIndexUnderCursor(
@@ -1069,7 +1075,7 @@ export class LinearElementEditor {
     while (--idx > -1) {
       const p = pointHandles[idx];
       if (
-        pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
+        pointDistance(pointFrom(x, y), pointFrom(p[0], p[1])) * zoom.value <
         // +1px to account for outline stroke
         LinearElementEditor.POINT_HANDLE_SIZE + 1
       ) {
@@ -1091,12 +1097,12 @@ export class LinearElementEditor {
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
     const [rotatedX, rotatedY] = pointRotateRads(
-      point(pointerOnGrid[0], pointerOnGrid[1]),
-      point(cx, cy),
+      pointFrom(pointerOnGrid[0], pointerOnGrid[1]),
+      pointFrom(cx, cy),
       -element.angle as Radians,
     );
 
-    return point(rotatedX - element.x, rotatedY - element.y);
+    return pointFrom(rotatedX - element.x, rotatedY - element.y);
   }
 
   /**
@@ -1116,7 +1122,7 @@ export class LinearElementEditor {
 
     return {
       points: points.map((p) => {
-        return point(p[0] - offsetX, p[1] - offsetY);
+        return pointFrom(p[0] - offsetX, p[1] - offsetY);
       }),
       x: element.x + offsetX,
       y: element.y + offsetY,
@@ -1170,8 +1176,8 @@ export class LinearElementEditor {
         }
         acc.push(
           nextPoint
-            ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
-            : point(p[0], p[1]),
+            ? pointFrom((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
+            : pointFrom(p[0], p[1]),
         );
 
         nextSelectedIndices.push(indexCursor + 1);
@@ -1192,7 +1198,7 @@ export class LinearElementEditor {
         [
           {
             index: element.points.length - 1,
-            point: point(lastPoint[0] + 30, lastPoint[1] + 30),
+            point: pointFrom(lastPoint[0] + 30, lastPoint[1] + 30),
           },
         ],
         elementsMap,
@@ -1233,7 +1239,9 @@ export class LinearElementEditor {
     const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
       if (!pointIndices.includes(idx)) {
         acc.push(
-          !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
+          !acc.length
+            ? pointFrom(0, 0)
+            : pointFrom(p[0] - offsetX, p[1] - offsetY),
         );
       }
       return acc;
@@ -1310,9 +1318,9 @@ export class LinearElementEditor {
         const deltaY =
           selectedPointData.point[1] - points[selectedPointData.index][1];
 
-        return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
+        return pointFrom(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
       }
-      return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
+      return offsetX || offsetY ? pointFrom(p[0] - offsetX, p[1] - offsetY) : p;
     });
 
     LinearElementEditor._updatePoints(
@@ -1366,8 +1374,8 @@ export class LinearElementEditor {
 
     const origin = linearElementEditor.pointerDownState.origin!;
     const dist = pointDistance(
-      point(origin.x, origin.y),
-      point(pointerCoords.x, pointerCoords.y),
+      pointFrom(origin.x, origin.y),
+      pointFrom(pointerCoords.x, pointerCoords.y),
     );
     if (
       !appState.editingLinearElement &&
@@ -1477,7 +1485,9 @@ export class LinearElementEditor {
         nextPoints,
         vector(offsetX, offsetY),
         bindings,
-        options,
+        {
+          isDragging: options?.isDragging,
+        },
       );
     } else {
       const nextCoords = getElementPointsCoords(element, nextPoints);
@@ -1489,8 +1499,8 @@ export class LinearElementEditor {
       const dX = prevCenterX - nextCenterX;
       const dY = prevCenterY - nextCenterY;
       const rotated = pointRotateRads(
-        point(offsetX, offsetY),
-        point(dX, dY),
+        pointFrom(offsetX, offsetY),
+        pointFrom(dX, dY),
         element.angle,
       );
       mutateElement(element, {
@@ -1536,8 +1546,8 @@ export class LinearElementEditor {
     );
 
     return pointRotateRads(
-      point(width, height),
-      point(0, 0),
+      pointFrom(width, height),
+      pointFrom(0, 0),
       -element.angle as Radians,
     );
   }
@@ -1607,36 +1617,36 @@ export class LinearElementEditor {
       );
     const boundTextX2 = boundTextX1 + boundTextElement.width;
     const boundTextY2 = boundTextY1 + boundTextElement.height;
-    const centerPoint = point(cx, cy);
+    const centerPoint = pointFrom(cx, cy);
 
     const topLeftRotatedPoint = pointRotateRads(
-      point(x1, y1),
+      pointFrom(x1, y1),
       centerPoint,
       element.angle,
     );
     const topRightRotatedPoint = pointRotateRads(
-      point(x2, y1),
+      pointFrom(x2, y1),
       centerPoint,
       element.angle,
     );
 
     const counterRotateBoundTextTopLeft = pointRotateRads(
-      point(boundTextX1, boundTextY1),
+      pointFrom(boundTextX1, boundTextY1),
       centerPoint,
       -element.angle as Radians,
     );
     const counterRotateBoundTextTopRight = pointRotateRads(
-      point(boundTextX2, boundTextY1),
+      pointFrom(boundTextX2, boundTextY1),
       centerPoint,
       -element.angle as Radians,
     );
     const counterRotateBoundTextBottomLeft = pointRotateRads(
-      point(boundTextX1, boundTextY2),
+      pointFrom(boundTextX1, boundTextY2),
       centerPoint,
       -element.angle as Radians,
     );
     const counterRotateBoundTextBottomRight = pointRotateRads(
-      point(boundTextX2, boundTextY2),
+      pointFrom(boundTextX2, boundTextY2),
       centerPoint,
       -element.angle as Radians,
     );

+ 2 - 2
packages/excalidraw/element/newElement.test.ts

@@ -5,7 +5,7 @@ import { FONT_FAMILY, ROUNDNESS } from "../constants";
 import { isPrimitive } from "../utils";
 import type { ExcalidrawLinearElement } from "./types";
 import type { LocalPoint } from "../../math";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 
 const assertCloneObjects = (source: any, clone: any) => {
   for (const key in clone) {
@@ -38,7 +38,7 @@ describe("duplicating single elements", () => {
     element.__proto__ = { hello: "world" };
 
     mutateElement(element, {
-      points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
+      points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
     });
 
     const copy = duplicateElement(null, new Map(), element);

+ 0 - 1
packages/excalidraw/element/newElement.ts

@@ -223,7 +223,6 @@ export const newTextElement = (
     verticalAlign?: VerticalAlign;
     containerId?: ExcalidrawTextContainer["id"] | null;
     lineHeight?: ExcalidrawTextElement["lineHeight"];
-    strokeWidth?: ExcalidrawTextElement["strokeWidth"];
     autoResize?: ExcalidrawTextElement["autoResize"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {

+ 58 - 56
packages/excalidraw/element/resizeElements.ts

@@ -9,6 +9,7 @@ import type {
   ExcalidrawTextElementWithContainer,
   ExcalidrawImageElement,
   ElementsMap,
+  ExcalidrawArrowElement,
   NonDeletedSceneElementsMap,
   SceneElementsMap,
 } from "./types";
@@ -57,7 +58,7 @@ import type { GlobalPoint } from "../../math";
 import {
   pointCenter,
   normalizeRadians,
-  point,
+  pointFrom,
   pointFromPair,
   pointRotateRads,
   type Radians,
@@ -239,8 +240,8 @@ const resizeSingleTextElement = (
   );
   // rotation pointer with reverse angle
   const [rotatedX, rotatedY] = pointRotateRads(
-    point(pointerX, pointerY),
-    point(cx, cy),
+    pointFrom(pointerX, pointerY),
+    pointFrom(cx, cy),
     -element.angle as Radians,
   );
   let scaleX = 0;
@@ -275,23 +276,23 @@ const resizeSingleTextElement = (
     const startBottomRight = [x2, y2];
     const startCenter = [cx, cy];
 
-    let newTopLeft = point<GlobalPoint>(x1, y1);
+    let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
     if (["n", "w", "nw"].includes(transformHandleType)) {
-      newTopLeft = point<GlobalPoint>(
+      newTopLeft = pointFrom<GlobalPoint>(
         startBottomRight[0] - Math.abs(nextWidth),
         startBottomRight[1] - Math.abs(nextHeight),
       );
     }
     if (transformHandleType === "ne") {
       const bottomLeft = [startTopLeft[0], startBottomRight[1]];
-      newTopLeft = point<GlobalPoint>(
+      newTopLeft = pointFrom<GlobalPoint>(
         bottomLeft[0],
         bottomLeft[1] - Math.abs(nextHeight),
       );
     }
     if (transformHandleType === "sw") {
       const topRight = [startBottomRight[0], startTopLeft[1]];
-      newTopLeft = point<GlobalPoint>(
+      newTopLeft = pointFrom<GlobalPoint>(
         topRight[0] - Math.abs(nextWidth),
         topRight[1],
       );
@@ -310,12 +311,20 @@ const resizeSingleTextElement = (
     }
 
     const angle = element.angle;
-    const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
-    const newCenter = point<GlobalPoint>(
+    const rotatedTopLeft = pointRotateRads(
+      newTopLeft,
+      pointFrom(cx, cy),
+      angle,
+    );
+    const newCenter = pointFrom<GlobalPoint>(
       newTopLeft[0] + Math.abs(nextWidth) / 2,
       newTopLeft[1] + Math.abs(nextHeight) / 2,
     );
-    const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
+    const rotatedNewCenter = pointRotateRads(
+      newCenter,
+      pointFrom(cx, cy),
+      angle,
+    );
     newTopLeft = pointRotateRads(
       rotatedTopLeft,
       rotatedNewCenter,
@@ -340,12 +349,12 @@ const resizeSingleTextElement = (
       stateAtResizeStart.height,
       true,
     );
-    const startTopLeft = point<GlobalPoint>(x1, y1);
-    const startBottomRight = point<GlobalPoint>(x2, y2);
+    const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
+    const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
     const startCenter = pointCenter(startTopLeft, startBottomRight);
 
     const rotatedPointer = pointRotateRads(
-      point(pointerX, pointerY),
+      pointFrom(pointerX, pointerY),
       startCenter,
       -stateAtResizeStart.angle as Radians,
     );
@@ -418,7 +427,7 @@ const resizeSingleTextElement = (
       startCenter,
       angle,
     );
-    const newCenter = point(
+    const newCenter = pointFrom(
       newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
       newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
     );
@@ -460,13 +469,13 @@ export const resizeSingleElement = (
     stateAtResizeStart.height,
     true,
   );
-  const startTopLeft = point(x1, y1);
-  const startBottomRight = point(x2, y2);
+  const startTopLeft = pointFrom(x1, y1);
+  const startBottomRight = pointFrom(x2, y2);
   const startCenter = pointCenter(startTopLeft, startBottomRight);
 
   // Calculate new dimensions based on cursor position
   const rotatedPointer = pointRotateRads(
-    point(pointerX, pointerY),
+    pointFrom(pointerX, pointerY),
     startCenter,
     -stateAtResizeStart.angle as Radians,
   );
@@ -647,7 +656,7 @@ export const resizeSingleElement = (
     startCenter,
     angle,
   );
-  const newCenter = point(
+  const newCenter = pointFrom(
     newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
     newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
   );
@@ -816,20 +825,20 @@ export const resizeMultipleElements = (
   const direction = transformHandleType;
 
   const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
-    ne: point(minX, maxY),
-    se: point(minX, minY),
-    sw: point(maxX, minY),
-    nw: point(maxX, maxY),
-    e: point(minX, minY + height / 2),
-    w: point(maxX, minY + height / 2),
-    n: point(minX + width / 2, maxY),
-    s: point(minX + width / 2, minY),
+    ne: pointFrom(minX, maxY),
+    se: pointFrom(minX, minY),
+    sw: pointFrom(maxX, minY),
+    nw: pointFrom(maxX, maxY),
+    e: pointFrom(minX, minY + height / 2),
+    w: pointFrom(maxX, minY + height / 2),
+    n: pointFrom(minX + width / 2, maxY),
+    s: pointFrom(minX + width / 2, minY),
   };
 
   // anchor point must be on the opposite side of the dragged selection handle
   // or be the center of the selection if shouldResizeFromCenter
   const [anchorX, anchorY] = shouldResizeFromCenter
-    ? point(midX, midY)
+    ? pointFrom(midX, midY)
     : anchorsMap[direction];
 
   const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -909,6 +918,8 @@ export const resizeMultipleElements = (
       fontSize?: ExcalidrawTextElement["fontSize"];
       scale?: ExcalidrawImageElement["scale"];
       boundTextFontSize?: ExcalidrawTextElement["fontSize"];
+      startBinding?: ExcalidrawArrowElement["startBinding"];
+      endBinding?: ExcalidrawArrowElement["endBinding"];
     };
   }[] = [];
 
@@ -993,19 +1004,6 @@ export const resizeMultipleElements = (
 
     mutateElement(element, update, false);
 
-    if (isArrowElement(element) && isElbowArrow(element)) {
-      mutateElbowArrow(
-        element,
-        elementsMap,
-        element.points,
-        undefined,
-        undefined,
-        {
-          informMutation: false,
-        },
-      );
-    }
-
     updateBoundElements(element, elementsMap, {
       simultaneouslyUpdated: elementsToUpdate,
       oldSize: { width: oldWidth, height: oldHeight },
@@ -1054,12 +1052,12 @@ const rotateMultipleElements = (
       const origAngle =
         originalElements.get(element.id)?.angle ?? element.angle;
       const [rotatedCX, rotatedCY] = pointRotateRads(
-        point(cx, cy),
-        point(centerX, centerY),
+        pointFrom(cx, cy),
+        pointFrom(centerX, centerY),
         (centerAngle + origAngle - element.angle) as Radians,
       );
 
-      if (isArrowElement(element) && isElbowArrow(element)) {
+      if (isElbowArrow(element)) {
         const points = getArrowLocalFixedPoints(element, elementsMap);
         mutateElbowArrow(element, elementsMap, points);
       } else {
@@ -1111,40 +1109,44 @@ export const getResizeOffsetXY = (
   const angle = (
     selectedElements.length === 1 ? selectedElements[0].angle : 0
   ) as Radians;
-  [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
+  [x, y] = pointRotateRads(
+    pointFrom(x, y),
+    pointFrom(cx, cy),
+    -angle as Radians,
+  );
   switch (transformHandleType) {
     case "n":
       return pointRotateRads(
-        point(x - (x1 + x2) / 2, y - y1),
-        point(0, 0),
+        pointFrom(x - (x1 + x2) / 2, y - y1),
+        pointFrom(0, 0),
         angle,
       );
     case "s":
       return pointRotateRads(
-        point(x - (x1 + x2) / 2, y - y2),
-        point(0, 0),
+        pointFrom(x - (x1 + x2) / 2, y - y2),
+        pointFrom(0, 0),
         angle,
       );
     case "w":
       return pointRotateRads(
-        point(x - x1, y - (y1 + y2) / 2),
-        point(0, 0),
+        pointFrom(x - x1, y - (y1 + y2) / 2),
+        pointFrom(0, 0),
         angle,
       );
     case "e":
       return pointRotateRads(
-        point(x - x2, y - (y1 + y2) / 2),
-        point(0, 0),
+        pointFrom(x - x2, y - (y1 + y2) / 2),
+        pointFrom(0, 0),
         angle,
       );
     case "nw":
-      return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
+      return pointRotateRads(pointFrom(x - x1, y - y1), pointFrom(0, 0), angle);
     case "ne":
-      return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
+      return pointRotateRads(pointFrom(x - x2, y - y1), pointFrom(0, 0), angle);
     case "sw":
-      return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
+      return pointRotateRads(pointFrom(x - x1, y - y2), pointFrom(0, 0), angle);
     case "se":
-      return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
+      return pointRotateRads(pointFrom(x - x2, y - y2), pointFrom(0, 0), angle);
     default:
       return [0, 0];
   }

+ 17 - 13
packages/excalidraw/element/resizeTest.ts

@@ -23,7 +23,7 @@ import { SIDE_RESIZING_THRESHOLD } from "../constants";
 import { isLinearElement } from "./typeChecks";
 import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
 import {
-  point,
+  pointFrom,
   pointOnLineSegment,
   pointRotateRads,
   type Radians,
@@ -92,16 +92,20 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
     if (!(isLinearElement(element) && element.points.length <= 2)) {
       const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
       const sides = getSelectionBorders(
-        point(x1 - SPACING, y1 - SPACING),
-        point(x2 + SPACING, y2 + SPACING),
-        point(cx, cy),
+        pointFrom(x1 - SPACING, y1 - SPACING),
+        pointFrom(x2 + SPACING, y2 + SPACING),
+        pointFrom(cx, cy),
         element.angle,
       );
 
       for (const [dir, side] of Object.entries(sides)) {
         // test to see if x, y are on the line segment
         if (
-          pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
+          pointOnLineSegment(
+            pointFrom(x, y),
+            side as LineSegment<Point>,
+            SPACING,
+          )
         ) {
           return dir as TransformHandleType;
         }
@@ -178,9 +182,9 @@ export const getTransformHandleTypeFromCoords = <
     const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
 
     const sides = getSelectionBorders(
-      point(x1 - SPACING, y1 - SPACING),
-      point(x2 + SPACING, y2 + SPACING),
-      point(cx, cy),
+      pointFrom(x1 - SPACING, y1 - SPACING),
+      pointFrom(x2 + SPACING, y2 + SPACING),
+      pointFrom(cx, cy),
       0 as Radians,
     );
 
@@ -188,7 +192,7 @@ export const getTransformHandleTypeFromCoords = <
       // test to see if x, y are on the line segment
       if (
         pointOnLineSegment(
-          point(scenePointerX, scenePointerY),
+          pointFrom(scenePointerX, scenePointerY),
           side as LineSegment<Point>,
           SPACING,
         )
@@ -265,10 +269,10 @@ const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
   center: Point,
   angle: Radians,
 ) => {
-  const topLeft = pointRotateRads(point(x1, y1), center, angle);
-  const topRight = pointRotateRads(point(x2, y1), center, angle);
-  const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
-  const bottomRight = pointRotateRads(point(x2, y2), center, angle);
+  const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
+  const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
+  const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
+  const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
 
   return {
     n: [topLeft, topRight],

+ 18 - 17
packages/excalidraw/element/routing.test.tsx

@@ -17,7 +17,7 @@ import type {
   ExcalidrawElbowArrowElement,
 } from "./types";
 import { ARROW_TYPE } from "../constants";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 
 const { h } = window;
 
@@ -32,8 +32,8 @@ describe("elbow arrow routing", () => {
     }) as ExcalidrawElbowArrowElement;
     scene.insertElement(arrow);
     mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
-      point(-45 - arrow.x, -100.1 - arrow.y),
-      point(45 - arrow.x, 99.9 - arrow.y),
+      pointFrom(-45 - arrow.x, -100.1 - arrow.y),
+      pointFrom(45 - arrow.x, 99.9 - arrow.y),
     ]);
     expect(arrow.points).toEqual([
       [0, 0],
@@ -69,7 +69,7 @@ describe("elbow arrow routing", () => {
       y: -100.1,
       width: 90,
       height: 200,
-      points: [point(0, 0), point(90, 200)],
+      points: [pointFrom(0, 0), pointFrom(90, 200)],
     }) as ExcalidrawElbowArrowElement;
     scene.insertElement(rectangle1);
     scene.insertElement(rectangle2);
@@ -81,7 +81,7 @@ describe("elbow arrow routing", () => {
     expect(arrow.startBinding).not.toBe(null);
     expect(arrow.endBinding).not.toBe(null);
 
-    mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
+    mutateElbowArrow(arrow, elementsMap, [pointFrom(0, 0), pointFrom(90, 200)]);
 
     expect(arrow.points).toEqual([
       [0, 0],
@@ -94,7 +94,16 @@ describe("elbow arrow routing", () => {
 
 describe("elbow arrow ui", () => {
   beforeEach(async () => {
+    localStorage.clear();
     await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+      button: 2,
+      clientX: 1,
+      clientY: 1,
+    });
+    const contextMenu = UI.queryContextMenu();
+    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
   });
 
   it("can follow bound shapes", async () => {
@@ -130,8 +139,8 @@ describe("elbow arrow ui", () => {
     expect(arrow.elbowed).toBe(true);
     expect(arrow.points).toEqual([
       [0, 0],
-      [35, 0],
-      [35, 200],
+      [45, 0],
+      [45, 200],
       [90, 200],
     ]);
   });
@@ -163,14 +172,6 @@ describe("elbow arrow ui", () => {
       h.state,
     )[0] as ExcalidrawArrowElement;
 
-    fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
-      button: 2,
-      clientX: 1,
-      clientY: 1,
-    });
-    const contextMenu = UI.queryContextMenu();
-    fireEvent.click(queryByTestId(contextMenu!, "stats")!);
-
     mouse.click(51, 51);
 
     const inputAngle = UI.queryStatsProperty("A")?.querySelector(
@@ -182,8 +183,8 @@ describe("elbow arrow ui", () => {
       [0, 0],
       [35, 0],
       [35, 90],
-      [25, 90],
-      [25, 165],
+      [35, 90], // Note that coordinates are rounded above!
+      [35, 165],
       [103, 165],
     ]);
   });

+ 67 - 37
packages/excalidraw/element/routing.ts

@@ -1,6 +1,6 @@
 import type { Radians } from "../../math";
 import {
-  point,
+  pointFrom,
   pointScaleFromOrigin,
   pointTranslate,
   vector,
@@ -36,11 +36,11 @@ import {
   HEADING_UP,
   vectorToHeading,
 } from "./heading";
+import type { ElementUpdate } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
 import { isBindableElement, isRectanguloidElement } from "./typeChecks";
 import type {
   ExcalidrawElbowArrowElement,
-  FixedPointBinding,
   NonDeletedSceneElementsMap,
   SceneElementsMap,
 } from "./types";
@@ -72,16 +72,48 @@ export const mutateElbowArrow = (
   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
   nextPoints: readonly LocalPoint[],
   offset?: Vector,
-  otherUpdates?: {
-    startBinding?: FixedPointBinding | null;
-    endBinding?: FixedPointBinding | null;
+  otherUpdates?: Omit<
+    ElementUpdate<ExcalidrawElbowArrowElement>,
+    "angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
+  >,
+  options?: {
+    isDragging?: boolean;
+    informMutation?: boolean;
   },
+) => {
+  const update = updateElbowArrow(
+    arrow,
+    elementsMap,
+    nextPoints,
+    offset,
+    options,
+  );
+  if (update) {
+    mutateElement(
+      arrow,
+      {
+        ...otherUpdates,
+        ...update,
+        angle: 0 as Radians,
+      },
+      options?.informMutation,
+    );
+  } else {
+    console.error("Elbow arrow cannot find a route");
+  }
+};
+
+export const updateElbowArrow = (
+  arrow: ExcalidrawElbowArrowElement,
+  elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
+  nextPoints: readonly LocalPoint[],
+  offset?: Vector,
   options?: {
     isDragging?: boolean;
     disableBinding?: boolean;
     informMutation?: boolean;
   },
-) => {
+): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
   const origStartGlobalPoint: GlobalPoint = pointTranslate(
     pointTranslate<LocalPoint, GlobalPoint>(
       nextPoints[0],
@@ -235,6 +267,8 @@ export const mutateElbowArrow = (
           BASE_PADDING,
         ),
     boundsOverlap,
+    hoveredStartElement && aabbForElement(hoveredStartElement),
+    hoveredEndElement && aabbForElement(hoveredEndElement),
   );
   const startDonglePosition = getDonglePosition(
     dynamicAABBs[0],
@@ -295,18 +329,10 @@ export const mutateElbowArrow = (
     startDongle && points.unshift(startGlobalPoint);
     endDongle && points.push(endGlobalPoint);
 
-    mutateElement(
-      arrow,
-      {
-        ...otherUpdates,
-        ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
-        angle: 0 as Radians,
-      },
-      options?.informMutation,
-    );
-  } else {
-    console.error("Elbow arrow cannot find a route");
+    return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
   }
+
+  return null;
 };
 
 const offsetFromHeading = (
@@ -475,7 +501,11 @@ const generateDynamicAABBs = (
   startDifference?: [number, number, number, number],
   endDifference?: [number, number, number, number],
   disableSideHack?: boolean,
+  startElementBounds?: Bounds | null,
+  endElementBounds?: Bounds | null,
 ): Bounds[] => {
+  const startEl = startElementBounds ?? a;
+  const endEl = endElementBounds ?? b;
   const [startUp, startRight, startDown, startLeft] = startDifference ?? [
     0, 0, 0, 0,
   ];
@@ -484,29 +514,29 @@ const generateDynamicAABBs = (
   const first = [
     a[0] > b[2]
       ? a[1] > b[3] || a[3] < b[1]
-        ? Math.min((a[0] + b[2]) / 2, a[0] - startLeft)
-        : (a[0] + b[2]) / 2
+        ? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
+        : (startEl[0] + endEl[2]) / 2
       : a[0] > b[0]
       ? a[0] - startLeft
       : common[0] - startLeft,
     a[1] > b[3]
       ? a[0] > b[2] || a[2] < b[0]
-        ? Math.min((a[1] + b[3]) / 2, a[1] - startUp)
-        : (a[1] + b[3]) / 2
+        ? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
+        : (startEl[1] + endEl[3]) / 2
       : a[1] > b[1]
       ? a[1] - startUp
       : common[1] - startUp,
     a[2] < b[0]
       ? a[1] > b[3] || a[3] < b[1]
-        ? Math.max((a[2] + b[0]) / 2, a[2] + startRight)
-        : (a[2] + b[0]) / 2
+        ? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
+        : (startEl[2] + endEl[0]) / 2
       : a[2] < b[2]
       ? a[2] + startRight
       : common[2] + startRight,
     a[3] < b[1]
       ? a[0] > b[2] || a[2] < b[0]
-        ? Math.max((a[3] + b[1]) / 2, a[3] + startDown)
-        : (a[3] + b[1]) / 2
+        ? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
+        : (startEl[3] + endEl[1]) / 2
       : a[3] < b[3]
       ? a[3] + startDown
       : common[3] + startDown,
@@ -514,29 +544,29 @@ const generateDynamicAABBs = (
   const second = [
     b[0] > a[2]
       ? b[1] > a[3] || b[3] < a[1]
-        ? Math.min((b[0] + a[2]) / 2, b[0] - endLeft)
-        : (b[0] + a[2]) / 2
+        ? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
+        : (endEl[0] + startEl[2]) / 2
       : b[0] > a[0]
       ? b[0] - endLeft
       : common[0] - endLeft,
     b[1] > a[3]
       ? b[0] > a[2] || b[2] < a[0]
-        ? Math.min((b[1] + a[3]) / 2, b[1] - endUp)
-        : (b[1] + a[3]) / 2
+        ? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
+        : (endEl[1] + startEl[3]) / 2
       : b[1] > a[1]
       ? b[1] - endUp
       : common[1] - endUp,
     b[2] < a[0]
       ? b[1] > a[3] || b[3] < a[1]
-        ? Math.max((b[2] + a[0]) / 2, b[2] + endRight)
-        : (b[2] + a[0]) / 2
+        ? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
+        : (endEl[2] + startEl[0]) / 2
       : b[2] < a[2]
       ? b[2] + endRight
       : common[2] + endRight,
     b[3] < a[1]
       ? b[0] > a[2] || b[2] < a[0]
-        ? Math.max((b[3] + a[1]) / 2, b[3] + endDown)
-        : (b[3] + a[1]) / 2
+        ? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
+        : (endEl[3] + startEl[1]) / 2
       : b[3] < a[3]
       ? b[3] + endDown
       : common[3] + endDown,
@@ -713,13 +743,13 @@ const getDonglePosition = (
 ): GlobalPoint => {
   switch (heading) {
     case HEADING_UP:
-      return point(p[0], bounds[1]);
+      return pointFrom(p[0], bounds[1]);
     case HEADING_RIGHT:
-      return point(bounds[2], p[1]);
+      return pointFrom(bounds[2], p[1]);
     case HEADING_DOWN:
-      return point(p[0], bounds[3]);
+      return pointFrom(p[0], bounds[3]);
   }
-  return point(bounds[0], p[1]);
+  return pointFrom(bounds[0], p[1]);
 };
 
 const estimateSegmentCount = (

+ 2 - 7
packages/excalidraw/element/sizeHelpers.ts

@@ -2,7 +2,7 @@ import type { ElementsMap, ExcalidrawElement } from "./types";
 import { mutateElement } from "./mutateElement";
 import { isFreeDrawElement, isLinearElement } from "./typeChecks";
 import { SHIFT_LOCKING_ANGLE } from "../constants";
-import type { AppState, Zoom } from "../types";
+import type { AppState, Offsets, Zoom } from "../types";
 import { getCommonBounds, getElementBounds } from "./bounds";
 import { viewportCoordsToSceneCoords } from "../utils";
 
@@ -67,12 +67,7 @@ export const isElementCompletelyInViewport = (
     scrollY: number;
   },
   elementsMap: ElementsMap,
-  padding?: Partial<{
-    top: number;
-    right: number;
-    bottom: number;
-    left: number;
-  }>,
+  padding?: Offsets,
 ) => {
   const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
   const topLeftSceneCoords = viewportCoordsToSceneCoords(

+ 4 - 3
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 };
 };
 

+ 2 - 2
packages/excalidraw/element/textWysiwyg.test.tsx

@@ -19,7 +19,7 @@ import type {
 import { API } from "../tests/helpers/api";
 import { getOriginalContainerHeightFromCache } from "./containerCache";
 import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -42,7 +42,7 @@ describe("textWysiwyg", () => {
         type: "line",
         width: 100,
         height: 0,
-        points: [point(0, 0), point(100, 0)],
+        points: [pointFrom(0, 0), pointFrom(100, 0)],
       });
       const textSize = 20;
       const text = API.createElement({

+ 1 - 1
packages/excalidraw/element/textWysiwyg.tsx

@@ -247,7 +247,7 @@ export const textWysiwyg = ({
 
       // adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
       const padding = !isSafari
-        ? Math.ceil(updatedTextElement.fontSize / 2)
+        ? Math.ceil(updatedTextElement.fontSize / appState.zoom.value / 2)
         : 0;
 
       // Make sure text editor height doesn't go beyond viewport

+ 3 - 3
packages/excalidraw/element/transformHandles.ts

@@ -19,7 +19,7 @@ import {
   isIOS,
 } from "../constants";
 import type { Radians } from "../../math";
-import { point, pointRotateRads } from "../../math";
+import { pointFrom, pointRotateRads } from "../../math";
 
 export type TransformHandleDirection =
   | "n"
@@ -95,8 +95,8 @@ const generateTransformHandle = (
   angle: Radians,
 ): TransformHandle => {
   const [xx, yy] = pointRotateRads(
-    point(x + width / 2, y + height / 2),
-    point(cx, cy),
+    pointFrom(x + width / 2, y + height / 2),
+    pointFrom(cx, cy),
     angle,
   );
   return [xx - width / 2, yy - height / 2, width, height];

+ 5 - 2
packages/excalidraw/element/typeChecks.ts

@@ -320,9 +320,12 @@ export const getDefaultRoundnessTypeForElement = (
 };
 
 export const isFixedPointBinding = (
-  binding: PointBinding,
+  binding: PointBinding | FixedPointBinding,
 ): binding is FixedPointBinding => {
-  return binding.fixedPoint != null;
+  return (
+    Object.hasOwn(binding, "fixedPoint") &&
+    (binding as FixedPointBinding).fixedPoint != null
+  );
 };
 
 // TODO: Move this to @excalidraw/math

+ 12 - 7
packages/excalidraw/element/types.ts

@@ -202,6 +202,7 @@ export type ExcalidrawElement =
   | ExcalidrawGenericElement
   | ExcalidrawTextElement
   | ExcalidrawLinearElement
+  | ExcalidrawArrowElement
   | ExcalidrawFreeDrawElement
   | ExcalidrawImageElement
   | ExcalidrawFrameElement
@@ -277,15 +278,19 @@ export type PointBinding = {
   elementId: ExcalidrawBindableElement["id"];
   focus: number;
   gap: number;
-  // Represents the fixed point binding information in form of a vertical and
-  // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
-  // gives the user selected fixed point by multiplying the bound element width
-  // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
-  // bound element-local point coordinate.
-  fixedPoint: FixedPoint | null;
 };
 
-export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
+export type FixedPointBinding = Merge<
+  PointBinding,
+  {
+    // Represents the fixed point binding information in form of a vertical and
+    // horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
+    // gives the user selected fixed point by multiplying the bound element width
+    // with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
+    // bound element-local point coordinate.
+    fixedPoint: FixedPoint;
+  }
+>;
 
 export type Arrowhead =
   | "arrow"

+ 6 - 5
packages/excalidraw/fonts/ExcalidrawFont.ts

@@ -1,4 +1,8 @@
-import { stringToBase64, toByteString } from "../data/encode";
+import {
+  base64ToArrayBuffer,
+  stringToBase64,
+  toByteString,
+} from "../data/encode";
 import { LOCAL_FONT_PROTOCOL } from "./metadata";
 import loadWoff2 from "./wasm/woff2.loader";
 import loadHbSubset from "./wasm/hb-subset.loader";
@@ -49,10 +53,7 @@ export class ExcalidrawFont implements Font {
 
       // it's dataurl (server), the font is inlined as base64, no need to fetch
       if (url.protocol === "data:") {
-        const arrayBuffer = Buffer.from(
-          url.toString().split(",")[1],
-          "base64",
-        ).buffer;
+        const arrayBuffer = base64ToArrayBuffer(url.toString().split(",")[1]);
 
         const base64 = await ExcalidrawFont.subsetGlyphsByCodePoints(
           arrayBuffer,

BIN
packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2


BIN
packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2


BIN
packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2


BIN
packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2


BIN
packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2


BIN
packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2


BIN
packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2


+ 8 - 8
packages/excalidraw/fonts/index.ts

@@ -24,14 +24,14 @@ import Cascadia from "./assets/CascadiaCode-Regular.woff2";
 import ComicShanns from "./assets/ComicShanns-Regular.woff2";
 import LiberationSans from "./assets/LiberationSans-Regular.woff2";
 
-import LilitaLatin from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
-import LilitaLatinExt from "https://fonts.gstatic.com/s/lilitaone/v15/i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
-
-import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
-import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
-import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
-import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
-import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
+import LilitaLatin from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2";
+import LilitaLatinExt from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2";
+
+import NunitoLatin from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2";
+import NunitoLatinExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2";
+import NunitoCyrilic from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2";
+import NunitoCyrilicExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2";
+import NunitoVietnamese from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2";
 
 export class Fonts {
   // it's ok to track fonts across multiple instances only once, so let's use

+ 4 - 4
packages/excalidraw/frame.ts

@@ -29,7 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
 import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
 import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
 import type { ReadonlySetLike } from "./utility-types";
-import { isPointWithinBounds, point } from "../math";
+import { isPointWithinBounds, pointFrom } from "../math";
 
 // --------------------------- Frame State ------------------------------------
 export const bindElementsToFramesAfterDuplication = (
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
   const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
 
   return isPointWithinBounds(
-    point(fx1, fy1),
-    point(cursorCoords.x, cursorCoords.y),
-    point(fx2, fy2),
+    pointFrom(fx1, fy1),
+    pointFrom(cursorCoords.x, cursorCoords.y),
+    pointFrom(fx2, fy2),
   );
 };
 

+ 8 - 0
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 on canvas..."
+  },
   "buttons": {
     "clearReset": "Reset the canvas",
     "exportJSON": "Export to file",
@@ -297,6 +304,7 @@
     "shapes": "Shapes"
   },
   "hints": {
+    "dismissSearch": "Escape to dismiss search",
     "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
     "linearElement": "Click to start multiple points, drag for single line",
     "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",

+ 46 - 5
packages/excalidraw/renderer/interactiveScene.ts

@@ -30,8 +30,12 @@ import {
   shouldShowBoundingBox,
 } from "../element/transformHandles";
 import { arrayToMap, throttleRAF } from "../utils";
-import type { InteractiveCanvasAppState } from "../types";
-import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
+import {
+  DEFAULT_TRANSFORM_HANDLE_SPACING,
+  FRAME_STYLE,
+  THEME,
+} from "../constants";
+import { type InteractiveCanvasAppState } from "../types";
 
 import { renderSnaps } from "../renderer/renderSnaps";
 
@@ -48,7 +52,6 @@ import {
 } from "./helpers";
 import oc from "open-color";
 import {
-  isArrowElement,
   isElbowArrow,
   isFrameLikeElement,
   isLinearElement,
@@ -901,7 +904,6 @@ const _renderInteractiveScene = ({
             // Elbow arrow elements cannot be selected when bound on either end
             (
               isSingleLinearElementSelected &&
-              isArrowElement(element) &&
               isElbowArrow(element) &&
               (element.startBinding || element.endBinding)
             )
@@ -1066,9 +1068,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({

+ 23 - 15
packages/excalidraw/renderer/renderSnaps.ts

@@ -1,4 +1,4 @@
-import { point, type GlobalPoint, type LocalPoint } from "../../math";
+import { pointFrom, type GlobalPoint, type LocalPoint } from "../../math";
 import { THEME } from "../constants";
 import type { PointSnapLine, PointerSnapLine } from "../snapping";
 import type { InteractiveCanvasAppState } from "../types";
@@ -140,27 +140,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
     // (1)
     if (!appState.zenModeEnabled) {
       drawLine(
-        point(from[0], from[1] - FULL),
-        point(from[0], from[1] + FULL),
+        pointFrom(from[0], from[1] - FULL),
+        pointFrom(from[0], from[1] + FULL),
         context,
       );
     }
 
     // (3)
     drawLine(
-      point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
-      point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
+      pointFrom(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
+      pointFrom(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
       context,
     );
     drawLine(
-      point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
-      point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
+      pointFrom(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
+      pointFrom(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
       context,
     );
 
     if (!appState.zenModeEnabled) {
       // (4)
-      drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
+      drawLine(
+        pointFrom(to[0], to[1] - FULL),
+        pointFrom(to[0], to[1] + FULL),
+        context,
+      );
 
       // (2)
       drawLine(from, to, context);
@@ -170,27 +174,31 @@ const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
     // (1)
     if (!appState.zenModeEnabled) {
       drawLine(
-        point(from[0] - FULL, from[1]),
-        point(from[0] + FULL, from[1]),
+        pointFrom(from[0] - FULL, from[1]),
+        pointFrom(from[0] + FULL, from[1]),
         context,
       );
     }
 
     // (3)
     drawLine(
-      point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
-      point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
+      pointFrom(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
+      pointFrom(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
       context,
     );
     drawLine(
-      point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
-      point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
+      pointFrom(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
+      pointFrom(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
       context,
     );
 
     if (!appState.zenModeEnabled) {
       // (4)
-      drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
+      drawLine(
+        pointFrom(to[0] - FULL, to[1]),
+        pointFrom(to[0] + FULL, to[1]),
+        context,
+      );
 
       // (2)
       drawLine(from, to, context);

+ 1 - 0
packages/excalidraw/renderer/staticSvgScene.ts

@@ -421,6 +421,7 @@ const renderElementToSvg = (
           image.setAttribute("width", "100%");
           image.setAttribute("height", "100%");
           image.setAttribute("href", fileData.dataURL);
+          image.setAttribute("preserveAspectRatio", "none");
 
           symbol.appendChild(image);
 

+ 2 - 2
packages/excalidraw/scene/Shape.ts

@@ -24,7 +24,7 @@ import {
 import { canChangeRoundness } from "./comparisons";
 import type { EmbedsValidationStatus } from "../types";
 import {
-  point,
+  pointFrom,
   pointDistance,
   type GlobalPoint,
   type LocalPoint,
@@ -408,7 +408,7 @@ export const _generateElementShape = (
       // initial position to it
       const points = element.points.length
         ? element.points
-        : [point<LocalPoint>(0, 0)];
+        : [pointFrom<LocalPoint>(0, 0)];
 
       if (isElbowArrow(element)) {
         shape = [

+ 10 - 0
packages/excalidraw/scene/export.ts

@@ -185,6 +185,11 @@ export const exportToCanvas = async (
     exportingFrame ?? null,
     appState.frameRendering ?? null,
   );
+  // for canvas export, don't clip if exporting a specific frame as it would
+  // clip the corners of the content
+  if (exportingFrame) {
+    frameRendering.clip = false;
+  }
 
   const elementsForRender = prepareElementsForRender({
     elements,
@@ -351,6 +356,11 @@ export const exportToSvg = async (
     }) rotate(${frame.angle} ${cx} ${cy})"
           width="${frame.width}"
           height="${frame.height}"
+          ${
+            exportingFrame
+              ? ""
+              : `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
+          }
           >
           </rect>
         </clipPath>`;

+ 17 - 3
packages/excalidraw/scene/scroll.ts

@@ -1,4 +1,4 @@
-import type { AppState, PointerCoords, Zoom } from "../types";
+import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
 import type { ExcalidrawElement } from "../element/types";
 import {
   getCommonBounds,
@@ -31,14 +31,28 @@ export const centerScrollOn = ({
   scenePoint,
   viewportDimensions,
   zoom,
+  offsets,
 }: {
   scenePoint: PointerCoords;
   viewportDimensions: { height: number; width: number };
   zoom: Zoom;
+  offsets?: Offsets;
 }) => {
+  let scrollX =
+    (viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
+    scenePoint.x;
+
+  scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
+
+  let scrollY =
+    (viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
+    scenePoint.y;
+
+  scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
+
   return {
-    scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x,
-    scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y,
+    scrollX,
+    scrollY,
   };
 };
 

+ 26 - 26
packages/excalidraw/shapes.tsx

@@ -1,6 +1,6 @@
 import {
   isPoint,
-  point,
+  pointFrom,
   pointDistance,
   pointFromPair,
   pointRotateRads,
@@ -167,15 +167,15 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
         ? getClosedCurveShape<Point>(
             element,
             roughShape,
-            point<Point>(element.x, element.y),
+            pointFrom<Point>(element.x, element.y),
             element.angle,
-            point(cx, cy),
+            pointFrom(cx, cy),
           )
         : getCurveShape<Point>(
             roughShape,
-            point<Point>(element.x, element.y),
+            pointFrom<Point>(element.x, element.y),
             element.angle,
-            point(cx, cy),
+            pointFrom(cx, cy),
           );
     }
 
@@ -186,7 +186,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
       const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
       return getFreedrawShape(
         element,
-        point(cx, cy),
+        pointFrom(cx, cy),
         shouldTestInside(element),
       );
     }
@@ -233,7 +233,7 @@ export const getControlPointsForBezierCurve = <
   }
 
   const ops = getCurvePathOps(shape[0]);
-  let currentP = point<P>(0, 0);
+  let currentP = pointFrom<P>(0, 0);
   let index = 0;
   let minDistance = Infinity;
   let controlPoints: P[] | null = null;
@@ -249,9 +249,9 @@ export const getControlPointsForBezierCurve = <
     }
     if (op === "bcurveTo") {
       const p0 = currentP;
-      const p1 = point<P>(data[0], data[1]);
-      const p2 = point<P>(data[2], data[3]);
-      const p3 = point<P>(data[4], data[5]);
+      const p1 = pointFrom<P>(data[0], data[1]);
+      const p2 = pointFrom<P>(data[2], data[3]);
+      const p3 = pointFrom<P>(data[4], data[5]);
       const distance = pointDistance(p3, endPoint);
       if (distance < minDistance) {
         minDistance = distance;
@@ -279,7 +279,7 @@ export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
     p0[idx] * Math.pow(t, 3);
   const tx = equation(t, 0);
   const ty = equation(t, 1);
-  return point(tx, ty);
+  return pointFrom(tx, ty);
 };
 
 const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
@@ -301,12 +301,12 @@ const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
       controlPoints[3],
       t,
     );
-    pointsOnCurve.push(point(p[0], p[1]));
+    pointsOnCurve.push(pointFrom(p[0], p[1]));
     t -= 0.05;
   }
   if (pointsOnCurve.length) {
     if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
-      pointsOnCurve.push(point(endPoint[0], endPoint[1]));
+      pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
     }
   }
   return pointsOnCurve;
@@ -393,24 +393,24 @@ export const aabbForElement = (
     midY: element.y + element.height / 2,
   };
 
-  const center = point(bbox.midX, bbox.midY);
+  const center = pointFrom(bbox.midX, bbox.midY);
   const [topLeftX, topLeftY] = pointRotateRads(
-    point(bbox.minX, bbox.minY),
+    pointFrom(bbox.minX, bbox.minY),
     center,
     element.angle,
   );
   const [topRightX, topRightY] = pointRotateRads(
-    point(bbox.maxX, bbox.minY),
+    pointFrom(bbox.maxX, bbox.minY),
     center,
     element.angle,
   );
   const [bottomRightX, bottomRightY] = pointRotateRads(
-    point(bbox.maxX, bbox.maxY),
+    pointFrom(bbox.maxX, bbox.maxY),
     center,
     element.angle,
   );
   const [bottomLeftX, bottomLeftY] = pointRotateRads(
-    point(bbox.minX, bbox.maxY),
+    pointFrom(bbox.minX, bbox.maxY),
     center,
     element.angle,
   );
@@ -442,14 +442,14 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
   p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
 
 export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
-  pointInsideBounds(point(a[0], a[1]), b) ||
-  pointInsideBounds(point(a[2], a[1]), b) ||
-  pointInsideBounds(point(a[2], a[3]), b) ||
-  pointInsideBounds(point(a[0], a[3]), b) ||
-  pointInsideBounds(point(b[0], b[1]), a) ||
-  pointInsideBounds(point(b[2], b[1]), a) ||
-  pointInsideBounds(point(b[2], b[3]), a) ||
-  pointInsideBounds(point(b[0], b[3]), a);
+  pointInsideBounds(pointFrom(a[0], a[1]), b) ||
+  pointInsideBounds(pointFrom(a[2], a[1]), b) ||
+  pointInsideBounds(pointFrom(a[2], a[3]), b) ||
+  pointInsideBounds(pointFrom(a[0], a[3]), b) ||
+  pointInsideBounds(pointFrom(b[0], b[1]), a) ||
+  pointInsideBounds(pointFrom(b[2], b[1]), a) ||
+  pointInsideBounds(pointFrom(b[2], b[3]), a) ||
+  pointInsideBounds(pointFrom(b[0], b[3]), a);
 
 export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
   if (

+ 89 - 65
packages/excalidraw/snapping.ts

@@ -1,6 +1,6 @@
 import type { InclusiveRange } from "../math";
 import {
-  point,
+  pointFrom,
   pointRotateRads,
   rangeInclusive,
   rangeIntersection,
@@ -228,52 +228,52 @@ export const getElementsCorners = (
       !boundingBoxCorners
     ) {
       const leftMid = pointRotateRads<GlobalPoint>(
-        point(x1, y1 + halfHeight),
-        point(cx, cy),
+        pointFrom(x1, y1 + halfHeight),
+        pointFrom(cx, cy),
         element.angle,
       );
       const topMid = pointRotateRads<GlobalPoint>(
-        point(x1 + halfWidth, y1),
-        point(cx, cy),
+        pointFrom(x1 + halfWidth, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const rightMid = pointRotateRads<GlobalPoint>(
-        point(x2, y1 + halfHeight),
-        point(cx, cy),
+        pointFrom(x2, y1 + halfHeight),
+        pointFrom(cx, cy),
         element.angle,
       );
       const bottomMid = pointRotateRads<GlobalPoint>(
-        point(x1 + halfWidth, y2),
-        point(cx, cy),
+        pointFrom(x1 + halfWidth, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
-      const center = point<GlobalPoint>(cx, cy);
+      const center = pointFrom<GlobalPoint>(cx, cy);
 
       result = omitCenter
         ? [leftMid, topMid, rightMid, bottomMid]
         : [leftMid, topMid, rightMid, bottomMid, center];
     } else {
       const topLeft = pointRotateRads<GlobalPoint>(
-        point(x1, y1),
-        point(cx, cy),
+        pointFrom(x1, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const topRight = pointRotateRads<GlobalPoint>(
-        point(x2, y1),
-        point(cx, cy),
+        pointFrom(x2, y1),
+        pointFrom(cx, cy),
         element.angle,
       );
       const bottomLeft = pointRotateRads<GlobalPoint>(
-        point(x1, y2),
-        point(cx, cy),
+        pointFrom(x1, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
       const bottomRight = pointRotateRads<GlobalPoint>(
-        point(x2, y2),
-        point(cx, cy),
+        pointFrom(x2, y2),
+        pointFrom(cx, cy),
         element.angle,
       );
-      const center = point<GlobalPoint>(cx, cy);
+      const center = pointFrom<GlobalPoint>(cx, cy);
 
       result = omitCenter
         ? [topLeft, topRight, bottomLeft, bottomRight]
@@ -287,18 +287,18 @@ export const getElementsCorners = (
     const width = maxX - minX;
     const height = maxY - minY;
 
-    const topLeft = point<GlobalPoint>(minX, minY);
-    const topRight = point<GlobalPoint>(maxX, minY);
-    const bottomLeft = point<GlobalPoint>(minX, maxY);
-    const bottomRight = point<GlobalPoint>(maxX, maxY);
-    const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
+    const topLeft = pointFrom<GlobalPoint>(minX, minY);
+    const topRight = pointFrom<GlobalPoint>(maxX, minY);
+    const bottomLeft = pointFrom<GlobalPoint>(minX, maxY);
+    const bottomRight = pointFrom<GlobalPoint>(maxX, maxY);
+    const center = pointFrom<GlobalPoint>(minX + width / 2, minY + height / 2);
 
     result = omitCenter
       ? [topLeft, topRight, bottomLeft, bottomRight]
       : [topLeft, topRight, bottomLeft, bottomRight, center];
   }
 
-  return result.map((p) => point(round(p[0]), round(p[1])));
+  return result.map((p) => pointFrom(round(p[0]), round(p[1])));
 };
 
 const getReferenceElements = (
@@ -375,8 +375,11 @@ export const getVisibleGaps = (
         horizontalGaps.push({
           startBounds,
           endBounds,
-          startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
-          endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
+          startSide: [
+            pointFrom(startMaxX, startMinY),
+            pointFrom(startMaxX, startMaxY),
+          ],
+          endSide: [pointFrom(endMinX, endMinY), pointFrom(endMinX, endMaxY)],
           length: endMinX - startMaxX,
           overlap: rangeIntersection(
             rangeInclusive(startMinY, startMaxY),
@@ -415,8 +418,11 @@ export const getVisibleGaps = (
         verticalGaps.push({
           startBounds,
           endBounds,
-          startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
-          endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
+          startSide: [
+            pointFrom(startMinX, startMaxY),
+            pointFrom(startMaxX, startMaxY),
+          ],
+          endSide: [pointFrom(endMinX, endMinY), pointFrom(endMaxX, endMinY)],
           length: endMinY - startMaxY,
           overlap: rangeIntersection(
             rangeInclusive(startMinX, startMaxX),
@@ -832,7 +838,7 @@ const createPointSnapLines = (
         }
         snapsX[key].push(
           ...snap.points.map((p) =>
-            point<GlobalPoint>(round(p[0]), round(p[1])),
+            pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
           ),
         );
       }
@@ -849,7 +855,7 @@ const createPointSnapLines = (
         }
         snapsY[key].push(
           ...snap.points.map((p) =>
-            point<GlobalPoint>(round(p[0]), round(p[1])),
+            pointFrom<GlobalPoint>(round(p[0]), round(p[1])),
           ),
         );
       }
@@ -863,7 +869,7 @@ const createPointSnapLines = (
         points: dedupePoints(
           points
             .map((p) => {
-              return point<GlobalPoint>(Number(key), p[1]);
+              return pointFrom<GlobalPoint>(Number(key), p[1]);
             })
             .sort((a, b) => a[1] - b[1]),
         ),
@@ -876,7 +882,7 @@ const createPointSnapLines = (
           points: dedupePoints(
             points
               .map((p) => {
-                return point<GlobalPoint>(p[0], Number(key));
+                return pointFrom<GlobalPoint>(p[0], Number(key));
               })
               .sort((a, b) => a[0] - b[0]),
           ),
@@ -940,16 +946,16 @@ const createGapSnapLines = (
               type: "gap",
               direction: "horizontal",
               points: [
-                point(gapSnap.gap.startSide[0][0], gapLineY),
-                point(minX, gapLineY),
+                pointFrom(gapSnap.gap.startSide[0][0], gapLineY),
+                pointFrom(minX, gapLineY),
               ],
             },
             {
               type: "gap",
               direction: "horizontal",
               points: [
-                point(maxX, gapLineY),
-                point(gapSnap.gap.endSide[0][0], gapLineY),
+                pointFrom(maxX, gapLineY),
+                pointFrom(gapSnap.gap.endSide[0][0], gapLineY),
               ],
             },
           );
@@ -966,16 +972,16 @@ const createGapSnapLines = (
               type: "gap",
               direction: "vertical",
               points: [
-                point(gapLineX, gapSnap.gap.startSide[0][1]),
-                point(gapLineX, minY),
+                pointFrom(gapLineX, gapSnap.gap.startSide[0][1]),
+                pointFrom(gapLineX, minY),
               ],
             },
             {
               type: "gap",
               direction: "vertical",
               points: [
-                point(gapLineX, maxY),
-                point(gapLineX, gapSnap.gap.endSide[0][1]),
+                pointFrom(gapLineX, maxY),
+                pointFrom(gapLineX, gapSnap.gap.endSide[0][1]),
               ],
             },
           );
@@ -991,12 +997,15 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "horizontal",
-              points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
+              points: [
+                pointFrom(startMaxX, gapLineY),
+                pointFrom(endMinX, gapLineY),
+              ],
             },
             {
               type: "gap",
               direction: "horizontal",
-              points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
+              points: [pointFrom(endMaxX, gapLineY), pointFrom(minX, gapLineY)],
             },
           );
         }
@@ -1011,12 +1020,18 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "horizontal",
-              points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
+              points: [
+                pointFrom(maxX, gapLineY),
+                pointFrom(startMinX, gapLineY),
+              ],
             },
             {
               type: "gap",
               direction: "horizontal",
-              points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
+              points: [
+                pointFrom(startMaxX, gapLineY),
+                pointFrom(endMinX, gapLineY),
+              ],
             },
           );
         }
@@ -1031,12 +1046,18 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "vertical",
-              points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
+              points: [
+                pointFrom(gapLineX, maxY),
+                pointFrom(gapLineX, startMinY),
+              ],
             },
             {
               type: "gap",
               direction: "vertical",
-              points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
+              points: [
+                pointFrom(gapLineX, startMaxY),
+                pointFrom(gapLineX, endMinY),
+              ],
             },
           );
         }
@@ -1051,12 +1072,15 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "vertical",
-              points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
+              points: [
+                pointFrom(gapLineX, startMaxY),
+                pointFrom(gapLineX, endMinY),
+              ],
             },
             {
               type: "gap",
               direction: "vertical",
-              points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
+              points: [pointFrom(gapLineX, endMaxY), pointFrom(gapLineX, minY)],
             },
           );
         }
@@ -1070,7 +1094,7 @@ const createGapSnapLines = (
       return {
         ...gapSnapLine,
         points: gapSnapLine.points.map((p) =>
-          point(round(p[0]), round(p[1])),
+          pointFrom(round(p[0]), round(p[1])),
         ) as PointPair,
       };
     }),
@@ -1120,35 +1144,35 @@ export const snapResizingElements = (
   if (transformHandle) {
     switch (transformHandle) {
       case "e": {
-        selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
+        selectionSnapPoints.push(pointFrom(maxX, minY), pointFrom(maxX, maxY));
         break;
       }
       case "w": {
-        selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
+        selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(minX, maxY));
         break;
       }
       case "n": {
-        selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
+        selectionSnapPoints.push(pointFrom(minX, minY), pointFrom(maxX, minY));
         break;
       }
       case "s": {
-        selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
+        selectionSnapPoints.push(pointFrom(minX, maxY), pointFrom(maxX, maxY));
         break;
       }
       case "ne": {
-        selectionSnapPoints.push(point(maxX, minY));
+        selectionSnapPoints.push(pointFrom(maxX, minY));
         break;
       }
       case "nw": {
-        selectionSnapPoints.push(point(minX, minY));
+        selectionSnapPoints.push(pointFrom(minX, minY));
         break;
       }
       case "se": {
-        selectionSnapPoints.push(point(maxX, maxY));
+        selectionSnapPoints.push(pointFrom(maxX, maxY));
         break;
       }
       case "sw": {
-        selectionSnapPoints.push(point(minX, maxY));
+        selectionSnapPoints.push(pointFrom(minX, maxY));
         break;
       }
     }
@@ -1191,10 +1215,10 @@ export const snapResizingElements = (
   );
 
   const corners: GlobalPoint[] = [
-    point(x1, y1),
-    point(x1, y2),
-    point(x2, y1),
-    point(x2, y2),
+    pointFrom(x1, y1),
+    pointFrom(x1, y2),
+    pointFrom(x2, y1),
+    pointFrom(x2, y2),
   ];
 
   getPointSnaps(
@@ -1231,7 +1255,7 @@ export const snapNewElement = (
   }
 
   const selectionSnapPoints: GlobalPoint[] = [
-    point(origin.x + dragOffset.x, origin.y + dragOffset.y),
+    pointFrom(origin.x + dragOffset.x, origin.y + dragOffset.y),
   ];
 
   const snapDistance = getSnapDistance(app.state.zoom.value);
@@ -1331,7 +1355,7 @@ export const getSnapLinesAtPointer = (
 
         verticalSnapLines.push({
           type: "pointer",
-          points: [corner, point(corner[0], pointer.y)],
+          points: [corner, pointFrom(corner[0], pointer.y)],
           direction: "vertical",
         });
 
@@ -1347,7 +1371,7 @@ export const getSnapLinesAtPointer = (
 
         horizontalSnapLines.push({
           type: "pointer",
-          points: [corner, point(pointer.x, corner[1])],
+          points: [corner, pointFrom(pointer.x, corner[1])],
           direction: "horizontal",
         });
 

+ 51 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -794,6 +794,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
     "left": 30,
     "top": 40,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -836,6 +837,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -866,6 +868,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,
@@ -999,6 +1002,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1041,6 +1045,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1068,6 +1073,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1214,6 +1220,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1256,6 +1263,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1283,6 +1291,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1544,6 +1553,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1586,6 +1596,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1613,6 +1624,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1874,6 +1886,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -1916,6 +1929,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -1943,6 +1957,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -2089,6 +2104,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2131,6 +2147,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2158,6 +2175,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -2328,6 +2346,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2370,6 +2389,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2397,6 +2417,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0_copy": true,
   },
@@ -2628,6 +2649,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -2670,6 +2692,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -2699,6 +2722,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -2996,6 +3020,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3038,6 +3063,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3065,6 +3091,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -3470,6 +3497,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3512,6 +3540,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3539,6 +3568,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -3792,6 +3822,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -3834,6 +3865,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -3861,6 +3893,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -4114,6 +4147,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   },
   "collaborators": Map {},
   "contextMenu": null,
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -4156,6 +4190,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -4185,6 +4220,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -5299,6 +5335,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
     "left": -17,
     "top": -7,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -5341,6 +5378,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -5370,6 +5408,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -6425,6 +6464,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
     "left": -17,
     "top": -7,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -6467,6 +6507,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -6496,6 +6537,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -7359,6 +7401,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
     "left": -19,
     "top": -9,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -7401,6 +7444,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -7431,6 +7475,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -8270,6 +8315,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "left": -17,
     "top": -7,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -8312,6 +8358,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -8339,6 +8386,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9163,6 +9211,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
     "left": 80,
     "top": 90,
   },
+  "croppingElement": null,
   "currentChartType": "bar",
   "currentHoveredFontFamily": null,
   "currentItemArrowType": "round",
@@ -9205,6 +9254,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "gridStep": 5,
   "height": 100,
   "isBindingEnabled": true,
+  "isCropping": false,
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
@@ -9235,6 +9285,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },

+ 49 - 0
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         Ctrl+Shift+E
       </div>
     </button>
+    <button
+      aria-label="Find on canvas"
+      class="dropdown-menu-item dropdown-menu-item-base"
+      data-testid="search-menu-button"
+      title="Find on canvas"
+    >
+      <div
+        class="dropdown-menu-item__icon"
+      >
+        <svg
+          aria-hidden="true"
+          class=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+        >
+          <g
+            stroke-width="1.5"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
+            />
+            <path
+              d="M21 21l-6 -6"
+            />
+          </g>
+        </svg>
+      </div>
+      <div
+        class="dropdown-menu-item__text"
+      >
+        Find on canvas
+      </div>
+      <div
+        class="dropdown-menu-item__shortcut"
+      >
+        Ctrl+F
+      </div>
+    </button>
     <button
       aria-label="Help"
       class="dropdown-menu-item dropdown-menu-item-base"

File diff suppressed because it is too large
+ 0 - 0
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


File diff suppressed because it is too large
+ 126 - 0
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


File diff suppressed because it is too large
+ 126 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap


+ 9 - 4
packages/excalidraw/tests/binding.test.tsx

@@ -7,7 +7,7 @@ import { API } from "./helpers/api";
 import { KEYS } from "../keys";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import { arrayToMap } from "../utils";
-import { point } from "../../math";
+import { pointFrom } from "../../math";
 
 const { h } = window;
 
@@ -32,7 +32,12 @@ describe("element binding", () => {
       y: 0,
       width: 100,
       height: 1,
-      points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)],
+      points: [
+        pointFrom(0, 0),
+        pointFrom(0, 0),
+        pointFrom(100, 0),
+        pointFrom(100, 0),
+      ],
     });
     API.setElements([rect, arrow]);
     expect(arrow.startBinding).toBe(null);
@@ -310,7 +315,7 @@ describe("element binding", () => {
     const arrow1 = API.createElement({
       type: "arrow",
       id: "arrow1",
-      points: [point(0, 0), point(0, -87.45777932247563)],
+      points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
       startBinding: {
         elementId: "rectangle1",
         focus: 0.2,
@@ -328,7 +333,7 @@ describe("element binding", () => {
     const arrow2 = API.createElement({
       type: "arrow",
       id: "arrow2",
-      points: [point(0, 0), point(0, -87.45777932247563)],
+      points: [pointFrom(0, 0), pointFrom(0, -87.45777932247563)],
       startBinding: {
         elementId: "text1",
         focus: 0.2,

Some files were not shown because too many files changed in this diff