diff --git a/.eslintignore b/.eslintignore index 8578fb7d4..8b4f458de 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,4 @@ public/workbox packages/excalidraw/types examples/**/public dev-dist +coverage diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx index 5c075de86..6337fe7ac 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/utils/export.mdx @@ -20,7 +20,7 @@ exportToCanvas({
  getDimensions,
  files,
  exportPadding?: number;
-}: ExportOpts +}: ExportOpts | Name | Type | Default | Description | diff --git a/examples/excalidraw/components/App.scss b/examples/excalidraw/components/ExampleApp.scss similarity index 100% rename from examples/excalidraw/components/App.scss rename to examples/excalidraw/components/ExampleApp.scss diff --git a/examples/excalidraw/components/App.tsx b/examples/excalidraw/components/ExampleApp.tsx similarity index 99% rename from examples/excalidraw/components/App.tsx rename to examples/excalidraw/components/ExampleApp.tsx index 7cfd8a05a..1e296786e 100644 --- a/examples/excalidraw/components/App.tsx +++ b/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, diff --git a/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx b/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx index 40af9f0cc..e9fa3bb23 100644 --- a/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx +++ b/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"; diff --git a/examples/excalidraw/with-script-in-browser/index.tsx b/examples/excalidraw/with-script-in-browser/index.tsx index e8584d7ca..00daaddc8 100644 --- a/examples/excalidraw/with-script-in-browser/index.tsx +++ b/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"; diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 0076eead1..9b7eadff8 100644 --- a/excalidraw-app/App.tsx +++ b/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), + ); } }; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 04bddedef..fd8d779a7 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{ /> )} + diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index b610ab7b5..471167989 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/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 }, ); diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 468126b2b..c8ac5b19a 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/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) { diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 7ec74e181..02f153d8c 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -130,15 +130,6 @@ <% } %> - - - `. [#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) diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 35fabcaf9..83b0ad529 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/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; /** whether to fit content to viewport (beyond >100%) */ fitToViewport: boolean; /** zoom content to cover X of the viewport, when fitToViewport=true */ viewportZoomFactor?: number; + minZoom?: number; + maxZoom?: number; }) => { + viewportZoomFactor = clamp(viewportZoomFactor, MIN_ZOOM, MAX_ZOOM); + const [x1, y1, x2, y2] = bounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; - let newZoomValue; - let scrollX; - let scrollY; + const canvasOffsetLeft = canvasOffsets?.left ?? 0; + const canvasOffsetTop = canvasOffsets?.top ?? 0; + const canvasOffsetRight = canvasOffsets?.right ?? 0; + const canvasOffsetBottom = canvasOffsets?.bottom ?? 0; + + const effectiveCanvasWidth = + appState.width - canvasOffsetLeft - canvasOffsetRight; + const effectiveCanvasHeight = + appState.height - canvasOffsetTop - canvasOffsetBottom; + + let adjustedZoomValue; if (fitToViewport) { const commonBoundsWidth = x2 - x1; const commonBoundsHeight = y2 - y1; - newZoomValue = + adjustedZoomValue = Math.min( - appState.width / commonBoundsWidth, - appState.height / commonBoundsHeight, - ) * clamp(viewportZoomFactor, 0.1, 1); - - newZoomValue = getNormalizedZoom(newZoomValue); - - let appStateWidth = appState.width; - - if (appState.openSidebar) { - const sidebarDOMElem = document.querySelector( - ".sidebar", - ) as HTMLElement | null; - const sidebarWidth = sidebarDOMElem?.offsetWidth ?? 0; - const isRTL = document.documentElement.getAttribute("dir") === "rtl"; - - appStateWidth = !isRTL - ? appState.width - sidebarWidth - : appState.width + sidebarWidth; - } - - scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; - scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; + effectiveCanvasWidth / commonBoundsWidth, + effectiveCanvasHeight / commonBoundsHeight, + ) * viewportZoomFactor; } else { - newZoomValue = zoomValueToFitBoundsOnViewport( + adjustedZoomValue = zoomValueToFitBoundsOnViewport( bounds, { - width: appState.width, - height: appState.height, + width: effectiveCanvasWidth, + height: effectiveCanvasHeight, }, viewportZoomFactor, ); - - const centerScroll = centerScrollOn({ - scenePoint: { x: centerX, y: centerY }, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: { value: newZoomValue }, - }); - - scrollX = centerScroll.scrollX; - scrollY = centerScroll.scrollY; } + const newZoomValue = getNormalizedZoom( + clamp(roundToStep(adjustedZoomValue, ZOOM_STEP, "floor"), minZoom, maxZoom), + ); + + const centerScroll = centerScrollOn({ + scenePoint: { x: centerX, y: centerY }, + viewportDimensions: { + width: appState.width, + height: appState.height, + }, + offsets: canvasOffsets, + zoom: { value: newZoomValue }, + }); + return { appState: { ...appState, - scrollX, - scrollY, + scrollX: centerScroll.scrollX, + scrollY: centerScroll.scrollY, zoom: { value: newZoomValue }, }, storeAction: StoreAction.NONE, @@ -349,25 +345,34 @@ export const zoomToFitBounds = ({ }; export const zoomToFit = ({ + canvasOffsets, targetElements, appState, fitToViewport, viewportZoomFactor, + minZoom, + maxZoom, }: { + canvasOffsets?: Offsets; targetElements: readonly ExcalidrawElement[]; appState: Readonly; /** 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 && diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index f19ab981f..0637e304f 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/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" }} /> ), }); diff --git a/packages/excalidraw/actions/actionFlip.test.tsx b/packages/excalidraw/actions/actionFlip.test.tsx new file mode 100644 index 000000000..5ee587b20 --- /dev/null +++ b/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(); + + 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(); + }); + + 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); + }); +}); diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index a6dad249f..6b75b8fac 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/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; }; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 0fa705f23..72ff8896b 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/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; diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts new file mode 100644 index 000000000..02a58cd2b --- /dev/null +++ b/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( + `.${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, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 092060425..eff5de297 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; + +export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index a5c3bad66..025d91037 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -51,7 +51,8 @@ export type ShortcutName = > | "saveScene" | "imageExport" - | "commandPalette"; + | "commandPalette" + | "searchMenu"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -112,6 +113,7 @@ const shortcutMap: Record = { saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], toggleShortcuts: [getShortcutKey("?")], + searchMenu: [getShortcutKey("CtrlOrCmd+F")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 2d0275bb3..15364d21f 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -137,7 +137,8 @@ export type ActionName = | "wrapTextInContainer" | "commandPalette" | "autoResize" - | "elementStats"; + | "elementStats" + | "searchMenu"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -191,7 +192,8 @@ export interface Action { | "history" | "menu" | "collab" - | "hyperlink"; + | "hyperlink" + | "search_menu"; action?: string; predicate?: ( appState: Readonly, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index cdf2e2301..7c858e92a 100644 --- a/packages/excalidraw/appState.ts +++ b/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 = < diff --git a/packages/excalidraw/charts.ts b/packages/excalidraw/charts.ts index 87d2b34fe..6e379c30a 100644 --- a/packages/excalidraw/charts.ts +++ b/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)], }); }); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 530d55802..565821e4e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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(null!); @@ -549,6 +553,7 @@ class App extends React.Component { public scene: Scene; public fonts: Fonts; public renderer: Renderer; + public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -556,7 +561,7 @@ class App extends React.Component { 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 { 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 { 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 { 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 { 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 { 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( + new Map( + startEndElements + .filter((x) => x != null) + .map( + (el) => + [el!.id, el] as [ + string, + Ordered, + ], + ), + ), + ), + [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 { if (opts.fitToContent) { this.scrollToContent(newElements, { fitToContent: true, + canvasOffsets: this.getEditorUIOffsets(), }); } }; @@ -3529,7 +3588,7 @@ class App extends React.Component { target: | ExcalidrawElement | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), - opts?: + opts?: ( | { fitToContent?: boolean; fitToViewport?: never; @@ -3546,7 +3605,12 @@ class App extends React.Component { viewportZoomFactor?: number; animate?: boolean; duration?: number; - }, + } + ) & { + minZoom?: number; + maxZoom?: number; + canvasOffsets?: Offsets; + }, ) => { this.cancelInProgressAnimation?.(); @@ -3559,10 +3623,13 @@ class App extends React.Component { 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 { 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 { }, ); - 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 { animate: true, duration: 300, fitToContent: true, - viewportZoomFactor: 0.8, + canvasOffsets: this.getEditorUIOffsets(), }); } @@ -3994,6 +4055,7 @@ class App extends React.Component { this.scrollToContent(nextNode, { animate: true, duration: 300, + canvasOffsets: this.getEditorUIOffsets(), }); } } @@ -4426,6 +4488,7 @@ class App extends React.Component { this.scrollToContent(firstNode, { animate: true, duration: 300, + canvasOffsets: this.getEditorUIOffsets(), }); } } @@ -4871,7 +4934,7 @@ class App extends React.Component { 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 { 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 { 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 { 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 { 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 { // 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 { { points: [ ...points, - point(scenePointerX - rx, scenePointerY - ry), + pointFrom(scenePointerX - rx, scenePointerY - ry), ], }, false, @@ -5566,7 +5632,7 @@ class App extends React.Component { 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 { this.scene.getNonDeletedElementsMap(), [ ...points.slice(0, -1), - point( + pointFrom( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, ), @@ -5633,7 +5699,7 @@ class App extends React.Component { { points: [ ...points.slice(0, -1), - point( + pointFrom( lastCommittedX + dxFromLastCommitted, lastCommittedY + dyFromLastCommitted, ), @@ -5862,8 +5928,8 @@ class App extends React.Component { }; 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 { 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 { 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 { } 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 { simulatePressure, locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, - points: [point(0, 0)], + points: [pointFrom(0, 0)], pressures: simulatePressure ? [] : [event.pressure], }); @@ -7277,7 +7361,10 @@ class App extends React.Component { 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 { }; }); mutateElement(element, { - points: [...element.points, point(0, 0)], + points: [...element.points, pointFrom(0, 0)], }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, @@ -7635,8 +7722,8 @@ class App extends React.Component { ) { 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 { mutateElement( newElement, { - points: [...points, point(dx, dy)], + points: [...points, pointFrom(dx, dy)], pressures, }, false, @@ -8060,7 +8147,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points, point(dx, dy)], + points: [...points, pointFrom(dx, dy)], }, false, ); @@ -8068,7 +8155,7 @@ class App extends React.Component { mutateElbowArrow( newElement, elementsMap, - [...points.slice(0, -1), point(dx, dy)], + [...points.slice(0, -1), pointFrom(dx, dy)], vector(0, 0), undefined, { @@ -8080,7 +8167,7 @@ class App extends React.Component { mutateElement( newElement, { - points: [...points.slice(0, -1), point(dx, dy)], + points: [...points.slice(0, -1), pointFrom(dx, dy)], }, false, ); @@ -8394,9 +8481,9 @@ class App extends React.Component { : [...newElement.pressures, childEvent.pressure]; mutateElement(newElement, { - points: [...points, point(dx, dy)], + points: [...points, pointFrom(dx, dy)], pressures, - lastCommittedPoint: point(dx, dy), + lastCommittedPoint: pointFrom(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -8443,7 +8530,7 @@ class App extends React.Component { mutateElement(newElement, { points: [ ...newElement.points, - point( + pointFrom( pointerCoords.x - newElement.x, pointerCoords.y - newElement.y, ), @@ -8771,8 +8858,8 @@ class App extends React.Component { 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) { diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 36c9a9a68..e732acfb5 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon"; import { SHAPES } from "../../shapes"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { actionClearCanvas, actionLink } from "../../actions"; +import { + actionClearCanvas, + actionLink, + actionToggleSearchMenu, +} from "../../actions"; import { jotaiStore } from "../../jotai"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import type { CommandPaletteItem } from "./types"; @@ -382,6 +386,15 @@ function CommandPaletteInner({ } }, }, + { + label: t("search.title"), + category: DEFAULT_CATEGORIES.app, + icon: searchIcon, + viewMode: true, + perform: () => { + actionManager.executeAction(actionToggleSearchMenu); + }, + }, { label: t("labels.changeStroke"), keywords: ["color", "outline"], diff --git a/packages/excalidraw/components/DefaultSidebar.tsx b/packages/excalidraw/components/DefaultSidebar.tsx index 78b03007f..70b0c2d6c 100644 --- a/packages/excalidraw/components/DefaultSidebar.tsx +++ b/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) => { +const DefaultTabTriggers = ({ children }: { children: React.ReactNode }) => { const { DefaultSidebarTabTriggersTunnel } = useTunnels(); return ( - {children} + {children} ); }; @@ -65,17 +68,21 @@ export const DefaultSidebar = Object.assign( const { DefaultSidebarTabTriggersTunnel } = useTunnels(); + const isForceDocked = appState.openSidebar?.tab === CANVAS_SEARCH_TAB; + return ( { @@ -85,26 +92,22 @@ export const DefaultSidebar = Object.assign( > - {rest.__fallback && ( -
- {t("toolBar.library")} -
- )} - + + + {searchIcon} + + + {LibraryIcon} + + +
+ + + {children}
diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 5892e6ac1..886c7bb7a 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { label={t("stats.fullTitle")} shortcuts={[getShortcutKey("Alt+/")]} /> + )} + diff --git a/packages/excalidraw/components/SearchMenu.scss b/packages/excalidraw/components/SearchMenu.scss new file mode 100644 index 000000000..ae6bb5647 --- /dev/null +++ b/packages/excalidraw/components/SearchMenu.scss @@ -0,0 +1,110 @@ +@import "open-color/open-color"; + +.excalidraw { + .layer-ui__search { + flex: 1 0 auto; + display: flex; + flex-direction: column; + padding: 8px 0 0 0; + } + + .layer-ui__search-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0.75rem; + .ExcTextField { + flex: 1 0 auto; + } + + .ExcTextField__input { + background-color: #f5f5f9; + @at-root .excalidraw.theme--dark#{&} { + background-color: #31303b; + } + + border-radius: var(--border-radius-md); + border: 0; + + input::placeholder { + font-size: 0.9rem; + } + } + } + + .layer-ui__search-count { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 0 8px; + margin: 0 0.75rem 0.25rem 0.75rem; + font-size: 0.8em; + + .result-nav { + display: flex; + + .result-nav-btn { + width: 36px; + height: 36px; + --button-border: transparent; + + &:active { + background-color: var(--color-surface-high); + } + + &:first { + margin-right: 4px; + } + } + } + } + + .layer-ui__search-result-container { + overflow-y: auto; + flex: 1 1 0; + display: flex; + flex-direction: column; + + gap: 0.125rem; + } + + .layer-ui__result-item { + display: flex; + align-items: center; + min-height: 2rem; + flex: 0 0 auto; + padding: 0.25rem 0.75rem; + cursor: pointer; + border: 1px solid transparent; + outline: none; + + margin: 0 0.75rem; + border-radius: var(--border-radius-md); + + .text-icon { + width: 1rem; + height: 1rem; + margin-right: 0.75rem; + } + + .preview-text { + flex: 1; + max-height: 48px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + } + + &:hover { + background-color: var(--color-surface-high); + } + &:active { + border-color: var(--color-primary); + } + + &.active { + background-color: var(--color-surface-high); + } + } +} diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx new file mode 100644 index 000000000..36922b0a5 --- /dev/null +++ b/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(""); +export const searchItemInFocusAtom = atom(null); + +const SEARCH_DEBOUNCE = 350; + +type SearchMatchItem = { + textElement: ExcalidrawTextElement; + searchQuery: SearchQuery; + index: number; + preview: { + indexInSearchQuery: number; + previewText: string; + moreBefore: boolean; + moreAfter: boolean; + }; + matchedLines: { + offsetX: number; + offsetY: number; + width: number; + height: number; + }[]; +}; + +type SearchMatches = { + nonce: number | null; + items: SearchMatchItem[]; +}; + +type SearchQuery = string & { _brand: "SearchQuery" }; + +export const SearchMenu = () => { + const app = useApp(); + const setAppState = useExcalidrawSetAppState(); + + const searchInputRef = useRef(null); + + const [inputValue, setInputValue] = useAtom(searchQueryAtom, jotaiScope); + const searchQuery = inputValue.trim() as SearchQuery; + + const [isSearching, setIsSearching] = useState(false); + + const [searchMatches, setSearchMatches] = useState({ + nonce: null, + items: [], + }); + const searchedQueryRef = useRef(null); + const lastSceneNonceRef = useRef(undefined); + + const [focusIndex, setFocusIndex] = useAtom( + searchItemInFocusAtom, + jotaiScope, + ); + const elementsMap = app.scene.getNonDeletedElementsMap(); + + useEffect(() => { + if (isSearching) { + return; + } + if ( + searchQuery !== searchedQueryRef.current || + app.scene.getSceneNonce() !== lastSceneNonceRef.current + ) { + searchedQueryRef.current = null; + handleSearch(searchQuery, app, (matchItems, index) => { + setSearchMatches({ + nonce: randomInteger(), + items: matchItems, + }); + searchedQueryRef.current = searchQuery; + lastSceneNonceRef.current = app.scene.getSceneNonce(); + setAppState({ + searchMatches: matchItems.map((searchMatch) => ({ + id: searchMatch.textElement.id, + focus: false, + matchedLines: searchMatch.matchedLines, + })), + }); + }); + } + }, [ + isSearching, + searchQuery, + elementsMap, + app, + setAppState, + setFocusIndex, + lastSceneNonceRef, + ]); + + const goToNextItem = () => { + if (searchMatches.items.length > 0) { + setFocusIndex((focusIndex) => { + if (focusIndex === null) { + return 0; + } + + return (focusIndex + 1) % searchMatches.items.length; + }); + } + }; + + const goToPreviousItem = () => { + if (searchMatches.items.length > 0) { + setFocusIndex((focusIndex) => { + if (focusIndex === null) { + return 0; + } + + return focusIndex - 1 < 0 + ? searchMatches.items.length - 1 + : focusIndex - 1; + }); + } + }; + + useEffect(() => { + setAppState((state) => { + return { + searchMatches: state.searchMatches.map((match, index) => { + if (index === focusIndex) { + return { ...match, focus: true }; + } + return { ...match, focus: false }; + }), + }; + }); + }, [focusIndex, setAppState]); + + useEffect(() => { + if (searchMatches.items.length > 0 && focusIndex !== null) { + const match = searchMatches.items[focusIndex]; + + if (match) { + const 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[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 ( +
+
+ { + setInputValue(value); + setIsSearching(true); + const searchQuery = value.trim() as SearchQuery; + handleSearch(searchQuery, app, (matchItems, index) => { + setSearchMatches({ + nonce: randomInteger(), + items: matchItems, + }); + setFocusIndex(index); + searchedQueryRef.current = searchQuery; + lastSceneNonceRef.current = app.scene.getSceneNonce(); + setAppState({ + searchMatches: matchItems.map((searchMatch) => ({ + id: searchMatch.textElement.id, + focus: false, + matchedLines: searchMatch.matchedLines, + })), + }); + + setIsSearching(false); + }); + }} + selectOnRender + /> +
+ +
+ {searchMatches.items.length > 0 && ( + <> + {focusIndex !== null && focusIndex > -1 ? ( +
+ {focusIndex + 1} / {matchCount} +
+ ) : ( +
{matchCount}
+ )} +
+ + +
+ + )} + + {searchMatches.items.length === 0 && + searchQuery && + searchedQueryRef.current && ( +
{t("search.noMatch")}
+ )} +
+ + +
+ ); +}; + +const ListItem = (props: { + preview: SearchMatchItem["preview"]; + searchQuery: SearchQuery; + highlighted: boolean; + onClick?: () => void; +}) => { + const preview = [ + props.preview.moreBefore ? "..." : "", + props.preview.previewText.slice(0, props.preview.indexInSearchQuery), + props.preview.previewText.slice( + props.preview.indexInSearchQuery, + props.preview.indexInSearchQuery + props.searchQuery.length, + ), + props.preview.previewText.slice( + props.preview.indexInSearchQuery + props.searchQuery.length, + ), + props.preview.moreAfter ? "..." : "", + ]; + + return ( +
{ + if (props.highlighted) { + ref?.scrollIntoView({ behavior: "auto", block: "nearest" }); + } + }} + > +
+ {preview.flatMap((text, idx) => ( + {idx === 2 ? {text} : text} + ))} +
+
+ ); +}; + +interface MatchListProps { + matches: SearchMatches; + onItemClick: (index: number) => void; + focusIndex: number | null; + searchQuery: SearchQuery; +} + +const MatchListBase = (props: MatchListProps) => { + return ( +
+ {props.matches.items.map((searchMatch, index) => ( + props.onItemClick(index)} + /> + ))} +
+ ); +}; + +const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => { + return ( + prevProps.matches.nonce === nextProps.matches.nonce && + prevProps.focusIndex === nextProps.focusIndex + ); +}; + +const MatchList = memo(MatchListBase, areEqual); + +const getMatchPreview = ( + text: string, + index: number, + searchQuery: SearchQuery, +) => { + const WORDS_BEFORE = 2; + const WORDS_AFTER = 5; + + const substrBeforeQuery = text.slice(0, index); + const wordsBeforeQuery = substrBeforeQuery.split(/\s+/); + // text = "small", query = "mall", not complete before + // text = "small", query = "smal", complete before + const isQueryCompleteBefore = substrBeforeQuery.endsWith(" "); + const startWordIndex = + wordsBeforeQuery.length - + WORDS_BEFORE - + 1 - + (isQueryCompleteBefore ? 0 : 1); + let wordsBeforeAsString = + wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") + + (isQueryCompleteBefore ? " " : ""); + + const MAX_ALLOWED_CHARS = 20; + + wordsBeforeAsString = + wordsBeforeAsString.length > MAX_ALLOWED_CHARS + ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS) + : wordsBeforeAsString; + + const substrAfterQuery = text.slice(index + searchQuery.length); + const wordsAfter = substrAfterQuery.split(/\s+/); + // text = "small", query = "mall", complete after + // text = "small", query = "smal", not complete after + const isQueryCompleteAfter = !substrAfterQuery.startsWith(" "); + const numberOfWordsToTake = isQueryCompleteAfter + ? WORDS_AFTER + 1 + : WORDS_AFTER; + const wordsAfterAsString = + (isQueryCompleteAfter ? "" : " ") + + wordsAfter.slice(0, numberOfWordsToTake).join(" "); + + return { + indexInSearchQuery: wordsBeforeAsString.length, + previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString, + moreBefore: startWordIndex > 0, + moreAfter: wordsAfter.length > numberOfWordsToTake, + }; +}; + +const normalizeWrappedText = ( + wrappedText: string, + originalText: string, +): string => { + const wrappedLines = wrappedText.split("\n"); + const normalizedLines: string[] = []; + let originalIndex = 0; + + for (let i = 0; i < wrappedLines.length; i++) { + let currentLine = wrappedLines[i]; + const nextLine = wrappedLines[i + 1]; + + if (nextLine) { + const nextLineIndexInOriginal = originalText.indexOf( + nextLine, + originalIndex, + ); + + if (nextLineIndexInOriginal > currentLine.length + originalIndex) { + let j = nextLineIndexInOriginal - (currentLine.length + originalIndex); + + while (j > 0) { + currentLine += " "; + j--; + } + } + } + + normalizedLines.push(currentLine); + originalIndex = originalIndex + currentLine.length; + } + + return normalizedLines.join("\n"); +}; + +const getMatchedLines = ( + textElement: ExcalidrawTextElement, + searchQuery: SearchQuery, + index: number, +) => { + const normalizedText = normalizeWrappedText( + textElement.text, + textElement.originalText, + ); + + const lines = normalizedText.split("\n"); + + const lineIndexRanges = []; + let currentIndex = 0; + let lineNumber = 0; + + for (const line of lines) { + const startIndex = currentIndex; + const endIndex = startIndex + line.length - 1; + + lineIndexRanges.push({ + line, + startIndex, + endIndex, + lineNumber, + }); + + // Move to the next line's start index + currentIndex = endIndex + 1; + lineNumber++; + } + + let startIndex = index; + let remainingQuery = textElement.originalText.slice( + index, + index + searchQuery.length, + ); + const matchedLines: { + offsetX: number; + offsetY: number; + width: number; + height: number; + }[] = []; + + for (const lineIndexRange of lineIndexRanges) { + if (remainingQuery === "") { + break; + } + + if ( + startIndex >= lineIndexRange.startIndex && + startIndex <= lineIndexRange.endIndex + ) { + const matchCapacity = lineIndexRange.endIndex + 1 - startIndex; + const textToStart = lineIndexRange.line.slice( + 0, + startIndex - lineIndexRange.startIndex, + ); + + const matchedWord = remainingQuery.slice(0, matchCapacity); + remainingQuery = remainingQuery.slice(matchCapacity); + + const offset = measureText( + textToStart, + getFontString(textElement), + textElement.lineHeight, + true, + ); + + // measureText returns a non-zero width for the empty string + // which is not what we're after here, hence the check and the correction + if (textToStart === "") { + offset.width = 0; + } + + if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) { + const lineLength = measureText( + lineIndexRange.line, + getFontString(textElement), + textElement.lineHeight, + true, + ); + + const spaceToStart = + textElement.textAlign === "center" + ? (textElement.width - lineLength.width) / 2 + : textElement.width - lineLength.width; + offset.width += spaceToStart; + } + + const { width, height } = measureText( + matchedWord, + getFontString(textElement), + textElement.lineHeight, + ); + + const offsetX = offset.width; + const offsetY = lineIndexRange.lineNumber * offset.height; + + matchedLines.push({ + offsetX, + offsetY, + width, + height, + }); + + startIndex += matchCapacity; + } + } + + return matchedLines; +}; + +const escapeSpecialCharacters = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&"); +}; + +const handleSearch = debounce( + ( + searchQuery: SearchQuery, + app: AppClassProperties, + cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void, + ) => { + if (!searchQuery || searchQuery === "") { + cb([], null); + return; + } + + const elements = app.scene.getNonDeletedElements(); + const texts = elements.filter((el) => + isTextElement(el), + ) as ExcalidrawTextElement[]; + + texts.sort((a, b) => a.y - b.y); + + const matchItems: SearchMatchItem[] = []; + + const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi"); + + for (const textEl of texts) { + let match = null; + const text = textEl.originalText; + + while ((match = regex.exec(text)) !== null) { + const preview = getMatchPreview(text, match.index, searchQuery); + const matchedLines = getMatchedLines(textEl, searchQuery, match.index); + + if (matchedLines.length > 0) { + matchItems.push({ + textElement: textEl, + searchQuery, + preview, + index: match.index, + matchedLines, + }); + } + } + } + + const visibleIds = new Set( + app.visibleElements.map((visibleElement) => visibleElement.id), + ); + + const focusIndex = + matchItems.findIndex((matchItem) => + visibleIds.has(matchItem.textElement.id), + ) ?? null; + + cb(matchItems, focusIndex); + }, + SEARCH_DEBOUNCE, +); diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index f36200585..0d1a65e91 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/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, diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index d0f001663..3285faf6a 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/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, ); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index 8e7671685..47cc3fe25 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/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 = diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index f281931c8..fc981ce6c 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/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, ); diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index f6cf16708..a6a443b9b 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/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, ); diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index 952c97592..c46cd2fe8 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/packages/excalidraw/components/TextField.scss @@ -3,16 +3,29 @@ .excalidraw { --ExcTextField--color: var(--color-on-surface); --ExcTextField--label-color: var(--color-on-surface); - --ExcTextField--background: transparent; + --ExcTextField--background: var(--color-surface-low); --ExcTextField--readonly--background: var(--color-surface-high); --ExcTextField--readonly--color: var(--color-on-surface); - --ExcTextField--border: var(--color-border-outline); + --ExcTextField--border: var(--color-gray-20); --ExcTextField--readonly--border: var(--color-border-outline-variant); --ExcTextField--border-hover: var(--color-brand-hover); --ExcTextField--border-active: var(--color-brand-active); --ExcTextField--placeholder: var(--color-border-outline-variant); .ExcTextField { + position: relative; + + svg { + position: absolute; + top: 50%; // 50% is not exactly in the center of the input + transform: translateY(-50%); + left: 0.75rem; + width: 1.25rem; + height: 1.25rem; + color: var(--color-gray-40); + z-index: 1; + } + &--fullWidth { width: 100%; flex-grow: 1; @@ -37,7 +50,6 @@ display: flex; flex-direction: row; align-items: center; - padding: 0 1rem; height: 3rem; @@ -45,6 +57,8 @@ border: 1px solid var(--ExcTextField--border); border-radius: 0.5rem; + padding: 0 0.75rem; + &:not(&--readonly) { &:hover { border-color: var(--ExcTextField--border-hover); @@ -80,10 +94,6 @@ width: 100%; - &::placeholder { - color: var(--ExcTextField--placeholder); - } - &:not(:focus) { &:hover { background-color: initial; @@ -105,5 +115,9 @@ } } } + + &--hasIcon .ExcTextField__input { + padding-left: 2.5rem; + } } } diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index 463ea2c2d..c5bdc8260 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -21,7 +21,9 @@ type TextFieldProps = { fullWidth?: boolean; selectOnRender?: boolean; + icon?: React.ReactNode; label?: string; + className?: string; placeholder?: string; isRedacted?: boolean; } & ({ value: string } | { defaultValue: string }); @@ -37,6 +39,8 @@ export const TextField = forwardRef( selectOnRender, onKeyDown, isRedacted = false, + icon, + className, ...rest }, ref, @@ -47,6 +51,8 @@ export const TextField = forwardRef( useLayoutEffect(() => { if (selectOnRender) { + // focusing first is needed because vitest/jsdom + innerRef.current?.focus(); innerRef.current?.select(); } }, [selectOnRender]); @@ -56,14 +62,16 @@ export const TextField = forwardRef( return (
{ innerRef.current?.focus(); }} > -
{label}
+ {icon} + {label &&
{label}
}
{ diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts index 3f451a230..bc470422c 100644 --- a/packages/excalidraw/components/hyperlink/helpers.ts +++ b/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), + ); }; diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index f4a3a94c2..17be3e7fc 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon( , tablerIconProps, ); + +export const upIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 26ef26000..bb3059db5 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -15,6 +15,7 @@ import { LoadIcon, MoonIcon, save, + searchIcon, SunIcon, TrashIcon, usersIcon, @@ -27,6 +28,7 @@ import { actionLoadScene, actionSaveToActiveFile, actionShortcuts, + actionToggleSearchMenu, actionToggleTheme, } from "../../actions"; import clsx from "clsx"; @@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten import { THEME } from "../../constants"; import type { Theme } from "../../element/types"; import { trackEvent } from "../../analytics"; - import "./DefaultItems.scss"; export const LoadScene = () => { @@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => { }; CommandPalette.displayName = "CommandPalette"; +export const SearchMenu = (opts?: { className?: string }) => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + + return ( + { + actionManager.executeAction(actionToggleSearchMenu); + }} + shortcut={getShortcutFromShortcutName("searchMenu")} + aria-label={t("search.title")} + className={opts?.className} + > + {t("search.title")} + + ); +}; +SearchMenu.displayName = "SearchMenu"; + export const Help = () => { const { t } = useI18n(); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 9601807f6..d43847b79 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -113,6 +113,7 @@ export const ENV = { export const CLASSES = { SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", + SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", }; /** @@ -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", diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 0ed6a7544..69c28afda 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -144,9 +144,9 @@ --border-radius-md: 0.375rem; --border-radius-lg: 0.5rem; - --color-surface-high: hsl(244, 100%, 97%); - --color-surface-mid: hsl(240 25% 96%); - --color-surface-low: hsl(240 25% 94%); + --color-surface-high: #f1f0ff; + --color-surface-mid: #f2f2f7; + --color-surface-low: #ececf4; --color-surface-lowest: #ffffff; --color-on-surface: #1b1b1f; --color-brand-hover: #5753d0; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 921118eb1..967de923e 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/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, "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, "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, "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", }, ], diff --git a/packages/excalidraw/data/encode.ts b/packages/excalidraw/data/encode.ts index 1d5a59556..104ab1ca8 100644 --- a/packages/excalidraw/data/encode.ts +++ b/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 // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index f77dce7d7..0aa6e0f49 100644 --- a/packages/excalidraw/data/restore.ts +++ b/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) { diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index d930cb923..3fecf957b 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/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, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 6573abd0d..d1fab0db9 100644 --- a/packages/excalidraw/data/transform.ts +++ b/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(); diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index fe820723f..d0faa4269 100644 --- a/packages/excalidraw/element/binding.ts +++ b/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( + pointFrom( 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( + const center = pointFrom( 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( - 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(x + width / 2 - 0.1, y + height / 2 - 0.1); + const center = pointFrom( + 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( + const globalMidPoint = pointFrom( bindableElement.x + bindableElement.width / 2, bindableElement.y + bindableElement.height / 2, ); - const global = point( + const global = pointFrom( 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( + pointFrom( element.x + element.width / 2, element.y + element.height / 2, ), @@ -2228,7 +2238,7 @@ const getGlobalFixedPoints = ( arrow.startBinding.fixedPoint, startElement as ExcalidrawBindableElement, ) - : point( + : pointFrom( 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( + : pointFrom( arrow.x + arrow.points[arrow.points.length - 1][0], arrow.y + arrow.points[arrow.points.length - 1][1], ); diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index f5ca0e901..ffa89bb3e 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/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(0, 0), - point(67.33984375, 92.48828125), - point(-102.7890625, 52.15625), + pointFrom(0, 0), + pointFrom(67.33984375, 92.48828125), + pointFrom(-102.7890625, 52.15625), ], } as ExcalidrawLinearElement; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 16f431855..6fedd4113 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/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[] = []; @@ -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(data[0], data[1]); - const _p2 = point(data[2], data[3]); - const _p3 = point(data[4], data[5]); + const _p1 = pointFrom(data[0], data[1]); + const _p2 = pointFrom(data[2], data[3]); + const _p3 = pointFrom(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( - 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, ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 7eafa7dfa..a1593d2f6 100644 --- a/packages/excalidraw/element/collision.ts +++ b/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 = ({ 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, @@ -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 = ( y: number, textShape: GeometricShape | null, ): boolean => { - return !!textShape && isPointInShape(point(x, y), textShape); + return !!textShape && isPointInShape(pointFrom(x, y), textShape); }; diff --git a/packages/excalidraw/element/cropElement.ts b/packages/excalidraw/element/cropElement.ts index 2913e80fa..4806c738b 100644 --- a/packages/excalidraw/element/cropElement.ts +++ b/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); diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index f77aecf2f..f773a7a06 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/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 diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index e262c7933..eada31a5b 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/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 = + /^ { @@ -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( + `

`, + ), + 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]; diff --git a/packages/excalidraw/element/flowchart.ts b/packages/excalidraw/element/flowchart.ts index cc174bfa9..8c14bc01a 100644 --- a/packages/excalidraw/element/flowchart.ts +++ b/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, }); diff --git a/packages/excalidraw/element/heading.ts b/packages/excalidraw/element/heading.ts index b22316c6a..c17a077fc 100644 --- a/packages/excalidraw/element/heading.ts +++ b/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; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 7607a2e16..0d1db7733 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/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) { 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, ); diff --git a/packages/excalidraw/element/newElement.test.ts b/packages/excalidraw/element/newElement.test.ts index 770d0d987..6a74e58f0 100644 --- a/packages/excalidraw/element/newElement.test.ts +++ b/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(1, 2), point(3, 4)], + points: [pointFrom(1, 2), pointFrom(3, 4)], }); const copy = duplicateElement(null, new Map(), element); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index e21d36617..55aa011f7 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/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 => { diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 3f3f8ef1e..08ca5543f 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/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(x1, y1); + let newTopLeft = pointFrom(x1, y1); if (["n", "w", "nw"].includes(transformHandleType)) { - newTopLeft = point( + newTopLeft = pointFrom( startBottomRight[0] - Math.abs(nextWidth), startBottomRight[1] - Math.abs(nextHeight), ); } if (transformHandleType === "ne") { const bottomLeft = [startTopLeft[0], startBottomRight[1]]; - newTopLeft = point( + newTopLeft = pointFrom( bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight), ); } if (transformHandleType === "sw") { const topRight = [startBottomRight[0], startTopLeft[1]]; - newTopLeft = point( + newTopLeft = pointFrom( 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( + const rotatedTopLeft = pointRotateRads( + newTopLeft, + pointFrom(cx, cy), + angle, + ); + const newCenter = pointFrom( 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(x1, y1); - const startBottomRight = point(x2, y2); + const startTopLeft = pointFrom(x1, y1); + const startBottomRight = pointFrom(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 = { - 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]; } diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index c363f6180..5fcae5335 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/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 = ( 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, SPACING) + pointOnLineSegment( + pointFrom(x, y), + side as LineSegment, + 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, SPACING, ) @@ -265,10 +269,10 @@ const getSelectionBorders = ( 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], diff --git a/packages/excalidraw/element/routing.test.tsx b/packages/excalidraw/element/routing.test.tsx index 9381541a5..fb6b23f28 100644 --- a/packages/excalidraw/element/routing.test.tsx +++ b/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(); + + 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], ]); }); diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 07f62ca82..c8b1c2d43 100644 --- a/packages/excalidraw/element/routing.ts +++ b/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, + "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 | null => { const origStartGlobalPoint: GlobalPoint = pointTranslate( pointTranslate( 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 = ( diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index b10f31f32..f633789a9 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/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( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9fb4766b6..9abebc356 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -284,16 +284,17 @@ export const measureText = ( text: string, font: FontString, lineHeight: ExcalidrawTextElement["lineHeight"], + forceAdvanceWidth?: true, ) => { - text = text + const _text = text .split("\n") // replace empty lines with single space because leading/trailing empty // lines would be stripped from computation .map((x) => x || " ") .join("\n"); const fontSize = parseFloat(font); - const height = getTextHeight(text, fontSize, lineHeight); - const width = getTextWidth(text, font); + const height = getTextHeight(_text, fontSize, lineHeight); + const width = getTextWidth(_text, font, forceAdvanceWidth); return { width, height }; }; diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index 98063f05b..ea57ca190 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/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({ diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 2281a0cc3..23778cb7b 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/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 diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 173c9fdc9..ccd68b282 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/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]; diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index 5ba089ab0..6bb4269f8 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/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 diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 98624875b..c804d8525 100644 --- a/packages/excalidraw/element/types.ts +++ b/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; +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" diff --git a/packages/excalidraw/fonts/ExcalidrawFont.ts b/packages/excalidraw/fonts/ExcalidrawFont.ts index 682ae7394..51d6578c6 100644 --- a/packages/excalidraw/fonts/ExcalidrawFont.ts +++ b/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, diff --git a/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2 b/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2 new file mode 100644 index 000000000..51e6f53a8 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2 b/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2 new file mode 100644 index 000000000..1fe1443ba Binary files /dev/null and b/packages/excalidraw/fonts/assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 new file mode 100644 index 000000000..b9b1c20c0 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 new file mode 100644 index 000000000..c9f8bb017 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 new file mode 100644 index 000000000..6b0697140 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 new file mode 100644 index 000000000..c21a6ed08 Binary files /dev/null and b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2 differ diff --git a/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 new file mode 100644 index 000000000..bd56dc50d Binary files /dev/null and b/packages/excalidraw/fonts/assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2 differ diff --git a/packages/excalidraw/fonts/index.ts b/packages/excalidraw/fonts/index.ts index 39f6bf8da..1de1f99c9 100644 --- a/packages/excalidraw/fonts/index.ts +++ b/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 LilitaLatin from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYEF8RXi4EwQ.woff2"; +import LilitaLatinExt from "./assets/Lilita-Regular-i7dPIFZ9Zz-WBtRtedDbYE98RXi4EwSsbg.woff2"; -import NunitoLatin from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"; -import NunitoLatinExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2"; -import NunitoCyrilic from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2"; -import NunitoCyrilicExt from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2"; -import NunitoVietnamese from "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2"; +import NunitoLatin from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"; +import NunitoLatinExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTo3j6zbXWjgevT5.woff2"; +import NunitoCyrilic from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTA3j6zbXWjgevT5.woff2"; +import NunitoCyrilicExt from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTk3j6zbXWjgevT5.woff2"; +import NunitoVietnamese from "./assets/Nunito-Regular-XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTs3j6zbXWjgevT5.woff2"; export class Fonts { // it's ok to track fonts across multiple instances only once, so let's use diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index fb9a45820..a8e91265f 100644 --- a/packages/excalidraw/frame.ts +++ b/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), ); }; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ebf9ff872..e4c5eea44 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -162,6 +162,13 @@ "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", "hint_emptyPrivateLibrary": "Select an item on canvas to add it here." }, + "search": { + "title": "Find on canvas", + "noMatch": "No matches found...", + "singleResult": "result", + "multipleResults": "results", + "placeholder": "Find text 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.", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index db349559b..243d5d45a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -30,8 +30,12 @@ import { shouldShowBoundingBox, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import type { InteractiveCanvasAppState } 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({ diff --git a/packages/excalidraw/renderer/renderSnaps.ts b/packages/excalidraw/renderer/renderSnaps.ts index 33b57ce68..57b57c570 100644 --- a/packages/excalidraw/renderer/renderSnaps.ts +++ b/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 = ( // (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 = ( // (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); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index 19169d4a9..f0bf98967 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/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); diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index fad0f4f93..0426b3f70 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/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(0, 0)]; + : [pointFrom(0, 0)]; if (isElbowArrow(element)) { shape = [ diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index b120d0cc9..6d1b963fc 100644 --- a/packages/excalidraw/scene/export.ts +++ b/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}` + } > `; diff --git a/packages/excalidraw/scene/scroll.ts b/packages/excalidraw/scene/scroll.ts index f3d6ac014..5d059e5b4 100644 --- a/packages/excalidraw/scene/scroll.ts +++ b/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, }; }; diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index 2c935145c..3f1855c63 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -1,6 +1,6 @@ import { isPoint, - point, + pointFrom, pointDistance, pointFromPair, pointRotateRads, @@ -167,15 +167,15 @@ export const getElementShape = ( ? getClosedCurveShape( element, roughShape, - point(element.x, element.y), + pointFrom(element.x, element.y), element.angle, - point(cx, cy), + pointFrom(cx, cy), ) : getCurveShape( roughShape, - point(element.x, element.y), + pointFrom(element.x, element.y), element.angle, - point(cx, cy), + pointFrom(cx, cy), ); } @@ -186,7 +186,7 @@ export const getElementShape = ( 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

(0, 0); + let currentP = pointFrom

(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

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

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

(data[4], data[5]); + const p1 = pointFrom

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

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

(data[4], data[5]); const distance = pointDistance(p3, endPoint); if (distance < minDistance) { minDistance = distance; @@ -279,7 +279,7 @@ export const getBezierXY =

( 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 =

( @@ -301,12 +301,12 @@ const getPointsInBezierCurve =

( 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[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 ( diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 9da3d74c4..1f2451b33 100644 --- a/packages/excalidraw/snapping.ts +++ b/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( - point(x1, y1 + halfHeight), - point(cx, cy), + pointFrom(x1, y1 + halfHeight), + pointFrom(cx, cy), element.angle, ); const topMid = pointRotateRads( - point(x1 + halfWidth, y1), - point(cx, cy), + pointFrom(x1 + halfWidth, y1), + pointFrom(cx, cy), element.angle, ); const rightMid = pointRotateRads( - point(x2, y1 + halfHeight), - point(cx, cy), + pointFrom(x2, y1 + halfHeight), + pointFrom(cx, cy), element.angle, ); const bottomMid = pointRotateRads( - point(x1 + halfWidth, y2), - point(cx, cy), + pointFrom(x1 + halfWidth, y2), + pointFrom(cx, cy), element.angle, ); - const center = point(cx, cy); + const center = pointFrom(cx, cy); result = omitCenter ? [leftMid, topMid, rightMid, bottomMid] : [leftMid, topMid, rightMid, bottomMid, center]; } else { const topLeft = pointRotateRads( - point(x1, y1), - point(cx, cy), + pointFrom(x1, y1), + pointFrom(cx, cy), element.angle, ); const topRight = pointRotateRads( - point(x2, y1), - point(cx, cy), + pointFrom(x2, y1), + pointFrom(cx, cy), element.angle, ); const bottomLeft = pointRotateRads( - point(x1, y2), - point(cx, cy), + pointFrom(x1, y2), + pointFrom(cx, cy), element.angle, ); const bottomRight = pointRotateRads( - point(x2, y2), - point(cx, cy), + pointFrom(x2, y2), + pointFrom(cx, cy), element.angle, ); - const center = point(cx, cy); + const center = pointFrom(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(minX, minY); - const topRight = point(maxX, minY); - const bottomLeft = point(minX, maxY); - const bottomRight = point(maxX, maxY); - const center = point(minX + width / 2, minY + height / 2); + const topLeft = pointFrom(minX, minY); + const topRight = pointFrom(maxX, minY); + const bottomLeft = pointFrom(minX, maxY); + const bottomRight = pointFrom(maxX, maxY); + const center = pointFrom(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(round(p[0]), round(p[1])), + pointFrom(round(p[0]), round(p[1])), ), ); } @@ -849,7 +855,7 @@ const createPointSnapLines = ( } snapsY[key].push( ...snap.points.map((p) => - point(round(p[0]), round(p[1])), + pointFrom(round(p[0]), round(p[1])), ), ); } @@ -863,7 +869,7 @@ const createPointSnapLines = ( points: dedupePoints( points .map((p) => { - return point(Number(key), p[1]); + return pointFrom(Number(key), p[1]); }) .sort((a, b) => a[1] - b[1]), ), @@ -876,7 +882,7 @@ const createPointSnapLines = ( points: dedupePoints( points .map((p) => { - return point(p[0], Number(key)); + return pointFrom(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", }); diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f9904a4d..f481d1d5d 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/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, }, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 2994cfc3e..e5e431dfc 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -239,6 +239,55 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende Ctrl+Shift+E

+