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

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

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

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

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

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

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

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

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

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

( + p: P, + bounds: Bounds, +): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; + +export const aabbsOverlapping = (a: Bounds, b: Bounds) => + pointInsideBounds(point(a[0], a[1]), b) || + pointInsideBounds(point(a[2], a[1]), b) || + pointInsideBounds(point(a[2], a[3]), b) || + pointInsideBounds(point(a[0], a[3]), b) || + pointInsideBounds(point(b[0], b[1]), a) || + pointInsideBounds(point(b[2], b[1]), a) || + pointInsideBounds(point(b[2], b[3]), a) || + pointInsideBounds(point(b[0], b[3]), a); + +export const getCornerRadius = (x: number, element: ExcalidrawElement) => { + if ( + element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS || + element.roundness?.type === ROUNDNESS.LEGACY + ) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) { + const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS; + + const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS; + + if (x <= CUTOFF_SIZE) { + return x * DEFAULT_PROPORTIONAL_RADIUS; + } + + return fixedRadiusSize; + } + + return 0; +}; + +// Checks if the first and last point are close enough +// to be considered a loop +export const isPathALoop = ( + points: ExcalidrawLinearElement["points"], + /** supply if you want the loop detection to account for current zoom */ + zoomValue: Zoom["value"] = 1 as NormalizedZoomValue, +): boolean => { + if (points.length >= 3) { + const [first, last] = [points[0], points[points.length - 1]]; + const distance = pointDistance(first, last); + + // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in + // really close we make the threshold smaller, and vice versa. + return distance <= LINE_CONFIRM_THRESHOLD / zoomValue; + } + return false; +}; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index ee19c648b..9da3d74c4 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -1,3 +1,12 @@ +import type { InclusiveRange } from "../math"; +import { + point, + pointRotateRads, + rangeInclusive, + rangeIntersection, + rangesOverlap, + type GlobalPoint, +} from "../math"; import { TOOL_TYPE } from "./constants"; import type { Bounds } from "./element/bounds"; import { @@ -14,7 +23,6 @@ import type { } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; -import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; import { getSelectedElements, getVisibleAndNonSelectedElements, @@ -23,7 +31,7 @@ import type { AppClassProperties, AppState, KeyboardModifiersObject, - Point, + NullableGridSize, } from "./types"; const SNAP_DISTANCE = 8; @@ -42,7 +50,7 @@ type Vector2D = { y: number; }; -type PointPair = [Point, Point]; +type PointPair = [GlobalPoint, GlobalPoint]; export type PointSnap = { type: "point"; @@ -62,9 +70,9 @@ export type Gap = { // ↑ end side startBounds: Bounds; endBounds: Bounds; - startSide: [Point, Point]; - endSide: [Point, Point]; - overlap: [number, number]; + startSide: [GlobalPoint, GlobalPoint]; + endSide: [GlobalPoint, GlobalPoint]; + overlap: InclusiveRange; length: number; }; @@ -88,7 +96,7 @@ export type Snaps = Snap[]; export type PointSnapLine = { type: "points"; - points: Point[]; + points: GlobalPoint[]; }; export type PointerSnapLine = { @@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine; // ----------------------------------------------------------------------------- export class SnapCache { - private static referenceSnapPoints: Point[] | null = null; + private static referenceSnapPoints: GlobalPoint[] | null = null; private static visibleGaps: { verticalGaps: Gap[]; horizontalGaps: Gap[]; } | null = null; - public static setReferenceSnapPoints = (snapPoints: Point[] | null) => { + public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => { SnapCache.referenceSnapPoints = snapPoints; }; @@ -191,8 +199,8 @@ export const getElementsCorners = ( omitCenter: false, boundingBoxCorners: false, }, -): Point[] => { - let result: Point[] = []; +): GlobalPoint[] => { + let result: GlobalPoint[] = []; if (elements.length === 1) { const element = elements[0]; @@ -219,33 +227,53 @@ export const getElementsCorners = ( (element.type === "diamond" || element.type === "ellipse") && !boundingBoxCorners ) { - const leftMid = rotatePoint( - [x1, y1 + halfHeight], - [cx, cy], + const leftMid = pointRotateRads( + point(x1, y1 + halfHeight), + point(cx, cy), element.angle, ); - const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle); - const rightMid = rotatePoint( - [x2, y1 + halfHeight], - [cx, cy], + const topMid = pointRotateRads( + point(x1 + halfWidth, y1), + point(cx, cy), element.angle, ); - const bottomMid = rotatePoint( - [x1 + halfWidth, y2], - [cx, cy], + const rightMid = pointRotateRads( + point(x2, y1 + halfHeight), + point(cx, cy), element.angle, ); - const center: Point = [cx, cy]; + const bottomMid = pointRotateRads( + point(x1 + halfWidth, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [leftMid, topMid, rightMid, bottomMid] : [leftMid, topMid, rightMid, bottomMid, center]; } else { - const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle); - const topRight = rotatePoint([x2, y1], [cx, cy], element.angle); - const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle); - const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle); - const center: Point = [cx, cy]; + const topLeft = pointRotateRads( + point(x1, y1), + point(cx, cy), + element.angle, + ); + const topRight = pointRotateRads( + point(x2, y1), + point(cx, cy), + element.angle, + ); + const bottomLeft = pointRotateRads( + point(x1, y2), + point(cx, cy), + element.angle, + ); + const bottomRight = pointRotateRads( + point(x2, y2), + point(cx, cy), + element.angle, + ); + const center = point(cx, cy); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] @@ -259,18 +287,18 @@ export const getElementsCorners = ( const width = maxX - minX; const height = maxY - minY; - const topLeft: Point = [minX, minY]; - const topRight: Point = [maxX, minY]; - const bottomLeft: Point = [minX, maxY]; - const bottomRight: Point = [maxX, maxY]; - const center: Point = [minX + width / 2, minY + height / 2]; + const topLeft = point(minX, minY); + const topRight = point(maxX, minY); + const bottomLeft = point(minX, maxY); + const bottomRight = point(maxX, maxY); + const center = point(minX + width / 2, minY + height / 2); result = omitCenter ? [topLeft, topRight, bottomLeft, bottomRight] : [topLeft, topRight, bottomLeft, bottomRight, center]; } - return result.map((point) => [round(point[0]), round(point[1])] as Point); + return result.map((p) => point(round(p[0]), round(p[1]))); }; const getReferenceElements = ( @@ -339,23 +367,20 @@ export const getVisibleGaps = ( if ( startMaxX < endMinX && - rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY]) + rangesOverlap( + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), + ) ) { horizontalGaps.push({ startBounds, endBounds, - startSide: [ - [startMaxX, startMinY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMinX, endMaxY], - ], + startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)], length: endMinX - startMaxX, overlap: rangeIntersection( - [startMinY, startMaxY], - [endMinY, endMaxY], + rangeInclusive(startMinY, startMaxY), + rangeInclusive(endMinY, endMaxY), )!, }); } @@ -382,23 +407,20 @@ export const getVisibleGaps = ( if ( startMaxY < endMinY && - rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX]) + rangesOverlap( + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), + ) ) { verticalGaps.push({ startBounds, endBounds, - startSide: [ - [startMinX, startMaxY], - [startMaxX, startMaxY], - ], - endSide: [ - [endMinX, endMinY], - [endMaxX, endMinY], - ], + startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)], + endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)], length: endMinY - startMaxY, overlap: rangeIntersection( - [startMinX, startMaxX], - [endMinX, endMaxX], + rangeInclusive(startMinX, startMaxX), + rangeInclusive(endMinX, endMaxX), )!, }); } @@ -441,7 +463,7 @@ const getGapSnaps = ( const centerY = (minY + maxY) / 2; for (const gap of horizontalGaps) { - if (!rangesOverlap([minY, maxY], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) { continue; } @@ -510,7 +532,7 @@ const getGapSnaps = ( } } for (const gap of verticalGaps) { - if (!rangesOverlap([minX, maxX], gap.overlap)) { + if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) { continue; } @@ -603,7 +625,7 @@ export const getReferenceSnapPoints = ( const getPointSnaps = ( selectedElements: ExcalidrawElement[], - selectionSnapPoints: Point[], + selectionSnapPoints: GlobalPoint[], app: AppClassProperties, event: KeyboardModifiersObject, nearestSnapsX: Snaps, @@ -779,8 +801,8 @@ const round = (x: number) => { return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces; }; -const dedupePoints = (points: Point[]): Point[] => { - const map = new Map(); +const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => { + const map = new Map(); for (const point of points) { const key = point.join(","); @@ -797,8 +819,8 @@ const createPointSnapLines = ( nearestSnapsX: Snaps, nearestSnapsY: Snaps, ): PointSnapLine[] => { - const snapsX = {} as { [key: string]: Point[] }; - const snapsY = {} as { [key: string]: Point[] }; + const snapsX = {} as { [key: string]: GlobalPoint[] }; + const snapsY = {} as { [key: string]: GlobalPoint[] }; if (nearestSnapsX.length > 0) { for (const snap of nearestSnapsX) { @@ -809,8 +831,8 @@ const createPointSnapLines = ( snapsX[key] = []; } snapsX[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -826,8 +848,8 @@ const createPointSnapLines = ( snapsY[key] = []; } snapsY[key].push( - ...snap.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + ...snap.points.map((p) => + point(round(p[0]), round(p[1])), ), ); } @@ -840,8 +862,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [Number(key), point[1]] as Point; + .map((p) => { + return point(Number(key), p[1]); }) .sort((a, b) => a[1] - b[1]), ), @@ -853,8 +875,8 @@ const createPointSnapLines = ( type: "points", points: dedupePoints( points - .map((point) => { - return [point[0], Number(key)] as Point; + .map((p) => { + return point(p[0], Number(key)); }) .sort((a, b) => a[0] - b[0]), ), @@ -898,12 +920,12 @@ const createGapSnapLines = ( const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds; const verticalIntersection = rangeIntersection( - [minY, maxY], + rangeInclusive(minY, maxY), gapSnap.gap.overlap, ); const horizontalGapIntersection = rangeIntersection( - [minX, maxX], + rangeInclusive(minX, maxX), gapSnap.gap.overlap, ); @@ -918,16 +940,16 @@ const createGapSnapLines = ( type: "gap", direction: "horizontal", points: [ - [gapSnap.gap.startSide[0][0], gapLineY], - [minX, gapLineY], + point(gapSnap.gap.startSide[0][0], gapLineY), + point(minX, gapLineY), ], }, { type: "gap", direction: "horizontal", points: [ - [maxX, gapLineY], - [gapSnap.gap.endSide[0][0], gapLineY], + point(maxX, gapLineY), + point(gapSnap.gap.endSide[0][0], gapLineY), ], }, ); @@ -944,16 +966,16 @@ const createGapSnapLines = ( type: "gap", direction: "vertical", points: [ - [gapLineX, gapSnap.gap.startSide[0][1]], - [gapLineX, minY], + point(gapLineX, gapSnap.gap.startSide[0][1]), + point(gapLineX, minY), ], }, { type: "gap", direction: "vertical", points: [ - [gapLineX, maxY], - [gapLineX, gapSnap.gap.endSide[0][1]], + point(gapLineX, maxY), + point(gapLineX, gapSnap.gap.endSide[0][1]), ], }, ); @@ -969,18 +991,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [endMaxX, gapLineY], - [minX, gapLineY], - ], + points: [point(endMaxX, gapLineY), point(minX, gapLineY)], }, ); } @@ -995,18 +1011,12 @@ const createGapSnapLines = ( { type: "gap", direction: "horizontal", - points: [ - [maxX, gapLineY], - [startMinX, gapLineY], - ], + points: [point(maxX, gapLineY), point(startMinX, gapLineY)], }, { type: "gap", direction: "horizontal", - points: [ - [startMaxX, gapLineY], - [endMinX, gapLineY], - ], + points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)], }, ); } @@ -1021,18 +1031,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, maxY], - [gapLineX, startMinY], - ], + points: [point(gapLineX, maxY), point(gapLineX, startMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, ); } @@ -1047,18 +1051,12 @@ const createGapSnapLines = ( { type: "gap", direction: "vertical", - points: [ - [gapLineX, startMaxY], - [gapLineX, endMinY], - ], + points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)], }, { type: "gap", direction: "vertical", - points: [ - [gapLineX, endMaxY], - [gapLineX, minY], - ], + points: [point(gapLineX, endMaxY), point(gapLineX, minY)], }, ); } @@ -1071,8 +1069,8 @@ const createGapSnapLines = ( gapSnapLines.map((gapSnapLine) => { return { ...gapSnapLine, - points: gapSnapLine.points.map( - (point) => [round(point[0]), round(point[1])] as Point, + points: gapSnapLine.points.map((p) => + point(round(p[0]), round(p[1])), ) as PointPair, }; }), @@ -1117,40 +1115,40 @@ export const snapResizingElements = ( } } - const selectionSnapPoints: Point[] = []; + const selectionSnapPoints: GlobalPoint[] = []; if (transformHandle) { switch (transformHandle) { case "e": { - selectionSnapPoints.push([maxX, minY], [maxX, maxY]); + selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY)); break; } case "w": { - selectionSnapPoints.push([minX, minY], [minX, maxY]); + selectionSnapPoints.push(point(minX, minY), point(minX, maxY)); break; } case "n": { - selectionSnapPoints.push([minX, minY], [maxX, minY]); + selectionSnapPoints.push(point(minX, minY), point(maxX, minY)); break; } case "s": { - selectionSnapPoints.push([minX, maxY], [maxX, maxY]); + selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY)); break; } case "ne": { - selectionSnapPoints.push([maxX, minY]); + selectionSnapPoints.push(point(maxX, minY)); break; } case "nw": { - selectionSnapPoints.push([minX, minY]); + selectionSnapPoints.push(point(minX, minY)); break; } case "se": { - selectionSnapPoints.push([maxX, maxY]); + selectionSnapPoints.push(point(maxX, maxY)); break; } case "sw": { - selectionSnapPoints.push([minX, maxY]); + selectionSnapPoints.push(point(minX, maxY)); break; } } @@ -1192,11 +1190,11 @@ export const snapResizingElements = ( round(bound), ); - const corners: Point[] = [ - [x1, y1], - [x1, y2], - [x2, y1], - [x2, y2], + const corners: GlobalPoint[] = [ + point(x1, y1), + point(x1, y2), + point(x2, y1), + point(x2, y2), ]; getPointSnaps( @@ -1232,8 +1230,8 @@ export const snapNewElement = ( }; } - const selectionSnapPoints: Point[] = [ - [origin.x + dragOffset.x, origin.y + dragOffset.y], + const selectionSnapPoints: GlobalPoint[] = [ + point(origin.x + dragOffset.x, origin.y + dragOffset.y), ]; const snapDistance = getSnapDistance(app.state.zoom.value); @@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = ( verticalSnapLines.push({ type: "pointer", - points: [corner, [corner[0], pointer.y]], + points: [corner, point(corner[0], pointer.y)], direction: "vertical", }); @@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = ( horizontalSnapLines.push({ type: "pointer", - points: [corner, [pointer.x, corner[1]]], + points: [corner, point(pointer.x, corner[1])], direction: "horizontal", }); @@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = ( activeToolType === TOOL_TYPE.text ); }; + +// TODO: Rounding this point causes some shake when free drawing +export const getGridPoint = ( + x: number, + y: number, + gridSize: NullableGridSize, +): [number, number] => { + if (gridSize) { + return [ + Math.round(x / gridSize) * gridSize, + Math.round(y / gridSize) * gridSize, + ]; + } + return [x, y]; +}; diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index 54ad8006b..4f6d6b56b 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -7,6 +7,7 @@ import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { arrayToMap } from "../utils"; +import { point } from "../../math"; const { h } = window; @@ -31,12 +32,7 @@ describe("element binding", () => { y: 0, width: 100, height: 1, - points: [ - [0, 0], - [0, 0], - [100, 0], - [100, 0], - ], + points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)], }); API.setElements([rect, arrow]); expect(arrow.startBinding).toBe(null); @@ -314,10 +310,7 @@ describe("element binding", () => { const arrow1 = API.createElement({ type: "arrow", id: "arrow1", - points: [ - [0, 0], - [0, -87.45777932247563], - ], + points: [point(0, 0), point(0, -87.45777932247563)], startBinding: { elementId: "rectangle1", focus: 0.2, @@ -335,10 +328,7 @@ describe("element binding", () => { const arrow2 = API.createElement({ type: "arrow", id: "arrow2", - points: [ - [0, 0], - [0, -87.45777932247563], - ], + points: [point(0, 0), point(0, -87.45777932247563)], startBinding: { elementId: "text1", focus: 0.2, diff --git a/packages/excalidraw/tests/fixtures/elementFixture.ts b/packages/excalidraw/tests/fixtures/elementFixture.ts index 766c2c7d1..3f93f0713 100644 --- a/packages/excalidraw/tests/fixtures/elementFixture.ts +++ b/packages/excalidraw/tests/fixtures/elementFixture.ts @@ -1,3 +1,4 @@ +import type { Radians } from "../../../math"; import { DEFAULT_FONT_FAMILY } from "../../constants"; import type { ExcalidrawElement } from "../../element/types"; @@ -7,7 +8,7 @@ const elementBase: Omit = { y: 237, width: 214, height: 214, - angle: 0, + angle: 0 as Radians, strokeColor: "#000000", backgroundColor: "#15aabf", fillStyle: "hachure", diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 8aedac46f..5cf4cd55c 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -28,6 +28,8 @@ import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; import { arrayToMap, cloneJSON } from "../utils"; +import type { LocalPoint } from "../../math"; +import { point, type Radians } from "../../math"; const { h } = window; const mouse = new Pointer("mouse"); @@ -131,7 +133,7 @@ const createLinearElementWithCurveInsideMinMaxPoints = ( y: -2412.5069664197654, width: 1750.4888916015625, height: 410.51605224609375, - angle: 0, + angle: 0 as Radians, strokeColor: "#000000", backgroundColor: "#fa5252", fillStyle: "hachure", @@ -145,9 +147,9 @@ const createLinearElementWithCurveInsideMinMaxPoints = ( link: null, locked: false, points: [ - [0, 0], - [-922.4761962890625, 300.3277587890625], - [828.0126953125, 410.51605224609375], + point(0, 0), + point(-922.4761962890625, 300.3277587890625), + point(828.0126953125, 410.51605224609375), ], }); }; @@ -423,8 +425,8 @@ describe("arrow", () => { }); it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); API.setElements([line]); API.setAppState({ @@ -444,8 +446,8 @@ describe("arrow", () => { }); it("flips a rotated arrow vertically with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); API.setElements([line]); API.setAppState({ @@ -477,8 +479,8 @@ describe("arrow", () => { //TODO: elements with curve outside minMax points have a wrong bounding box!!! it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -501,8 +503,8 @@ describe("arrow", () => { //TODO: elements with curve outside minMax points have a wrong bounding box!!! it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -585,8 +587,8 @@ describe("line", () => { //TODO: elements with curve outside minMax points have a wrong bounding box it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("line"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -600,8 +602,8 @@ describe("line", () => { //TODO: elements with curve outside minMax points have a wrong bounding box it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementsWithCurveOutsideMinMaxPoints("line"); API.updateElement(line, { angle: originalAngle }); API.setElements([line]); @@ -619,8 +621,8 @@ describe("line", () => { }); it("flips a rotated line horizontally with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); API.setElements([line]); API.setAppState({ @@ -640,8 +642,8 @@ describe("line", () => { }); it("flips a rotated line vertically with line inside min/max points bounds", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); API.setElements([line]); API.setAppState({ @@ -772,8 +774,8 @@ describe("image", () => { }); it("flips an rotated image horizontally correctly", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; //paste image await createImage(); await waitFor(() => { @@ -790,8 +792,8 @@ describe("image", () => { }); it("flips an rotated image vertically correctly", async () => { - const originalAngle = Math.PI / 4; - const expectedAngle = (7 * Math.PI) / 4; + const originalAngle = (Math.PI / 4) as Radians; + const expectedAngle = ((7 * Math.PI) / 4) as Radians; //paste image await createImage(); await waitFor(() => { diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 53d66152e..409d5825b 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -27,7 +27,7 @@ import { newImageElement, newMagicFrameElement, } from "../../element/newElement"; -import type { AppState, Point } from "../../types"; +import type { AppState } from "../../types"; import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; import type { Mutable } from "../../utility-types"; @@ -36,6 +36,7 @@ import type App from "../../components/App"; import { createTestHook } from "../../components/App"; import type { Action } from "../../actions/types"; import { mutateElement } from "../../element/mutateElement"; +import { point, type LocalPoint, type Radians } from "../../../math"; const readFile = util.promisify(fs.readFile); // so that window.h is available when App.tsx is not imported as well. @@ -171,7 +172,7 @@ export class API { containerId?: T extends "text" ? ExcalidrawTextElement["containerId"] : never; - points?: T extends "arrow" | "line" ? readonly Point[] : never; + points?: T extends "arrow" | "line" ? readonly LocalPoint[] : never; locked?: boolean; fileId?: T extends "image" ? string : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; @@ -218,7 +219,7 @@ export class API { y, frameId: rest.frameId ?? null, index: rest.index ?? null, - angle: rest.angle ?? 0, + angle: (rest.angle ?? 0) as Radians, strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor, backgroundColor: rest.backgroundColor ?? appState.currentItemBackgroundColor, @@ -293,8 +294,8 @@ export class API { height, type, points: rest.points ?? [ - [0, 0], - [100, 100], + point(0, 0), + point(100, 100), ], elbowed: rest.elbowed ?? false, }); @@ -306,8 +307,8 @@ export class API { height, type, points: rest.points ?? [ - [0, 0], - [100, 100], + point(0, 0), + point(100, 100), ], }); break; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 693e78333..3c7fe072d 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -1,4 +1,4 @@ -import type { Point, ToolType } from "../../types"; +import type { ToolType } from "../../types"; import type { ExcalidrawElement, ExcalidrawLinearElement, @@ -30,10 +30,11 @@ import { isFrameLikeElement, } from "../../element/typeChecks"; import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; -import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; import { arrayToMap } from "../../utils"; import { createTestHook } from "../../components/App"; +import type { GlobalPoint, LocalPoint, Radians } from "../../../math"; +import { point, pointRotateRads } from "../../../math"; // so that window.h is available when App.tsx is not imported as well. createTestHook(); @@ -131,27 +132,29 @@ export class Keyboard { }; } -const getElementPointForSelection = (element: ExcalidrawElement): Point => { +const getElementPointForSelection = ( + element: ExcalidrawElement, +): GlobalPoint => { const { x, y, width, height, angle } = element; - const target: Point = [ + const target = point( x + (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2), y, - ]; - let center: Point; + ); + let center: GlobalPoint; if (isLinearElement(element)) { const bounds = getElementPointsCoords(element, element.points); - center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2]; + center = point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2); } else { - center = [x + width / 2, y + height / 2]; + center = point(x + width / 2, y + height / 2); } if (isTextElement(element)) { return center; } - return rotatePoint(target, center, angle); + return pointRotateRads(target, center, angle); }; export class Pointer { @@ -328,7 +331,7 @@ const transform = ( const isFrameSelected = elements.some(isFrameLikeElement); const transformHandles = getTransformHandlesFromCoords( [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], - 0, + 0 as Radians, h.state.zoom, "mouse", isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -450,7 +453,7 @@ export class UI { width?: number; height?: number; angle?: number; - points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never; + points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never; } = {}, ): Element & { /** Returns the actual, current element from the elements array, instead @@ -459,9 +462,9 @@ export class UI { } { const width = initialWidth ?? initialHeight ?? size; const height = initialHeight ?? size; - const points: Point[] = initialPoints ?? [ - [0, 0], - [width, height], + const points: LocalPoint[] = initialPoints ?? [ + point(0, 0), + point(width, height), ]; UI.clickTool(type); diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 343dfa428..8e825e414 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -44,6 +44,8 @@ import { queryByText } from "@testing-library/react"; import { HistoryEntry } from "../history"; import { AppStateChange, ElementsChange } from "../change"; import { Snapshot, StoreAction } from "../store"; +import type { LocalPoint, Radians } from "../../math"; +import { point } from "../../math"; const { h } = window; @@ -2038,9 +2040,9 @@ describe("history", () => { width: 178.9000000000001, height: 236.10000000000002, points: [ - [0, 0], - [178.9000000000001, 0], - [178.9000000000001, 236.10000000000002], + point(0, 0), + point(178.9000000000001, 0), + point(178.9000000000001, 236.10000000000002), ], startBinding: { elementId: "KPrBI4g_v9qUB1XxYLgSz", @@ -2156,12 +2158,12 @@ describe("history", () => { elements: [ newElementWith(h.elements[0] as ExcalidrawLinearElement, { points: [ - [0, 0], - [5, 5], - [10, 10], - [15, 15], - [20, 20], - ], + point(0, 0), + point(5, 5), + point(10, 10), + point(15, 15), + point(20, 20), + ] as LocalPoint[], }), ], storeAction: StoreAction.UPDATE, @@ -4003,7 +4005,7 @@ describe("history", () => { newElementWith(h.elements[0], { x: 200, y: 200, - angle: 90, + angle: 90 as Radians, }), ], storeAction: StoreAction.CAPTURE, @@ -4121,7 +4123,7 @@ describe("history", () => { newElementWith(h.elements[0], { x: 205, y: 205, - angle: 90, + angle: 90 as Radians, }), ], storeAction: StoreAction.CAPTURE, diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 4bf88f0e4..06ca24a9c 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -8,7 +8,6 @@ import type { SceneElementsMap, } from "../element/types"; import { Excalidraw, mutateElement } from "../index"; -import { centerPoint } from "../math"; import { reseed } from "../random"; import * as StaticScene from "../renderer/staticScene"; import * as InteractiveCanvas from "../renderer/interactiveScene"; @@ -16,7 +15,6 @@ import * as InteractiveCanvas from "../renderer/interactiveScene"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; -import type { Point } from "../types"; import { KEYS } from "../keys"; import { LinearElementEditor } from "../element/linearElementEditor"; import { act, queryByTestId, queryByText } from "@testing-library/react"; @@ -29,6 +27,8 @@ import * as textElementUtils from "../element/textElement"; import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; +import type { GlobalPoint } from "../../math"; +import { pointCenter, point } from "../../math"; const renderInteractiveScene = vi.spyOn( InteractiveCanvas, @@ -57,9 +57,9 @@ describe("Test Linear Elements", () => { interactiveCanvas = container.querySelector("canvas.interactive")!; }); - const p1: Point = [20, 20]; - const p2: Point = [60, 20]; - const midpoint = centerPoint(p1, p2); + const p1 = point(20, 20); + const p2 = point(60, 20); + const midpoint = pointCenter(p1, p2); const delta = 50; const mouse = new Pointer("mouse"); @@ -75,10 +75,7 @@ describe("Test Linear Elements", () => { height: 0, type, roughness, - points: [ - [0, 0], - [p2[0] - p1[0], p2[1] - p1[1]], - ], + points: [point(0, 0), point(p2[0] - p1[0], p2[1] - p1[1])], roundness, }); API.setElements([line]); @@ -102,9 +99,9 @@ describe("Test Linear Elements", () => { type, roughness, points: [ - [0, 0], - [p3[0], p3[1]], - [p2[0] - p1[0], p2[1] - p1[1]], + point(0, 0), + point(p3[0], p3[1]), + point(p2[0] - p1[0], p2[1] - p1[1]), ], roundness, }); @@ -129,7 +126,7 @@ describe("Test Linear Elements", () => { expect(h.state.editingLinearElement?.elementId).toEqual(line.id); }; - const drag = (startPoint: Point, endPoint: Point) => { + const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => { fireEvent.pointerDown(interactiveCanvas, { clientX: startPoint[0], clientY: startPoint[1], @@ -144,7 +141,7 @@ describe("Test Linear Elements", () => { }); }; - const deletePoint = (point: Point) => { + const deletePoint = (point: GlobalPoint) => { fireEvent.pointerDown(interactiveCanvas, { clientX: point[0], clientY: point[1], @@ -164,7 +161,7 @@ describe("Test Linear Elements", () => { expect(line.points.length).toEqual(2); mouse.clickAt(midpoint[0], midpoint[1]); - drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); + drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1)); expect(line.points.length).toEqual(2); @@ -172,7 +169,7 @@ describe("Test Linear Elements", () => { expect(line.y).toBe(originalY); expect(line.points.length).toEqual(2); - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); @@ -187,7 +184,7 @@ describe("Test Linear Elements", () => { expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); // drag line from midpoint - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); @@ -251,7 +248,7 @@ describe("Test Linear Elements", () => { mouse.clickAt(midpoint[0], midpoint[1]); expect(line.points.length).toEqual(2); - drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]); + drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1)); expect(line.x).toBe(originalX); expect(line.y).toBe(originalY); expect(line.points.length).toEqual(3); @@ -264,7 +261,7 @@ describe("Test Linear Elements", () => { enterLineEditingMode(line); // drag line from midpoint - drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); + drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, ); @@ -356,10 +353,13 @@ describe("Test Linear Elements", () => { h.state, ); - const startPoint = centerPoint(points[0], midPoints[0] as Point); + const startPoint = pointCenter(points[0], midPoints[0]!); const deltaX = 50; const deltaY = 20; - const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY]; + const endPoint = point( + startPoint[0] + deltaX, + startPoint[1] + deltaY, + ); // Move the element drag(startPoint, endPoint); @@ -399,8 +399,8 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [55, 45]; - const lastSegmentMidpoint: Point = [75, 40]; + const firstSegmentMidpoint = point(55, 45); + const lastSegmentMidpoint = point(75, 40); let line: ExcalidrawLinearElement; @@ -414,17 +414,20 @@ describe("Test Linear Elements", () => { it("should allow dragging lines from midpoints in between segments", async () => { // drag line via first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); expect(line.points.length).toEqual(4); // drag line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, @@ -472,10 +475,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); + drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -513,10 +516,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); + drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -551,10 +554,10 @@ describe("Test Linear Elements", () => { ); // dragging line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + 50, - lastSegmentMidpoint[1] + 50, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50), + ); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -586,12 +589,14 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [ - 55.9697848965255, 47.442326230998205, - ]; - const lastSegmentMidpoint: Point = [ - 76.08587175006699, 43.294165939653226, - ]; + const firstSegmentMidpoint = point( + 55.9697848965255, + 47.442326230998205, + ); + const lastSegmentMidpoint = point( + 76.08587175006699, + 43.294165939653226, + ); let line: ExcalidrawLinearElement; beforeEach(() => { @@ -605,17 +610,20 @@ describe("Test Linear Elements", () => { it("should allow dragging lines from midpoints in between segments", async () => { // drag line from first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); expect(line.points.length).toEqual(4); // drag line from last segment midpoint - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `16`, ); @@ -661,10 +669,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); + drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta)); const newPoints = LinearElementEditor.getPointsGlobalCoordinates( line, @@ -709,10 +717,10 @@ describe("Test Linear Elements", () => { h.state, ); - const hitCoords: Point = [points[0][0], points[0][1]]; + const hitCoords = point(points[0][0], points[0][1]); // Drag from first point - drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); + drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( `12`, @@ -741,10 +749,10 @@ describe("Test Linear Elements", () => { it("should update all the midpoints when a point is deleted", async () => { const elementsMap = arrayToMap(h.elements); - drag(lastSegmentMidpoint, [ - lastSegmentMidpoint[0] + delta, - lastSegmentMidpoint[1] + delta, - ]); + drag( + lastSegmentMidpoint, + point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta), + ); expect(line.points.length).toEqual(4); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -803,8 +811,11 @@ describe("Test Linear Elements", () => { API.setSelectedElements([line]); enterLineEditingMode(line, true); drag( - [line.points[0][0] + line.x, line.points[0][1] + line.y], - [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y], + point(line.points[0][0] + line.x, line.points[0][1] + line.y), + point( + dragEndPositionOffset[0] + line.x, + dragEndPositionOffset[1] + line.y, + ), ); expect(line.points).toMatchInlineSnapshot(` [ @@ -916,14 +927,18 @@ describe("Test Linear Elements", () => { // This is the expected midpoint for line with round edge // hence hardcoding it so if later some bug is introduced // this will fail and we can fix it - const firstSegmentMidpoint: Point = [ - 55.9697848965255, 47.442326230998205, - ]; + const firstSegmentMidpoint = point( + 55.9697848965255, + 47.442326230998205, + ); // drag line from first segment midpoint - drag(firstSegmentMidpoint, [ - firstSegmentMidpoint[0] + delta, - firstSegmentMidpoint[1] + delta, - ]); + drag( + firstSegmentMidpoint, + point( + firstSegmentMidpoint[0] + delta, + firstSegmentMidpoint[1] + delta, + ), + ); const position = LinearElementEditor.getBoundTextElementPosition( container, @@ -1136,7 +1151,7 @@ describe("Test Linear Elements", () => { ); // Drag from last point - drag(points[1], [points[1][0] + 300, points[1][1]]); + drag(points[1], point(points[1][0] + 300, points[1][1])); expect({ width: container.width, height: container.height }) .toMatchInlineSnapshot(` @@ -1335,14 +1350,14 @@ describe("Test Linear Elements", () => { [ { index: 0, - point: [line.points[0][0] + 10, line.points[0][1] + 10], + point: point(line.points[0][0] + 10, line.points[0][1] + 10), }, { index: line.points.length - 1, - point: [ + point: point( line.points[line.points.length - 1][0] - 10, line.points[line.points.length - 1][1] - 10, - ], + ), }, ], new Map() as SceneElementsMap, diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 34e189cee..d18f5cd49 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -7,7 +7,6 @@ import type { ExcalidrawFreeDrawElement, ExcalidrawLinearElement, } from "../element/types"; -import type { Point } from "../types"; import type { Bounds } from "../element/bounds"; import { getElementPointsCoords } from "../element/bounds"; import { Excalidraw } from "../index"; @@ -16,6 +15,8 @@ import { KEYS } from "../keys"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { arrayToMap } from "../utils"; +import type { LocalPoint } from "../../math"; +import { point } from "../../math"; ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -217,18 +218,13 @@ describe("generic element", () => { }); describe.each(["line", "freedraw"] as const)("%s element", (type) => { - const points: Record = { - line: [ - [0, 0], - [60, -20], - [20, 40], - [-40, 0], - ], + const points: Record = { + line: [point(0, 0), point(60, -20), point(20, 40), point(-40, 0)], freedraw: [ - [0, 0], - [-2.474600807561444, 41.021700699972], - [3.6627956000014024, 47.84174560617245], - [40.495224145598115, 47.15909710753482], + point(0, 0), + point(-2.474600807561444, 41.021700699972), + point(3.6627956000014024, 47.84174560617245), + point(40.495224145598115, 47.15909710753482), ], }; @@ -296,11 +292,11 @@ describe("arrow element", () => { it("resizes with a label", async () => { const arrow = UI.createElement("arrow", { points: [ - [0, 0], - [40, 140], - [80, 60], // label's anchor - [180, 20], - [200, 120], + point(0, 0), + point(40, 140), + point(80, 60), // label's anchor + point(180, 20), + point(200, 120), ], }); const label = await UI.editText(arrow, "Hello"); @@ -694,24 +690,24 @@ describe("multiple selection", () => { x: 60, y: 40, points: [ - [0, 0], - [-40, 40], - [-60, 0], - [0, -40], - [40, 20], - [0, 40], + point(0, 0), + point(-40, 40), + point(-60, 0), + point(0, -40), + point(40, 20), + point(0, 40), ], }); const freedraw = UI.createElement("freedraw", { x: 63.56072661326618, y: 100, points: [ - [0, 0], - [-43.56072661326618, 18.15048126846341], - [-43.56072661326618, 29.041198460587566], - [-38.115368017204105, 42.652452795512204], - [-19.964886748740696, 66.24829266003775], - [19.056612930986716, 77.1390098521619], + point(0, 0), + point(-43.56072661326618, 18.15048126846341), + point(-43.56072661326618, 29.041198460587566), + point(-38.115368017204105, 42.652452795512204), + point(-19.964886748740696, 66.24829266003775), + point(19.056612930986716, 77.1390098521619), ], }); @@ -1050,13 +1046,13 @@ describe("multiple selection", () => { x: 60, y: 0, points: [ - [0, 0], - [-40, 40], - [-20, 60], - [20, 20], - [40, 40], - [-20, 100], - [-60, 60], + point(0, 0), + point(-40, 40), + point(-20, 60), + point(20, 20), + point(40, 40), + point(-20, 100), + point(-60, 60), ], }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index c94c5134e..58994e86e 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -24,7 +24,6 @@ import type { ExcalidrawNonSelectionElement, } from "./element/types"; import type { Action } from "./actions/types"; -import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { LinearElementEditor } from "./element/linearElementEditor"; import type { SuggestedBinding } from "./element/binding"; import type { ImportedDataState } from "./data/types"; @@ -43,8 +42,6 @@ import type { SnapLine } from "./snapping"; import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types"; import type { StoreActionType } from "./store"; -export type Point = Readonly; - export type SocketId = string & { _brand: "SocketId" }; export type Collaborator = Readonly<{ diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 0f3d11f71..ef8ab8308 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1,3 +1,4 @@ +import { average } from "../math"; import { COLOR_PALETTE } from "./colors"; import type { EVENT } from "./constants"; import { @@ -992,10 +993,6 @@ export const isMemberOf = ( export const cloneJSON = (obj: T): T => JSON.parse(JSON.stringify(obj)); -export const isFiniteNumber = (value: any): value is number => { - return typeof value === "number" && Number.isFinite(value); -}; - export const updateStable = >( prevValue: T, nextValue: T, @@ -1079,7 +1076,6 @@ export function addEventListener( }; } -const average = (a: number, b: number) => (a + b) / 2; export function getSvgPathFromStroke(points: number[][], closed = true) { const len = points.length; diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index f6ab8f744..7181719f7 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -1,7 +1,7 @@ +import { isLineSegment, lineSegment, point, type GlobalPoint } from "../math"; import type { LineSegment } from "../utils"; import type { BoundingBox, Bounds } from "./element/bounds"; -import { isBounds, isLineSegment } from "./element/typeChecks"; -import type { Point } from "./types"; +import { isBounds } from "./element/typeChecks"; // The global data holder to collect the debug operations declare global { @@ -15,18 +15,22 @@ declare global { export type DebugElement = { color: string; - data: LineSegment; + data: LineSegment; permanent: boolean; }; export const debugDrawLine = ( - segment: LineSegment | LineSegment[], + segment: LineSegment | LineSegment[], opts?: { color?: string; permanent?: boolean; }, ) => { - (isLineSegment(segment) ? [segment] : segment).forEach((data) => + const segments = ( + isLineSegment(segment) ? [segment] : segment + ) as LineSegment[]; + + segments.forEach((data) => addToCurrentFrame({ color: opts?.color ?? "red", data, @@ -36,7 +40,7 @@ export const debugDrawLine = ( }; export const debugDrawPoint = ( - point: Point, + p: GlobalPoint, opts?: { color?: string; permanent?: boolean; @@ -47,20 +51,20 @@ export const debugDrawPoint = ( const yOffset = opts?.fuzzy ? Math.random() * 3 : 0; debugDrawLine( - [ - [point[0] + xOffset - 10, point[1] + yOffset - 10], - [point[0] + xOffset + 10, point[1] + yOffset + 10], - ], + lineSegment( + point(p[0] + xOffset - 10, p[1] + yOffset - 10), + point(p[0] + xOffset + 10, p[1] + yOffset + 10), + ), { color: opts?.color ?? "cyan", permanent: opts?.permanent, }, ); debugDrawLine( - [ - [point[0] + xOffset - 10, point[1] + yOffset + 10], - [point[0] + xOffset + 10, point[1] + yOffset - 10], - ], + lineSegment( + point(p[0] + xOffset - 10, p[1] + yOffset + 10), + point(p[0] + xOffset + 10, p[1] + yOffset - 10), + ), { color: opts?.color ?? "cyan", permanent: opts?.permanent, @@ -78,22 +82,22 @@ export const debugDrawBoundingBox = ( (Array.isArray(box) ? box : [box]).forEach((bbox) => debugDrawLine( [ - [ - [bbox.minX, bbox.minY], - [bbox.maxX, bbox.minY], - ], - [ - [bbox.maxX, bbox.minY], - [bbox.maxX, bbox.maxY], - ], - [ - [bbox.maxX, bbox.maxY], - [bbox.minX, bbox.maxY], - ], - [ - [bbox.minX, bbox.maxY], - [bbox.minX, bbox.minY], - ], + lineSegment( + point(bbox.minX, bbox.minY), + point(bbox.maxX, bbox.minY), + ), + lineSegment( + point(bbox.maxX, bbox.minY), + point(bbox.maxX, bbox.maxY), + ), + lineSegment( + point(bbox.maxX, bbox.maxY), + point(bbox.minX, bbox.maxY), + ), + lineSegment( + point(bbox.minX, bbox.maxY), + point(bbox.minX, bbox.minY), + ), ], { color: opts?.color ?? "cyan", @@ -113,22 +117,22 @@ export const debugDrawBounds = ( (isBounds(box) ? [box] : box).forEach((bbox) => debugDrawLine( [ - [ - [bbox[0], bbox[1]], - [bbox[2], bbox[1]], - ], - [ - [bbox[2], bbox[1]], - [bbox[2], bbox[3]], - ], - [ - [bbox[2], bbox[3]], - [bbox[0], bbox[3]], - ], - [ - [bbox[0], bbox[3]], - [bbox[0], bbox[1]], - ], + lineSegment( + point(bbox[0], bbox[1]), + point(bbox[2], bbox[1]), + ), + lineSegment( + point(bbox[2], bbox[1]), + point(bbox[2], bbox[3]), + ), + lineSegment( + point(bbox[2], bbox[3]), + point(bbox[0], bbox[3]), + ), + lineSegment( + point(bbox[0], bbox[3]), + point(bbox[0], bbox[1]), + ), ], { color: opts?.color ?? "green", diff --git a/packages/math/CHANGELOG.md b/packages/math/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/math/README.md b/packages/math/README.md new file mode 100644 index 000000000..eaa163037 --- /dev/null +++ b/packages/math/README.md @@ -0,0 +1,21 @@ +# @excalidraw/math + +## Install + +```bash +npm install @excalidraw/math +``` + +If you prefer Yarn over npm, use this command to install the Excalidraw utils package: + +```bash +yarn add @excalidraw/math +``` + +With PNPM, similarly install the package with this command: + +```bash +pnpm add @excalidraw/math +``` + +## API diff --git a/packages/math/angle.ts b/packages/math/angle.ts new file mode 100644 index 000000000..2dc97a469 --- /dev/null +++ b/packages/math/angle.ts @@ -0,0 +1,47 @@ +import type { + Degrees, + GlobalPoint, + LocalPoint, + PolarCoords, + Radians, +} from "./types"; +import { PRECISION } from "./utils"; + +// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI +export const normalizeRadians = (angle: Radians): Radians => { + if (angle < 0) { + return (angle + 2 * Math.PI) as Radians; + } + if (angle >= 2 * Math.PI) { + return (angle - 2 * Math.PI) as Radians; + } + return angle; +}; + +/** + * Return the polar coordinates for the given cartesian point represented by + * (x, y) for the center point 0,0 where the first number returned is the radius, + * the second is the angle in radians. + */ +export const cartesian2Polar =

([ + x, + y, +]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)]; + +export function degreesToRadians(degrees: Degrees): Radians { + return ((degrees * Math.PI) / 180) as Radians; +} + +export function radiansToDegrees(degrees: Radians): Degrees { + return ((degrees * 180) / Math.PI) as Degrees; +} + +/** + * Determines if the provided angle is a right angle. + * + * @param rads The angle to measure + * @returns TRUE if the provided angle is a right angle + */ +export function isRightAngleRads(rads: Radians): boolean { + return Math.abs(Math.sin(2 * rads)) < PRECISION; +} diff --git a/packages/math/arc.test.ts b/packages/math/arc.test.ts new file mode 100644 index 000000000..12e880c9c --- /dev/null +++ b/packages/math/arc.test.ts @@ -0,0 +1,41 @@ +import { isPointOnSymmetricArc } from "./arc"; +import { point } from "./point"; + +describe("point on arc", () => { + it("should detect point on simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(0.92291667, 0.385), + ), + ).toBe(true); + }); + it("should not detect point outside of a simple arc", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(-0.92291667, 0.385), + ), + ).toBe(false); + }); + it("should not detect point with good angle but incorrect radius", () => { + expect( + isPointOnSymmetricArc( + { + radius: 1, + startAngle: -Math.PI / 4, + endAngle: Math.PI / 4, + }, + point(-0.5, 0.5), + ), + ).toBe(false); + }); +}); diff --git a/packages/math/arc.ts b/packages/math/arc.ts new file mode 100644 index 000000000..c93830dba --- /dev/null +++ b/packages/math/arc.ts @@ -0,0 +1,20 @@ +import { cartesian2Polar } from "./angle"; +import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types"; +import { PRECISION } from "./utils"; + +/** + * Determines if a cartesian point lies on a symmetric arc, i.e. an arc which + * is part of a circle contour centered on 0, 0. + */ +export const isPointOnSymmetricArc =

( + { radius: arcRadius, startAngle, endAngle }: SymmetricArc, + point: P, +): boolean => { + const [radius, angle] = cartesian2Polar(point); + + return startAngle < endAngle + ? Math.abs(radius - arcRadius) < PRECISION && + startAngle <= angle && + endAngle >= angle + : startAngle <= angle || endAngle >= angle; +}; diff --git a/packages/math/curve.ts b/packages/math/curve.ts new file mode 100644 index 000000000..ca4571057 --- /dev/null +++ b/packages/math/curve.ts @@ -0,0 +1,223 @@ +import { point, pointRotateRads } from "./point"; +import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types"; + +/** + * + * @param a + * @param b + * @param c + * @param d + * @returns + */ +export function curve( + a: Point, + b: Point, + c: Point, + d: Point, +) { + return [a, b, c, d] as Curve; +} + +export const curveRotate = ( + curve: Curve, + angle: Radians, + origin: Point, +) => { + return curve.map((p) => pointRotateRads(p, origin, angle)); +}; + +/** + * + * @param pointsIn + * @param curveTightness + * @returns + */ +export function curveToBezier( + pointsIn: readonly Point[], + curveTightness = 0, +): Point[] { + const len = pointsIn.length; + if (len < 3) { + throw new Error("A curve must have at least three points."); + } + const out: Point[] = []; + if (len === 3) { + out.push( + point(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned + point(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned + point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned + point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned + ); + } else { + const points: Point[] = []; + points.push(pointsIn[0], pointsIn[0]); + for (let i = 1; i < pointsIn.length; i++) { + points.push(pointsIn[i]); + if (i === pointsIn.length - 1) { + points.push(pointsIn[i]); + } + } + const b: Point[] = []; + const s = 1 - curveTightness; + out.push(point(points[0][0], points[0][1])); + for (let i = 1; i + 2 < points.length; i++) { + const cachedVertArray = points[i]; + b[0] = point(cachedVertArray[0], cachedVertArray[1]); + b[1] = point( + cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, + cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, + ); + b[2] = point( + points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, + points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, + ); + b[3] = point(points[i + 1][0], points[i + 1][1]); + out.push(b[1], b[2], b[3]); + } + } + return out; +} + +/** + * + * @param t + * @param controlPoints + * @returns + */ +export const cubicBezierPoint = ( + t: number, + controlPoints: Curve, +): Point => { + const [p0, p1, p2, p3] = controlPoints; + + const x = + Math.pow(1 - t, 3) * p0[0] + + 3 * Math.pow(1 - t, 2) * t * p1[0] + + 3 * (1 - t) * Math.pow(t, 2) * p2[0] + + Math.pow(t, 3) * p3[0]; + + const y = + Math.pow(1 - t, 3) * p0[1] + + 3 * Math.pow(1 - t, 2) * t * p1[1] + + 3 * (1 - t) * Math.pow(t, 2) * p2[1] + + Math.pow(t, 3) * p3[1]; + + return point(x, y); +}; + +/** + * + * @param point + * @param controlPoints + * @returns + */ +export const cubicBezierDistance = ( + point: Point, + controlPoints: Curve, +) => { + // Calculate the closest point on the Bezier curve to the given point + const t = findClosestParameter(point, controlPoints); + + // Calculate the coordinates of the closest point on the curve + const [closestX, closestY] = cubicBezierPoint(t, controlPoints); + + // Calculate the distance between the given point and the closest point on the curve + const distance = Math.sqrt( + (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2, + ); + + return distance; +}; + +const solveCubic = (a: number, b: number, c: number, d: number) => { + // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0 + const roots: number[] = []; + + const discriminant = + 18 * a * b * c * d - + 4 * Math.pow(b, 3) * d + + Math.pow(b, 2) * Math.pow(c, 2) - + 4 * a * Math.pow(c, 3) - + 27 * Math.pow(a, 2) * Math.pow(d, 2); + + if (discriminant >= 0) { + const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2); + const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2); + + const root1 = (-b - C - D) / (3 * a); + const root2 = (-b + (C + D) / 2) / (3 * a); + const root3 = (-b + (C + D) / 2) / (3 * a); + + roots.push(root1, root2, root3); + } else { + const realPart = -b / (3 * a); + + const root1 = + 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3); + const root2 = + 2 * + Math.sqrt(-b / (3 * a)) * + Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3); + const root3 = + 2 * + Math.sqrt(-b / (3 * a)) * + Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3); + + roots.push(root1, root2, root3); + } + + return roots; +}; + +const findClosestParameter = ( + point: Point, + controlPoints: Curve, +) => { + // This function finds the parameter t that minimizes the distance between the point + // and any point on the cubic Bezier curve. + + const [p0, p1, p2, p3] = controlPoints; + + // Use the direct formula to find the parameter t + const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0]; + const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0]; + const c = 3 * p1[0] - 3 * p0[0]; + const d = p0[0] - point[0]; + + const rootsX = solveCubic(a, b, c, d); + + // Do the same for the y-coordinate + const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1]; + const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1]; + const g = 3 * p1[1] - 3 * p0[1]; + const h = p0[1] - point[1]; + + const rootsY = solveCubic(e, f, g, h); + + // Select the real root that is between 0 and 1 (inclusive) + const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1); + const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1); + + if (validRootsX.length === 0 || validRootsY.length === 0) { + // No valid roots found, use the midpoint as a fallback + return 0.5; + } + + // Choose the parameter t that minimizes the distance + let minDistance = Infinity; + let closestT = 0; + + for (const rootX of validRootsX) { + for (const rootY of validRootsY) { + const distance = Math.sqrt( + (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2, + ); + if (distance < minDistance) { + minDistance = distance; + closestT = (rootX + rootY) / 2; // Use the average for a smoother result + } + } + } + + return closestT; +}; diff --git a/packages/excalidraw/tests/geometricAlgebra.test.ts b/packages/math/ga/ga.test.ts similarity index 90% rename from packages/excalidraw/tests/geometricAlgebra.test.ts rename to packages/math/ga/ga.test.ts index 59279073d..767b5b65b 100644 --- a/packages/excalidraw/tests/geometricAlgebra.test.ts +++ b/packages/math/ga/ga.test.ts @@ -1,8 +1,8 @@ -import * as GA from "../ga"; -import { point, toString, direction, offset } from "../ga"; -import * as GAPoint from "../gapoints"; -import * as GALine from "../galines"; -import * as GATransform from "../gatransforms"; +import * as GA from "./ga"; +import { point, toString, direction, offset } from "./ga"; +import * as GAPoint from "./gapoints"; +import * as GALine from "./galines"; +import * as GATransform from "./gatransforms"; describe("geometric algebra", () => { describe("points", () => { diff --git a/packages/excalidraw/ga.ts b/packages/math/ga/ga.ts similarity index 100% rename from packages/excalidraw/ga.ts rename to packages/math/ga/ga.ts diff --git a/packages/excalidraw/gadirections.ts b/packages/math/ga/gadirections.ts similarity index 100% rename from packages/excalidraw/gadirections.ts rename to packages/math/ga/gadirections.ts diff --git a/packages/excalidraw/galines.ts b/packages/math/ga/galines.ts similarity index 100% rename from packages/excalidraw/galines.ts rename to packages/math/ga/galines.ts diff --git a/packages/excalidraw/gapoints.ts b/packages/math/ga/gapoints.ts similarity index 100% rename from packages/excalidraw/gapoints.ts rename to packages/math/ga/gapoints.ts diff --git a/packages/excalidraw/gatransforms.ts b/packages/math/ga/gatransforms.ts similarity index 100% rename from packages/excalidraw/gatransforms.ts rename to packages/math/ga/gatransforms.ts diff --git a/packages/math/index.ts b/packages/math/index.ts new file mode 100644 index 000000000..05ec5158f --- /dev/null +++ b/packages/math/index.ts @@ -0,0 +1,12 @@ +export * from "./arc"; +export * from "./angle"; +export * from "./curve"; +export * from "./line"; +export * from "./point"; +export * from "./polygon"; +export * from "./range"; +export * from "./segment"; +export * from "./triangle"; +export * from "./types"; +export * from "./vector"; +export * from "./utils"; diff --git a/packages/math/line.ts b/packages/math/line.ts new file mode 100644 index 000000000..c646e04d4 --- /dev/null +++ b/packages/math/line.ts @@ -0,0 +1,52 @@ +import { pointCenter, pointRotateRads } from "./point"; +import type { GlobalPoint, Line, LocalPoint, Radians } from "./types"; + +/** + * Create a line from two points. + * + * @param points The two points lying on the line + * @returns The line on which the points lie + */ +export function line

(a: P, b: P): Line

{ + return [a, b] as Line

; +} + +/** + * Convenient point creation from an array of two points. + * + * @param param0 The array with the two points to convert to a line + * @returns The created line + */ +export function lineFromPointPair

([a, b]: [ + P, + P, +]): Line

{ + return line(a, b); +} + +/** + * TODO + * + * @param pointArray + * @returns + */ +export function lineFromPointArray

( + pointArray: P[], +): Line

| undefined { + return pointArray.length === 2 + ? line

(pointArray[0], pointArray[1]) + : undefined; +} + +// return the coordinates resulting from rotating the given line about an origin by an angle in degrees +// note that when the origin is not given, the midpoint of the given line is used as the origin +export const lineRotate = ( + l: Line, + angle: Radians, + origin?: Point, +): Line => { + return line( + pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle), + pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), + ); +}; diff --git a/packages/math/package.json b/packages/math/package.json new file mode 100644 index 000000000..b6c87e8f3 --- /dev/null +++ b/packages/math/package.json @@ -0,0 +1,61 @@ +{ + "name": "@excalidraw/math", + "version": "0.1.0", + "main": "./dist/prod/index.js", + "type": "module", + "module": "./dist/prod/index.js", + "exports": { + ".": { + "development": "./dist/dev/index.js", + "default": "./dist/prod/index.js" + } + }, + "types": "./dist/utils/index.d.ts", + "files": [ + "dist/*" + ], + "description": "Excalidraw math functions", + "publishConfig": { + "access": "public" + }, + "license": "MIT", + "keywords": [ + "excalidraw", + "excalidraw-math", + "math", + "vector", + "algebra", + "2d" + ], + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all", + "not safari < 12", + "not kaios <= 2.5", + "not edge < 79", + "not chrome < 70", + "not and_uc < 13", + "not samsung < 10" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "bugs": "https://github.com/excalidraw/excalidraw/issues", + "repository": "https://github.com/excalidraw/excalidraw", + "dependencies": { + "@excalidraw/utils": "*" + }, + "scripts": { + "gen:types": "rm -rf types && tsc", + "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", + "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types", + "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", + "pack": "yarn build:umd && yarn pack" + } +} diff --git a/packages/math/point.test.ts b/packages/math/point.test.ts new file mode 100644 index 000000000..77ea06c93 --- /dev/null +++ b/packages/math/point.test.ts @@ -0,0 +1,24 @@ +import { point, pointRotateRads } from "./point"; +import type { Radians } from "./types"; + +describe("rotate", () => { + it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => { + const x1 = 10; + const y1 = 20; + const x2 = 20; + const y2 = 30; + const angle = (Math.PI / 2) as Radians; + const [rotatedX, rotatedY] = pointRotateRads( + point(x1, y1), + point(x2, y2), + angle, + ); + expect([rotatedX, rotatedY]).toEqual([30, 20]); + const res2 = pointRotateRads( + point(rotatedX, rotatedY), + point(x2, y2), + -angle as Radians, + ); + expect(res2).toEqual([x1, x2]); + }); +}); diff --git a/packages/math/point.ts b/packages/math/point.ts new file mode 100644 index 000000000..97b574270 --- /dev/null +++ b/packages/math/point.ts @@ -0,0 +1,257 @@ +import { degreesToRadians } from "./angle"; +import type { + LocalPoint, + GlobalPoint, + Radians, + Degrees, + Vector, +} from "./types"; +import { PRECISION } from "./utils"; +import { vectorFromPoint, vectorScale } from "./vector"; + +/** + * Create a properly typed Point instance from the X and Y coordinates. + * + * @param x The X coordinate + * @param y The Y coordinate + * @returns The branded and created point + */ +export function point( + x: number, + y: number, +): Point { + return [x, y] as Point; +} + +/** + * Converts and remaps an array containing a pair of numbers to Point. + * + * @param numberArray The number array to check and to convert to Point + * @returns The point instance + */ +export function pointFromArray( + numberArray: number[], +): Point | undefined { + return numberArray.length === 2 + ? point(numberArray[0], numberArray[1]) + : undefined; +} + +/** + * Converts and remaps a pair of numbers to Point. + * + * @param pair A number pair to convert to Point + * @returns The point instance + */ +export function pointFromPair( + pair: [number, number], +): Point { + return pair as Point; +} + +/** + * Convert a vector to a point. + * + * @param v The vector to convert + * @returns The point the vector points at with origin 0,0 + */ +export function pointFromVector

( + v: Vector, +): P { + return v as unknown as P; +} + +/** + * Checks if the provided value has the shape of a Point. + * + * @param p The value to attempt verification on + * @returns TRUE if the provided value has the shape of a local or global point + */ +export function isPoint(p: unknown): p is LocalPoint | GlobalPoint { + return ( + Array.isArray(p) && + p.length === 2 && + typeof p[0] === "number" && + !isNaN(p[0]) && + typeof p[1] === "number" && + !isNaN(p[1]) + ); +} + +/** + * Compare two points coordinate-by-coordinate and if + * they are closer than INVERSE_PRECISION it returns TRUE. + * + * @param a Point The first point to compare + * @param b Point The second point to compare + * @returns TRUE if the points are sufficiently close to each other + */ +export function pointsEqual( + a: Point, + b: Point, +): boolean { + const abs = Math.abs; + return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION; +} + +/** + * Roate a point by [angle] radians. + * + * @param point The point to rotate + * @param center The point to rotate around, the center point + * @param angle The radians to rotate the point by + * @returns The rotated point + */ +export function pointRotateRads( + [x, y]: Point, + [cx, cy]: Point, + angle: Radians, +): Point { + return point( + (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, + (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, + ); +} + +/** + * Roate a point by [angle] degree. + * + * @param point The point to rotate + * @param center The point to rotate around, the center point + * @param angle The degree to rotate the point by + * @returns The rotated point + */ +export function pointRotateDegs( + point: Point, + center: Point, + angle: Degrees, +): Point { + return pointRotateRads(point, center, degreesToRadians(angle)); +} + +/** + * Translate a point by a vector. + * + * WARNING: This is not for translating Excalidraw element points! + * You need to account for rotation on base coordinates + * on your own. + * CONSIDER USING AN APPROPRIATE ELEMENT-AWARE TRANSLATE! + * + * @param p The point to apply the translation on + * @param v The vector to translate by + * @returns + */ +// TODO 99% of use is translating between global and local coords, which need to be formalized +export function pointTranslate< + From extends GlobalPoint | LocalPoint, + To extends GlobalPoint | LocalPoint, +>(p: From, v: Vector = [0, 0] as Vector): To { + return point(p[0] + v[0], p[1] + v[1]); +} + +/** + * Find the center point at equal distance from both points. + * + * @param a One of the points to create the middle point for + * @param b The other point to create the middle point for + * @returns The middle point + */ +export function pointCenter

(a: P, b: P): P { + return point((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); +} + +/** + * Add together two points by their coordinates like you'd apply a translation + * to a point by a vector. + * + * @param a One point to act as a basis + * @param b The other point to act like the vector to translate by + * @returns + */ +export function pointAdd( + a: Point, + b: Point, +): Point { + return point(a[0] + b[0], a[1] + b[1]); +} + +/** + * Subtract a point from another point like you'd translate a point by an + * invese vector. + * + * @param a The point to translate + * @param b The point which will act like a vector + * @returns The resulting point + */ +export function pointSubtract( + a: Point, + b: Point, +): Point { + return point(a[0] - b[0], a[1] - b[1]); +} + +/** + * Calculate the distance between two points. + * + * @param a First point + * @param b Second point + * @returns The euclidean distance between the two points. + */ +export function pointDistance

( + a: P, + b: P, +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +/** + * Calculate the squared distance between two points. + * + * Note: Use this if you only compare distances, it saves a square root. + * + * @param a First point + * @param b Second point + * @returns The euclidean distance between the two points. + */ +export function pointDistanceSq

( + a: P, + b: P, +): number { + return Math.hypot(b[0] - a[0], b[1] - a[1]); +} + +/** + * Scale a point from a given origin by the multiplier. + * + * @param p The point to scale + * @param mid The origin to scale from + * @param multiplier The scaling factor + * @returns + */ +export const pointScaleFromOrigin =

( + p: P, + mid: P, + multiplier: number, +) => pointTranslate(mid, vectorScale(vectorFromPoint(p, mid), multiplier)); + +/** + * Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`. + * This is an approximation to "does `q` lie on a segment `pr`" check. + * + * @param p The first point to compare against + * @param q The actual point this function checks whether is in between + * @param r The other point to compare against + * @returns TRUE if q is indeed between p and r + */ +export const isPointWithinBounds =

( + p: P, + q: P, + r: P, +) => { + return ( + q[0] <= Math.max(p[0], r[0]) && + q[0] >= Math.min(p[0], r[0]) && + q[1] <= Math.max(p[1], r[1]) && + q[1] >= Math.min(p[1], r[1]) + ); +}; diff --git a/packages/math/polygon.ts b/packages/math/polygon.ts new file mode 100644 index 000000000..783bc4cf3 --- /dev/null +++ b/packages/math/polygon.ts @@ -0,0 +1,72 @@ +import { pointsEqual } from "./point"; +import { lineSegment, pointOnLineSegment } from "./segment"; +import type { GlobalPoint, LocalPoint, Polygon } from "./types"; +import { PRECISION } from "./utils"; + +export function polygon( + ...points: Point[] +) { + return polygonClose(points) as Polygon; +} + +export function polygonFromPoints( + points: Point[], +) { + return polygonClose(points) as Polygon; +} + +export const polygonIncludesPoint = ( + point: Point, + polygon: Polygon, +) => { + const x = point[0]; + const y = point[1]; + let inside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i][0]; + const yi = polygon[i][1]; + const xj = polygon[j][0]; + const yj = polygon[j][1]; + + if ( + ((yi > y && yj <= y) || (yi <= y && yj > y)) && + x < ((xj - xi) * (y - yi)) / (yj - yi) + xi + ) { + inside = !inside; + } + } + + return inside; +}; + +export const pointOnPolygon = ( + p: Point, + poly: Polygon, + threshold = PRECISION, +) => { + let on = false; + + for (let i = 0, l = poly.length - 1; i < l; i++) { + if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) { + on = true; + break; + } + } + + return on; +}; + +function polygonClose( + polygon: Point[], +) { + return polygonIsClosed(polygon) + ? polygon + : ([...polygon, polygon[0]] as Polygon); +} + +function polygonIsClosed( + polygon: Point[], +) { + return pointsEqual(polygon[0], polygon[polygon.length - 1]); +} diff --git a/packages/math/range.test.ts b/packages/math/range.test.ts new file mode 100644 index 000000000..fb4b6a38d --- /dev/null +++ b/packages/math/range.test.ts @@ -0,0 +1,51 @@ +import { rangeInclusive, rangeIntersection, rangesOverlap } from "./range"; + +describe("range overlap", () => { + const range1_4 = rangeInclusive(1, 4); + + it("should overlap when range a contains range b", () => { + expect(rangesOverlap(range1_4, rangeInclusive(2, 3))).toBe(true); + expect(rangesOverlap(range1_4, range1_4)).toBe(true); + expect(rangesOverlap(range1_4, rangeInclusive(1, 3))).toBe(true); + expect(rangesOverlap(range1_4, rangeInclusive(2, 4))).toBe(true); + }); + + it("should overlap when range b contains range a", () => { + expect(rangesOverlap(rangeInclusive(2, 3), range1_4)).toBe(true); + expect(rangesOverlap(rangeInclusive(1, 3), range1_4)).toBe(true); + expect(rangesOverlap(rangeInclusive(2, 4), range1_4)).toBe(true); + }); + + it("should overlap when range a and b intersect", () => { + expect(rangesOverlap(range1_4, rangeInclusive(2, 5))).toBe(true); + }); +}); + +describe("range intersection", () => { + const range1_4 = rangeInclusive(1, 4); + + it("should intersect completely with itself", () => { + expect(rangeIntersection(range1_4, range1_4)).toEqual(range1_4); + }); + + it("should intersect irrespective of order", () => { + expect(rangeIntersection(range1_4, rangeInclusive(2, 3))).toEqual([2, 3]); + expect(rangeIntersection(rangeInclusive(2, 3), range1_4)).toEqual([2, 3]); + expect(rangeIntersection(range1_4, rangeInclusive(3, 5))).toEqual( + rangeInclusive(3, 4), + ); + expect(rangeIntersection(rangeInclusive(3, 5), range1_4)).toEqual( + rangeInclusive(3, 4), + ); + }); + + it("should intersect at the edge", () => { + expect(rangeIntersection(range1_4, rangeInclusive(4, 5))).toEqual( + rangeInclusive(4, 4), + ); + }); + + it("should not intersect", () => { + expect(rangeIntersection(range1_4, rangeInclusive(5, 7))).toEqual(null); + }); +}); diff --git a/packages/math/range.ts b/packages/math/range.ts new file mode 100644 index 000000000..314d1c8ae --- /dev/null +++ b/packages/math/range.ts @@ -0,0 +1,82 @@ +import { toBrandedType } from "../excalidraw/utils"; +import type { InclusiveRange } from "./types"; + +/** + * Create an inclusive range from the two numbers provided. + * + * @param start Start of the range + * @param end End of the range + * @returns + */ +export function rangeInclusive(start: number, end: number): InclusiveRange { + return toBrandedType([start, end]); +} + +/** + * Turn a number pair into an inclusive range. + * + * @param pair The number pair to convert to an inclusive range + * @returns The new inclusive range + */ +export function rangeInclusiveFromPair(pair: [start: number, end: number]) { + return toBrandedType(pair); +} + +/** + * Given two ranges, return if the two ranges overlap with each other e.g. + * [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]. + * + * @param param0 One of the ranges to compare + * @param param1 The other range to compare against + * @returns TRUE if the ranges overlap + */ +export const rangesOverlap = ( + [a0, a1]: InclusiveRange, + [b0, b1]: InclusiveRange, +): boolean => { + if (a0 <= b0) { + return a1 >= b0; + } + + if (a0 >= b0) { + return b1 >= a0; + } + + return false; +}; + +/** + * Given two ranges,return ther intersection of the two ranges if any e.g. the + * intersection of [1, 3] and [2, 4] is [2, 3]. + * + * @param param0 The first range to compare + * @param param1 The second range to compare + * @returns The inclusive range intersection or NULL if no intersection + */ +export const rangeIntersection = ( + [a0, a1]: InclusiveRange, + [b0, b1]: InclusiveRange, +): InclusiveRange | null => { + const rangeStart = Math.max(a0, b0); + const rangeEnd = Math.min(a1, b1); + + if (rangeStart <= rangeEnd) { + return toBrandedType([rangeStart, rangeEnd]); + } + + return null; +}; + +/** + * Determine if a value is inside a range. + * + * @param value The value to check + * @param range The range + * @returns + */ +export const rangeIncludesValue = ( + value: number, + [min, max]: InclusiveRange, +): boolean => { + return value >= min && value <= max; +}; diff --git a/packages/math/segment.ts b/packages/math/segment.ts new file mode 100644 index 000000000..6c0c2de34 --- /dev/null +++ b/packages/math/segment.ts @@ -0,0 +1,158 @@ +import { + isPoint, + pointCenter, + pointFromVector, + pointRotateRads, +} from "./point"; +import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types"; +import { PRECISION } from "./utils"; +import { + vectorAdd, + vectorCross, + vectorFromPoint, + vectorScale, + vectorSubtract, +} from "./vector"; + +/** + * Create a line segment from two points. + * + * @param points The two points delimiting the line segment on each end + * @returns The line segment delineated by the points + */ +export function lineSegment

( + a: P, + b: P, +): LineSegment

{ + return [a, b] as LineSegment

; +} + +export function lineSegmentFromPointArray

( + pointArray: P[], +): LineSegment

| undefined { + return pointArray.length === 2 + ? lineSegment

(pointArray[0], pointArray[1]) + : undefined; +} + +/** + * + * @param segment + * @returns + */ +export const isLineSegment = ( + segment: unknown, +): segment is LineSegment => + Array.isArray(segment) && + segment.length === 2 && + isPoint(segment[0]) && + isPoint(segment[0]); + +/** + * Return the coordinates resulting from rotating the given line about an origin by an angle in radians + * note that when the origin is not given, the midpoint of the given line is used as the origin. + * + * @param l + * @param angle + * @param origin + * @returns + */ +export const lineSegmentRotate = ( + l: LineSegment, + angle: Radians, + origin?: Point, +): LineSegment => { + return lineSegment( + pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle), + pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), + ); +}; + +/** + * Calculates the point two line segments with a definite start and end point + * intersect at. + */ +export const segmentsIntersectAt = ( + a: Readonly>, + b: Readonly>, +): Point | null => { + const a0 = vectorFromPoint(a[0]); + const a1 = vectorFromPoint(a[1]); + const b0 = vectorFromPoint(b[0]); + const b1 = vectorFromPoint(b[1]); + const r = vectorSubtract(a1, a0); + const s = vectorSubtract(b1, b0); + const denominator = vectorCross(r, s); + + if (denominator === 0) { + return null; + } + + const i = vectorSubtract(vectorFromPoint(b[0]), vectorFromPoint(a[0])); + const u = vectorCross(i, r) / denominator; + const t = vectorCross(i, s) / denominator; + + if (u === 0) { + return null; + } + + const p = vectorAdd(a0, vectorScale(r, t)); + + if (t >= 0 && t < 1 && u >= 0 && u < 1) { + return pointFromVector(p); + } + + return null; +}; + +export const pointOnLineSegment = ( + point: Point, + line: LineSegment, + threshold = PRECISION, +) => { + const distance = distanceToLineSegment(point, line); + + if (distance === 0) { + return true; + } + + return distance < threshold; +}; + +export const distanceToLineSegment = ( + point: Point, + line: LineSegment, +) => { + const [x, y] = point; + const [[x1, y1], [x2, y2]] = line; + + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const len_sq = C * C + D * D; + let param = -1; + if (len_sq !== 0) { + param = dot / len_sq; + } + + let xx; + let yy; + + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + const dx = x - xx; + const dy = y - yy; + return Math.sqrt(dx * dx + dy * dy); +}; diff --git a/packages/math/triangle.ts b/packages/math/triangle.ts new file mode 100644 index 000000000..bc74372b7 --- /dev/null +++ b/packages/math/triangle.ts @@ -0,0 +1,28 @@ +import type { GlobalPoint, LocalPoint, Triangle } from "./types"; + +// Types + +/** + * Tests if a point lies inside a triangle. This function + * will return FALSE if the point lies exactly on the sides + * of the triangle. + * + * @param triangle The triangle to test the point for + * @param p The point to test whether is in the triangle + * @returns TRUE if the point is inside of the triangle + */ +export function triangleIncludesPoint

( + [a, b, c]: Triangle

, + p: P, +): boolean { + const triangleSign = (p1: P, p2: P, p3: P) => + (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]); + const d1 = triangleSign(p, a, b); + const d2 = triangleSign(p, b, c); + const d3 = triangleSign(p, c, a); + + const has_neg = d1 < 0 || d2 < 0 || d3 < 0; + const has_pos = d1 > 0 || d2 > 0 || d3 > 0; + + return !(has_neg && has_pos); +} diff --git a/packages/math/types.ts b/packages/math/types.ts new file mode 100644 index 000000000..138a44bc0 --- /dev/null +++ b/packages/math/types.ts @@ -0,0 +1,130 @@ +// +// Measurements +// + +/** + * By definition one radian is the angle subtended at the centre + * of a circle by an arc that is equal in length to the radius. + */ +export type Radians = number & { _brand: "excalimath__radian" }; + +/** + * An angle measurement of a plane angle in which one full + * rotation is 360 degrees. + */ +export type Degrees = number & { _brand: "excalimath_degree" }; + +// +// Range +// + +/** + * A number range which includes the start and end numbers in the range. + */ +export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" }; + +// +// Point +// + +/** + * Represents a 2D position in world or canvas space. A + * global coordinate. + */ +export type GlobalPoint = [x: number, y: number] & { + _brand: "excalimath__globalpoint"; +}; + +/** + * Represents a 2D position in whatever local space it's + * needed. A local coordinate. + */ +export type LocalPoint = [x: number, y: number] & { + _brand: "excalimath__localpoint"; +}; + +// Line + +/** + * A line is an infinitely long object with no width, depth, or curvature. + */ +export type Line

= [p: P, q: P] & { + _brand: "excalimath_line"; +}; + +/** + * In geometry, a line segment is a part of a straight + * line that is bounded by two distinct end points, and + * contains every point on the line that is between its endpoints. + */ +export type LineSegment

= [a: P, b: P] & { + _brand: "excalimath_linesegment"; +}; + +// +// Vector +// + +/** + * Represents a 2D vector + */ +export type Vector = [u: number, v: number] & { + _brand: "excalimath__vector"; +}; + +// Triangles + +/** + * A triangle represented by 3 points + */ +export type Triangle

= [ + a: P, + b: P, + c: P, +] & { + _brand: "excalimath__triangle"; +}; + +// +// Polygon +// + +/** + * A polygon is a closed shape by connecting the given points + * rectangles and diamonds are modelled by polygons + */ +export type Polygon = Point[] & { + _brand: "excalimath_polygon"; +}; + +// +// Curve +// + +/** + * Cubic bezier curve with four control points + */ +export type Curve = [ + Point, + Point, + Point, + Point, +] & { + _brand: "excalimath_curve"; +}; + +export type PolarCoords = [ + radius: number, + /** angle in radians */ + angle: number, +]; + +/** + * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle + * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right". + */ +export type SymmetricArc = { + radius: number; + startAngle: number; + endAngle: number; +}; diff --git a/packages/math/utils.ts b/packages/math/utils.ts new file mode 100644 index 000000000..f4d90704f --- /dev/null +++ b/packages/math/utils.ts @@ -0,0 +1,17 @@ +export const PRECISION = 10e-5; + +export function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +export function round(value: number, precision: number) { + const multiplier = Math.pow(10, precision); + + return Math.round((value + Number.EPSILON) * multiplier) / multiplier; +} + +export const average = (a: number, b: number) => (a + b) / 2; + +export const isFiniteNumber = (value: any): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; diff --git a/packages/math/vector.test.ts b/packages/math/vector.test.ts new file mode 100644 index 000000000..145c90953 --- /dev/null +++ b/packages/math/vector.test.ts @@ -0,0 +1,12 @@ +import { isVector } from "."; + +describe("Vector", () => { + test("isVector", () => { + expect(isVector([5, 5])).toBe(true); + expect(isVector([-5, -5])).toBe(true); + expect(isVector([5, 0.5])).toBe(true); + expect(isVector(null)).toBe(false); + expect(isVector(undefined)).toBe(false); + expect(isVector([5, NaN])).toBe(false); + }); +}); diff --git a/packages/math/vector.ts b/packages/math/vector.ts new file mode 100644 index 000000000..9b640b243 --- /dev/null +++ b/packages/math/vector.ts @@ -0,0 +1,141 @@ +import type { GlobalPoint, LocalPoint, Vector } from "./types"; + +/** + * Create a vector from the x and y coordiante elements. + * + * @param x The X aspect of the vector + * @param y T Y aspect of the vector + * @returns The constructed vector with X and Y as the coordinates + */ +export function vector( + x: number, + y: number, + originX: number = 0, + originY: number = 0, +): Vector { + return [x - originX, y - originY] as Vector; +} + +/** + * Turn a point into a vector with the origin point. + * + * @param p The point to turn into a vector + * @param origin The origin point in a given coordiante system + * @returns The created vector from the point and the origin + */ +export function vectorFromPoint( + p: Point, + origin: Point = [0, 0] as Point, +): Vector { + return vector(p[0] - origin[0], p[1] - origin[1]); +} + +/** + * Cross product is a binary operation on two vectors in 2D space. + * It results in a vector that is perpendicular to both vectors. + * + * @param a One of the vectors to use for the directed area calculation + * @param b The other vector to use for the directed area calculation + * @returns The directed area value for the two vectos + */ +export function vectorCross(a: Vector, b: Vector): number { + return a[0] * b[1] - b[0] * a[1]; +} + +/** + * Dot product is defined as the sum of the products of the + * two vectors. + * + * @param a One of the vectors for which the sum of products is calculated + * @param b The other vector for which the sum of products is calculated + * @returns The sum of products of the two vectors + */ +export function vectorDot(a: Vector, b: Vector) { + return a[0] * b[0] + a[1] * b[1]; +} + +/** + * Determines if the value has the shape of a Vector. + * + * @param v The value to test + * @returns TRUE if the value has the shape and components of a Vectors + */ +export function isVector(v: unknown): v is Vector { + return ( + Array.isArray(v) && + v.length === 2 && + typeof v[0] === "number" && + !isNaN(v[0]) && + typeof v[1] === "number" && + !isNaN(v[1]) + ); +} + +/** + * Add two vectors by adding their coordinates. + * + * @param a One of the vectors to add + * @param b The other vector to add + * @returns The sum vector of the two provided vectors + */ +export function vectorAdd(a: Readonly, b: Readonly): Vector { + return [a[0] + b[0], a[1] + b[1]] as Vector; +} + +/** + * Add two vectors by adding their coordinates. + * + * @param start One of the vectors to add + * @param end The other vector to add + * @returns The sum vector of the two provided vectors + */ +export function vectorSubtract( + start: Readonly, + end: Readonly, +): Vector { + return [start[0] - end[0], start[1] - end[1]] as Vector; +} + +/** + * Scale vector by a scalar. + * + * @param v The vector to scale + * @param scalar The scalar to multiply the vector components with + * @returns The new scaled vector + */ +export function vectorScale(v: Vector, scalar: number): Vector { + return vector(v[0] * scalar, v[1] * scalar); +} + +/** + * Calculates the sqare magnitude of a vector. Use this if you compare + * magnitudes as it saves you an SQRT. + * + * @param v The vector to measure + * @returns The scalar squared magnitude of the vector + */ +export function vectorMagnitudeSq(v: Vector) { + return v[0] * v[0] + v[1] * v[1]; +} + +/** + * Calculates the magnitude of a vector. + * + * @param v The vector to measure + * @returns The scalar magnitude of the vector + */ +export function vectorMagnitude(v: Vector) { + return Math.sqrt(vectorMagnitudeSq(v)); +} + +/** + * Normalize the vector (i.e. make the vector magnitue equal 1). + * + * @param v The vector to normalize + * @returns The new normalized vector + */ +export const vectorNormalize = (v: Vector): Vector => { + const m = vectorMagnitude(v); + + return vector(v[0] / m, v[1] / m); +}; diff --git a/packages/math/webpack.prod.config.js b/packages/math/webpack.prod.config.js new file mode 100644 index 000000000..410d67510 --- /dev/null +++ b/packages/math/webpack.prod.config.js @@ -0,0 +1,55 @@ +const webpack = require("webpack"); +const path = require("path"); +const BundleAnalyzerPlugin = + require("webpack-bundle-analyzer").BundleAnalyzerPlugin; + +module.exports = { + mode: "production", + entry: { "excalidraw-math.min": "./index.js" }, + output: { + path: path.resolve(__dirname, "dist"), + filename: "[name].js", + library: "ExcalidrawMath", + libraryTarget: "umd", + }, + resolve: { + extensions: [".tsx", ".ts", ".js", ".css", ".scss"], + }, + optimization: { + runtimeChunk: false, + }, + module: { + rules: [ + { + test: /\.(ts|tsx|js)$/, + use: [ + { + loader: "ts-loader", + options: { + transpileOnly: true, + configFile: path.resolve(__dirname, "../tsconfig.prod.json"), + }, + }, + { + loader: "babel-loader", + + options: { + presets: [ + "@babel/preset-env", + ["@babel/preset-react", { runtime: "automatic" }], + "@babel/preset-typescript", + ], + plugins: [["@babel/plugin-transform-runtime"]], + }, + }, + ], + }, + ], + }, + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []), + ], +}; diff --git a/packages/utils/bbox.ts b/packages/utils/bbox.ts index e662a5a8c..933c630c8 100644 --- a/packages/utils/bbox.ts +++ b/packages/utils/bbox.ts @@ -1,9 +1,16 @@ +import { + vectorCross, + vectorFromPoint, + type GlobalPoint, + type LocalPoint, +} from "../math"; import type { Bounds } from "../excalidraw/element/bounds"; -import type { Point } from "../excalidraw/types"; -export type LineSegment = [Point, Point]; +export type LineSegment

= [P, P]; -export function getBBox(line: LineSegment): Bounds { +export function getBBox

( + line: LineSegment

, +): Bounds { return [ Math.min(line[0][0], line[1][0]), Math.min(line[0][1], line[1][1]), @@ -12,40 +19,37 @@ export function getBBox(line: LineSegment): Bounds { ]; } -export function crossProduct(a: Point, b: Point) { - return a[0] * b[1] - b[0] * a[1]; -} - export function doBBoxesIntersect(a: Bounds, b: Bounds) { return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]; } -export function translate(a: Point, b: Point): Point { - return [a[0] - b[0], a[1] - b[1]]; -} - const EPSILON = 0.000001; -export function isPointOnLine(l: LineSegment, p: Point) { - const p1 = translate(l[1], l[0]); - const p2 = translate(p, l[0]); +export function isPointOnLine

( + l: LineSegment

, + p: P, +) { + const p1 = vectorFromPoint(l[1], l[0]); + const p2 = vectorFromPoint(p, l[0]); - const r = crossProduct(p1, p2); + const r = vectorCross(p1, p2); return Math.abs(r) < EPSILON; } -export function isPointRightOfLine(l: LineSegment, p: Point) { - const p1 = translate(l[1], l[0]); - const p2 = translate(p, l[0]); +export function isPointRightOfLine

( + l: LineSegment

, + p: P, +) { + const p1 = vectorFromPoint(l[1], l[0]); + const p2 = vectorFromPoint(p, l[0]); - return crossProduct(p1, p2) < 0; + return vectorCross(p1, p2) < 0; } -export function isLineSegmentTouchingOrCrossingLine( - a: LineSegment, - b: LineSegment, -) { +export function isLineSegmentTouchingOrCrossingLine< + P extends GlobalPoint | LocalPoint, +>(a: LineSegment

, b: LineSegment

) { return ( isPointOnLine(a, b[0]) || isPointOnLine(a, b[1]) || @@ -56,7 +60,10 @@ export function isLineSegmentTouchingOrCrossingLine( } // https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/ -export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) { +export function doLineSegmentsIntersect

( + a: LineSegment

, + b: LineSegment

, +) { return ( doBBoxesIntersect(getBBox(a), getBBox(b)) && isLineSegmentTouchingOrCrossingLine(a, b) && diff --git a/packages/utils/collision.test.ts b/packages/utils/collision.test.ts new file mode 100644 index 000000000..398c3cb68 --- /dev/null +++ b/packages/utils/collision.test.ts @@ -0,0 +1,87 @@ +import type { Curve, Degrees, GlobalPoint } from "../math"; +import { + curve, + degreesToRadians, + lineSegment, + lineSegmentRotate, + point, + pointRotateDegs, +} from "../math"; +import { pointOnCurve, pointOnPolyline } from "./collision"; +import type { Polyline } from "./geometry/shape"; + +describe("point and curve", () => { + const c: Curve = curve( + point(1.4, 1.65), + point(1.9, 7.9), + point(5.9, 1.65), + point(6.44, 4.84), + ); + + it("point on curve", () => { + expect(pointOnCurve(c[0], c, 10e-5)).toBe(true); + expect(pointOnCurve(c[3], c, 10e-5)).toBe(true); + + expect(pointOnCurve(point(2, 4), c, 0.1)).toBe(true); + expect(pointOnCurve(point(4, 4.4), c, 0.1)).toBe(true); + expect(pointOnCurve(point(5.6, 3.85), c, 0.1)).toBe(true); + + expect(pointOnCurve(point(5.6, 4), c, 0.1)).toBe(false); + expect(pointOnCurve(c[1], c, 0.1)).toBe(false); + expect(pointOnCurve(c[2], c, 0.1)).toBe(false); + }); +}); + +describe("point and polylines", () => { + const polyline: Polyline = [ + lineSegment(point(1, 0), point(1, 2)), + lineSegment(point(1, 2), point(2, 2)), + lineSegment(point(2, 2), point(2, 1)), + lineSegment(point(2, 1), point(3, 1)), + ]; + + it("point on the line", () => { + expect(pointOnPolyline(point(1, 0), polyline)).toBe(true); + expect(pointOnPolyline(point(1, 2), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 2), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 1), polyline)).toBe(true); + expect(pointOnPolyline(point(3, 1), polyline)).toBe(true); + + expect(pointOnPolyline(point(1, 1), polyline)).toBe(true); + expect(pointOnPolyline(point(2, 1.5), polyline)).toBe(true); + expect(pointOnPolyline(point(2.5, 1), polyline)).toBe(true); + + expect(pointOnPolyline(point(0, 1), polyline)).toBe(false); + expect(pointOnPolyline(point(2.1, 1.5), polyline)).toBe(false); + }); + + it("point on the line with rotation", () => { + const truePoints = [ + point(1, 0), + point(1, 2), + point(2, 2), + point(2, 1), + point(3, 1), + ]; + + truePoints.forEach((p) => { + const rotation = (Math.random() * 360) as Degrees; + const rotatedPoint = pointRotateDegs(p, point(0, 0), rotation); + const rotatedPolyline = polyline.map((line) => + lineSegmentRotate(line, degreesToRadians(rotation), point(0, 0)), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true); + }); + + const falsePoints = [point(0, 1), point(2.1, 1.5)]; + + falsePoints.forEach((p) => { + const rotation = (Math.random() * 360) as Degrees; + const rotatedPoint = pointRotateDegs(p, point(0, 0), rotation); + const rotatedPolyline = polyline.map((line) => + lineSegmentRotate(line, degreesToRadians(rotation), point(0, 0)), + ); + expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false); + }); + }); +}); diff --git a/packages/utils/collision.ts b/packages/utils/collision.ts index e36975399..126939737 100644 --- a/packages/utils/collision.ts +++ b/packages/utils/collision.ts @@ -1,20 +1,26 @@ -import type { Point, Polygon, GeometricShape } from "./geometry/shape"; +import type { Polycurve, Polyline } from "./geometry/shape"; import { pointInEllipse, - pointInPolygon, - pointOnCurve, pointOnEllipse, - pointOnLine, - pointOnPolycurve, + type GeometricShape, +} from "./geometry/shape"; +import type { Curve } from "../math"; +import { + lineSegment, + point, + polygonIncludesPoint, + pointOnLineSegment, pointOnPolygon, - pointOnPolyline, - close, -} from "./geometry/geometry"; + polygonFromPoints, + type GlobalPoint, + type LocalPoint, + type Polygon, +} from "../math"; // check if the given point is considered on the given shape's border -export const isPointOnShape = ( +export const isPointOnShape = ( point: Point, - shape: GeometricShape, + shape: GeometricShape, tolerance = 0, ) => { // get the distance from the given point to the given element @@ -25,7 +31,7 @@ export const isPointOnShape = ( case "ellipse": return pointOnEllipse(point, shape.data, tolerance); case "line": - return pointOnLine(point, shape.data, tolerance); + return pointOnLineSegment(point, shape.data, tolerance); case "polyline": return pointOnPolyline(point, shape.data, tolerance); case "curve": @@ -38,10 +44,13 @@ export const isPointOnShape = ( }; // check if the given point is considered inside the element's border -export const isPointInShape = (point: Point, shape: GeometricShape) => { +export const isPointInShape = ( + point: Point, + shape: GeometricShape, +) => { switch (shape.type) { case "polygon": - return pointInPolygon(point, shape.data); + return polygonIncludesPoint(point, shape.data); case "line": return false; case "curve": @@ -49,8 +58,8 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => { case "ellipse": return pointInEllipse(point, shape.data); case "polyline": { - const polygon = close(shape.data.flat()) as Polygon; - return pointInPolygon(point, polygon); + const polygon = polygonFromPoints(shape.data.flat()); + return polygonIncludesPoint(point, polygon); } case "polycurve": { return false; @@ -61,6 +70,67 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => { }; // check if the given element is in the given bounds -export const isPointInBounds = (point: Point, bounds: Polygon) => { - return pointInPolygon(point, bounds); +export const isPointInBounds = ( + point: Point, + bounds: Polygon, +) => { + return polygonIncludesPoint(point, bounds); +}; + +const pointOnPolycurve = ( + point: Point, + polycurve: Polycurve, + tolerance: number, +) => { + return polycurve.some((curve) => pointOnCurve(point, curve, tolerance)); +}; + +const cubicBezierEquation = ( + curve: Curve, +) => { + const [p0, p1, p2, p3] = curve; + // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 + return (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); +}; + +const polyLineFromCurve = ( + curve: Curve, + segments = 10, +): Polyline => { + const equation = cubicBezierEquation(curve); + let startingPoint = [equation(0, 0), equation(0, 1)] as Point; + const lineSegments: Polyline = []; + let t = 0; + const increment = 1 / segments; + + for (let i = 0; i < segments; i++) { + t += increment; + if (t <= 1) { + const nextPoint: Point = point(equation(t, 0), equation(t, 1)); + lineSegments.push(lineSegment(startingPoint, nextPoint)); + startingPoint = nextPoint; + } + } + + return lineSegments; +}; + +export const pointOnCurve = ( + point: Point, + curve: Curve, + threshold: number, +) => { + return pointOnPolyline(point, polyLineFromCurve(curve), threshold); +}; + +export const pointOnPolyline = ( + point: Point, + polyline: Polyline, + threshold = 10e-5, +) => { + return polyline.some((line) => pointOnLineSegment(point, line, threshold)); }; diff --git a/packages/utils/geometry/geometry.test.ts b/packages/utils/geometry/geometry.test.ts index 0a75aae53..6ee357d70 100644 --- a/packages/utils/geometry/geometry.test.ts +++ b/packages/utils/geometry/geometry.test.ts @@ -1,249 +1,122 @@ +import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math"; import { - lineIntersectsLine, - lineRotate, - pointInEllipse, - pointInPolygon, - pointLeftofLine, - pointOnCurve, - pointOnEllipse, - pointOnLine, + point, + lineSegment, + polygon, + pointOnLineSegment, pointOnPolygon, - pointOnPolyline, - pointRightofLine, - pointRotate, -} from "./geometry"; -import type { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape"; + polygonIncludesPoint, + segmentsIntersectAt, +} from "../../math"; +import { pointInEllipse, pointOnEllipse, type Ellipse } from "./shape"; describe("point and line", () => { - const line: Line = [ - [1, 0], - [1, 2], - ]; + // const l: Line = line(point(1, 0), point(1, 2)); - it("point on left or right of line", () => { - expect(pointLeftofLine([0, 1], line)).toBe(true); - expect(pointLeftofLine([1, 1], line)).toBe(false); - expect(pointLeftofLine([2, 1], line)).toBe(false); + // it("point on left or right of line", () => { + // expect(pointLeftofLine(point(0, 1), l)).toBe(true); + // expect(pointLeftofLine(point(1, 1), l)).toBe(false); + // expect(pointLeftofLine(point(2, 1), l)).toBe(false); - expect(pointRightofLine([0, 1], line)).toBe(false); - expect(pointRightofLine([1, 1], line)).toBe(false); - expect(pointRightofLine([2, 1], line)).toBe(true); - }); + // expect(pointRightofLine(point(0, 1), l)).toBe(false); + // expect(pointRightofLine(point(1, 1), l)).toBe(false); + // expect(pointRightofLine(point(2, 1), l)).toBe(true); + // }); + + const s: LineSegment = lineSegment(point(1, 0), point(1, 2)); it("point on the line", () => { - expect(pointOnLine([0, 1], line)).toBe(false); - expect(pointOnLine([1, 1], line, 0)).toBe(true); - expect(pointOnLine([2, 1], line)).toBe(false); - }); -}); - -describe("point and polylines", () => { - const polyline: Polyline = [ - [ - [1, 0], - [1, 2], - ], - [ - [1, 2], - [2, 2], - ], - [ - [2, 2], - [2, 1], - ], - [ - [2, 1], - [3, 1], - ], - ]; - - it("point on the line", () => { - expect(pointOnPolyline([1, 0], polyline)).toBe(true); - expect(pointOnPolyline([1, 2], polyline)).toBe(true); - expect(pointOnPolyline([2, 2], polyline)).toBe(true); - expect(pointOnPolyline([2, 1], polyline)).toBe(true); - expect(pointOnPolyline([3, 1], polyline)).toBe(true); - - expect(pointOnPolyline([1, 1], polyline)).toBe(true); - expect(pointOnPolyline([2, 1.5], polyline)).toBe(true); - expect(pointOnPolyline([2.5, 1], polyline)).toBe(true); - - expect(pointOnPolyline([0, 1], polyline)).toBe(false); - expect(pointOnPolyline([2.1, 1.5], polyline)).toBe(false); - }); - - it("point on the line with rotation", () => { - const truePoints = [ - [1, 0], - [1, 2], - [2, 2], - [2, 1], - [3, 1], - ] as Point[]; - - truePoints.forEach((point) => { - const rotation = Math.random() * 360; - const rotatedPoint = pointRotate(point, rotation); - const rotatedPolyline: Polyline = polyline.map((line) => - lineRotate(line, rotation, [0, 0]), - ); - expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true); - }); - - const falsePoints = [ - [0, 1], - [2.1, 1.5], - ] as Point[]; - - falsePoints.forEach((point) => { - const rotation = Math.random() * 360; - const rotatedPoint = pointRotate(point, rotation); - const rotatedPolyline: Polyline = polyline.map((line) => - lineRotate(line, rotation, [0, 0]), - ); - expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false); - }); + expect(pointOnLineSegment(point(0, 1), s)).toBe(false); + expect(pointOnLineSegment(point(1, 1), s, 0)).toBe(true); + expect(pointOnLineSegment(point(2, 1), s)).toBe(false); }); }); describe("point and polygon", () => { - const polygon: Polygon = [ - [10, 10], - [50, 10], - [50, 50], - [10, 50], - ]; + const poly: Polygon = polygon( + point(10, 10), + point(50, 10), + point(50, 50), + point(10, 50), + ); it("point on polygon", () => { - expect(pointOnPolygon([30, 10], polygon)).toBe(true); - expect(pointOnPolygon([50, 30], polygon)).toBe(true); - expect(pointOnPolygon([30, 50], polygon)).toBe(true); - expect(pointOnPolygon([10, 30], polygon)).toBe(true); - expect(pointOnPolygon([30, 30], polygon)).toBe(false); - expect(pointOnPolygon([30, 70], polygon)).toBe(false); + expect(pointOnPolygon(point(30, 10), poly)).toBe(true); + expect(pointOnPolygon(point(50, 30), poly)).toBe(true); + expect(pointOnPolygon(point(30, 50), poly)).toBe(true); + expect(pointOnPolygon(point(10, 30), poly)).toBe(true); + expect(pointOnPolygon(point(30, 30), poly)).toBe(false); + expect(pointOnPolygon(point(30, 70), poly)).toBe(false); }); it("point in polygon", () => { - const polygon: Polygon = [ - [0, 0], - [2, 0], - [2, 2], - [0, 2], - ]; - expect(pointInPolygon([1, 1], polygon)).toBe(true); - expect(pointInPolygon([3, 3], polygon)).toBe(false); - }); -}); - -describe("point and curve", () => { - const curve: Curve = [ - [1.4, 1.65], - [1.9, 7.9], - [5.9, 1.65], - [6.44, 4.84], - ]; - - it("point on curve", () => { - expect(pointOnCurve(curve[0], curve)).toBe(true); - expect(pointOnCurve(curve[3], curve)).toBe(true); - - expect(pointOnCurve([2, 4], curve, 0.1)).toBe(true); - expect(pointOnCurve([4, 4.4], curve, 0.1)).toBe(true); - expect(pointOnCurve([5.6, 3.85], curve, 0.1)).toBe(true); - - expect(pointOnCurve([5.6, 4], curve, 0.1)).toBe(false); - expect(pointOnCurve(curve[1], curve, 0.1)).toBe(false); - expect(pointOnCurve(curve[2], curve, 0.1)).toBe(false); + const poly: Polygon = polygon( + point(0, 0), + point(2, 0), + point(2, 2), + point(0, 2), + ); + expect(polygonIncludesPoint(point(1, 1), poly)).toBe(true); + expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false); }); }); describe("point and ellipse", () => { - const ellipse: Ellipse = { - center: [0, 0], - angle: 0, + const ellipse: Ellipse = { + center: point(0, 0), + angle: 0 as Radians, halfWidth: 2, halfHeight: 1, }; it("point on ellipse", () => { - [ - [0, 1], - [0, -1], - [2, 0], - [-2, 0], - ].forEach((point) => { - expect(pointOnEllipse(point as Point, ellipse)).toBe(true); + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointOnEllipse(p, ellipse)).toBe(true); }); - expect(pointOnEllipse([-1.4, 0.7], ellipse, 0.1)).toBe(true); - expect(pointOnEllipse([-1.4, 0.71], ellipse, 0.01)).toBe(true); + expect(pointOnEllipse(point(-1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-1.4, 0.71), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse([1.4, 0.7], ellipse, 0.1)).toBe(true); - expect(pointOnEllipse([1.4, 0.71], ellipse, 0.01)).toBe(true); + expect(pointOnEllipse(point(1.4, 0.7), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(1.4, 0.71), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse([1, -0.86], ellipse, 0.1)).toBe(true); - expect(pointOnEllipse([1, -0.86], ellipse, 0.01)).toBe(true); + expect(pointOnEllipse(point(1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(1, -0.86), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse([-1, -0.86], ellipse, 0.1)).toBe(true); - expect(pointOnEllipse([-1, -0.86], ellipse, 0.01)).toBe(true); + expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.1)).toBe(true); + expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true); - expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false); - expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false); + expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false); + expect(pointOnEllipse(point(1, -0.8), ellipse)).toBe(false); }); it("point in ellipse", () => { - [ - [0, 1], - [0, -1], - [2, 0], - [-2, 0], - ].forEach((point) => { - expect(pointInEllipse(point as Point, ellipse)).toBe(true); + [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + expect(pointInEllipse(p, ellipse)).toBe(true); }); - expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true); - expect(pointInEllipse([1, -0.8], ellipse)).toBe(true); + expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true); + expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true); - expect(pointInEllipse([-1, 1], ellipse)).toBe(false); - expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false); + expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false); + expect(pointInEllipse(point(-1.4, 0.8), ellipse)).toBe(false); }); }); describe("line and line", () => { - const lineA: Line = [ - [1, 4], - [3, 4], - ]; - const lineB: Line = [ - [2, 1], - [2, 7], - ]; - const lineC: Line = [ - [1, 8], - [3, 8], - ]; - const lineD: Line = [ - [1, 8], - [3, 8], - ]; - const lineE: Line = [ - [1, 9], - [3, 9], - ]; - const lineF: Line = [ - [1, 2], - [3, 4], - ]; - const lineG: Line = [ - [0, 1], - [2, 3], - ]; + const lineA: LineSegment = lineSegment(point(1, 4), point(3, 4)); + const lineB: LineSegment = lineSegment(point(2, 1), point(2, 7)); + const lineC: LineSegment = lineSegment(point(1, 8), point(3, 8)); + const lineD: LineSegment = lineSegment(point(1, 8), point(3, 8)); + const lineE: LineSegment = lineSegment(point(1, 9), point(3, 9)); + const lineF: LineSegment = lineSegment(point(1, 2), point(3, 4)); + const lineG: LineSegment = lineSegment(point(0, 1), point(2, 3)); it("intersection", () => { - expect(lineIntersectsLine(lineA, lineB)).toBe(true); - expect(lineIntersectsLine(lineA, lineC)).toBe(false); - expect(lineIntersectsLine(lineB, lineC)).toBe(false); - expect(lineIntersectsLine(lineC, lineD)).toBe(true); - expect(lineIntersectsLine(lineE, lineD)).toBe(false); - expect(lineIntersectsLine(lineF, lineG)).toBe(true); + expect(segmentsIntersectAt(lineA, lineB)).toEqual([2, 4]); + expect(segmentsIntersectAt(lineA, lineC)).toBe(null); + expect(segmentsIntersectAt(lineB, lineC)).toBe(null); + expect(segmentsIntersectAt(lineC, lineD)).toBe(null); // Line overlapping line is not intersection! + expect(segmentsIntersectAt(lineE, lineD)).toBe(null); + expect(segmentsIntersectAt(lineF, lineG)).toBe(null); }); }); diff --git a/packages/utils/geometry/geometry.ts b/packages/utils/geometry/geometry.ts deleted file mode 100644 index 9274ce746..000000000 --- a/packages/utils/geometry/geometry.ts +++ /dev/null @@ -1,1060 +0,0 @@ -import type { ExcalidrawBindableElement } from "../../excalidraw/element/types"; -import { - addVectors, - distance2d, - rotatePoint, - scaleVector, - subtractVectors, -} from "../../excalidraw/math"; -import type { LineSegment } from "../bbox"; -import { crossProduct } from "../bbox"; -import type { - Point, - Line, - Polygon, - Curve, - Ellipse, - Polycurve, - Polyline, -} from "./shape"; - -const DEFAULT_THRESHOLD = 10e-5; - -/** - * utils - */ - -// the two vectors are ao and bo -export const cross = ( - a: Readonly, - b: Readonly, - o: Readonly, -) => { - return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); -}; - -export const dot = ( - a: Readonly, - b: Readonly, - o: Readonly, -) => { - return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]); -}; - -export const isClosed = (polygon: Polygon) => { - const first = polygon[0]; - const last = polygon[polygon.length - 1]; - return first[0] === last[0] && first[1] === last[1]; -}; - -export const close = (polygon: Polygon) => { - return isClosed(polygon) ? polygon : [...polygon, polygon[0]]; -}; - -/** - * angles - */ - -// convert radians to degress -export const angleToDegrees = (angle: number) => { - const theta = (angle * 180) / Math.PI; - - return theta < 0 ? 360 + theta : theta; -}; - -// convert degrees to radians -export const angleToRadians = (angle: number) => { - return (angle / 180) * Math.PI; -}; - -// return the angle of reflection given an angle of incidence and a surface angle in degrees -export const angleReflect = (incidenceAngle: number, surfaceAngle: number) => { - const a = surfaceAngle * 2 - incidenceAngle; - return a >= 360 ? a - 360 : a < 0 ? a + 360 : a; -}; - -/** - * points - */ - -const rotate = (point: Point, angle: number): Point => { - return [ - point[0] * Math.cos(angle) - point[1] * Math.sin(angle), - point[0] * Math.sin(angle) + point[1] * Math.cos(angle), - ]; -}; - -const isOrigin = (point: Point) => { - return point[0] === 0 && point[1] === 0; -}; - -// rotate a given point about a given origin at the given angle -export const pointRotate = ( - point: Point, - angle: number, - origin?: Point, -): Point => { - const r = angleToRadians(angle); - - if (!origin || isOrigin(origin)) { - return rotate(point, r); - } - return rotate(point.map((c, i) => c - origin[i]) as Point, r).map( - (c, i) => c + origin[i], - ) as Point; -}; - -// translate a point by an angle (in degrees) and distance -export const pointTranslate = (point: Point, angle = 0, distance = 0) => { - const r = angleToRadians(angle); - return [ - point[0] + distance * Math.cos(r), - point[1] + distance * Math.sin(r), - ] as Point; -}; - -export const pointInverse = (point: Point) => { - return [-point[0], -point[1]] as Point; -}; - -export const pointAdd = (pointA: Point, pointB: Point): Point => { - return [pointA[0] + pointB[0], pointA[1] + pointB[1]]; -}; - -export const distanceToPoint = (p1: Point, p2: Point) => { - return distance2d(...p1, ...p2); -}; - -/** - * lines - */ - -// return the angle of a line, in degrees -export const lineAngle = (line: Line) => { - return angleToDegrees( - Math.atan2(line[1][1] - line[0][1], line[1][0] - line[0][0]), - ); -}; - -// get the distance between the endpoints of a line segment -export const lineLength = (line: Line) => { - return Math.sqrt( - Math.pow(line[1][0] - line[0][0], 2) + Math.pow(line[1][1] - line[0][1], 2), - ); -}; - -// get the midpoint of a line segment -export const lineMidpoint = (line: Line) => { - return [ - (line[0][0] + line[1][0]) / 2, - (line[0][1] + line[1][1]) / 2, - ] as Point; -}; - -// return the coordinates resulting from rotating the given line about an origin by an angle in degrees -// note that when the origin is not given, the midpoint of the given line is used as the origin -export const lineRotate = (line: Line, angle: number, origin?: Point): Line => { - return line.map((point) => - pointRotate(point, angle, origin || lineMidpoint(line)), - ) as Line; -}; - -// returns the coordinates resulting from translating a line by an angle in degrees and a distance. -export const lineTranslate = (line: Line, angle: number, distance: number) => { - return line.map((point) => pointTranslate(point, angle, distance)); -}; - -export const lineInterpolate = (line: Line, clamp = false) => { - const [[x1, y1], [x2, y2]] = line; - return (t: number) => { - const t0 = clamp ? (t < 0 ? 0 : t > 1 ? 1 : t) : t; - return [(x2 - x1) * t0 + x1, (y2 - y1) * t0 + y1] as Point; - }; -}; - -/** - * curves - */ -function clone(p: Point): Point { - return [...p] as Point; -} - -export const curveToBezier = ( - pointsIn: readonly Point[], - curveTightness = 0, -): Point[] => { - const len = pointsIn.length; - if (len < 3) { - throw new Error("A curve must have at least three points."); - } - const out: Point[] = []; - if (len === 3) { - out.push( - clone(pointsIn[0]), - clone(pointsIn[1]), - clone(pointsIn[2]), - clone(pointsIn[2]), - ); - } else { - const points: Point[] = []; - points.push(pointsIn[0], pointsIn[0]); - for (let i = 1; i < pointsIn.length; i++) { - points.push(pointsIn[i]); - if (i === pointsIn.length - 1) { - points.push(pointsIn[i]); - } - } - const b: Point[] = []; - const s = 1 - curveTightness; - out.push(clone(points[0])); - for (let i = 1; i + 2 < points.length; i++) { - const cachedVertArray = points[i]; - b[0] = [cachedVertArray[0], cachedVertArray[1]]; - b[1] = [ - cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, - cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, - ]; - b[2] = [ - points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, - points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, - ]; - b[3] = [points[i + 1][0], points[i + 1][1]]; - out.push(b[1], b[2], b[3]); - } - } - return out; -}; - -export const curveRotate = (curve: Curve, angle: number, origin: Point) => { - return curve.map((p) => pointRotate(p, angle, origin)); -}; - -export const cubicBezierPoint = (t: number, controlPoints: Curve): Point => { - const [p0, p1, p2, p3] = controlPoints; - - const x = - Math.pow(1 - t, 3) * p0[0] + - 3 * Math.pow(1 - t, 2) * t * p1[0] + - 3 * (1 - t) * Math.pow(t, 2) * p2[0] + - Math.pow(t, 3) * p3[0]; - - const y = - Math.pow(1 - t, 3) * p0[1] + - 3 * Math.pow(1 - t, 2) * t * p1[1] + - 3 * (1 - t) * Math.pow(t, 2) * p2[1] + - Math.pow(t, 3) * p3[1]; - - return [x, y]; -}; - -const solveCubicEquation = (a: number, b: number, c: number, d: number) => { - // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0 - const roots: number[] = []; - - const discriminant = - 18 * a * b * c * d - - 4 * Math.pow(b, 3) * d + - Math.pow(b, 2) * Math.pow(c, 2) - - 4 * a * Math.pow(c, 3) - - 27 * Math.pow(a, 2) * Math.pow(d, 2); - - if (discriminant >= 0) { - const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2); - const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2); - - const root1 = (-b - C - D) / (3 * a); - const root2 = (-b + (C + D) / 2) / (3 * a); - const root3 = (-b + (C + D) / 2) / (3 * a); - - roots.push(root1, root2, root3); - } else { - const realPart = -b / (3 * a); - - const root1 = - 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3); - const root2 = - 2 * - Math.sqrt(-b / (3 * a)) * - Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3); - const root3 = - 2 * - Math.sqrt(-b / (3 * a)) * - Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3); - - roots.push(root1, root2, root3); - } - - return roots; -}; - -const findClosestParameter = (point: Point, controlPoints: Curve) => { - // This function finds the parameter t that minimizes the distance between the point - // and any point on the cubic Bezier curve. - - const [p0, p1, p2, p3] = controlPoints; - - // Use the direct formula to find the parameter t - const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0]; - const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0]; - const c = 3 * p1[0] - 3 * p0[0]; - const d = p0[0] - point[0]; - - const rootsX = solveCubicEquation(a, b, c, d); - - // Do the same for the y-coordinate - const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1]; - const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1]; - const g = 3 * p1[1] - 3 * p0[1]; - const h = p0[1] - point[1]; - - const rootsY = solveCubicEquation(e, f, g, h); - - // Select the real root that is between 0 and 1 (inclusive) - const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1); - const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1); - - if (validRootsX.length === 0 || validRootsY.length === 0) { - // No valid roots found, use the midpoint as a fallback - return 0.5; - } - - // Choose the parameter t that minimizes the distance - let minDistance = Infinity; - let closestT = 0; - - for (const rootX of validRootsX) { - for (const rootY of validRootsY) { - const distance = Math.sqrt( - (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2, - ); - if (distance < minDistance) { - minDistance = distance; - closestT = (rootX + rootY) / 2; // Use the average for a smoother result - } - } - } - - return closestT; -}; - -export const cubicBezierDistance = (point: Point, controlPoints: Curve) => { - // Calculate the closest point on the Bezier curve to the given point - const t = findClosestParameter(point, controlPoints); - - // Calculate the coordinates of the closest point on the curve - const [closestX, closestY] = cubicBezierPoint(t, controlPoints); - - // Calculate the distance between the given point and the closest point on the curve - const distance = Math.sqrt( - (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2, - ); - - return distance; -}; - -/** - * polygons - */ - -export const polygonRotate = ( - polygon: Polygon, - angle: number, - origin: Point, -) => { - return polygon.map((p) => pointRotate(p, angle, origin)); -}; - -export const polygonBounds = (polygon: Polygon) => { - let xMin = Infinity; - let xMax = -Infinity; - let yMin = Infinity; - let yMax = -Infinity; - - for (let i = 0, l = polygon.length; i < l; i++) { - const p = polygon[i]; - const x = p[0]; - const y = p[1]; - - if (x != null && isFinite(x) && y != null && isFinite(y)) { - if (x < xMin) { - xMin = x; - } - if (x > xMax) { - xMax = x; - } - if (y < yMin) { - yMin = y; - } - if (y > yMax) { - yMax = y; - } - } - } - - return [ - [xMin, yMin], - [xMax, yMax], - ] as [Point, Point]; -}; - -export const polygonCentroid = (vertices: Point[]) => { - let a = 0; - let x = 0; - let y = 0; - const l = vertices.length; - - for (let i = 0; i < l; i++) { - const s = i === l - 1 ? 0 : i + 1; - const v0 = vertices[i]; - const v1 = vertices[s]; - const f = v0[0] * v1[1] - v1[0] * v0[1]; - - a += f; - x += (v0[0] + v1[0]) * f; - y += (v0[1] + v1[1]) * f; - } - - const d = a * 3; - - return [x / d, y / d] as Point; -}; - -export const polygonScale = ( - polygon: Polygon, - scale: number, - origin?: Point, -) => { - if (!origin) { - origin = polygonCentroid(polygon); - } - - const p: Polygon = []; - - for (let i = 0, l = polygon.length; i < l; i++) { - const v = polygon[i]; - const d = lineLength([origin, v]); - const a = lineAngle([origin, v]); - - p[i] = pointTranslate(origin, a, d * scale); - } - - return p; -}; - -export const polygonScaleX = ( - polygon: Polygon, - scale: number, - origin?: Point, -) => { - if (!origin) { - origin = polygonCentroid(polygon); - } - - const p: Polygon = []; - - for (let i = 0, l = polygon.length; i < l; i++) { - const v = polygon[i]; - const d = lineLength([origin, v]); - const a = lineAngle([origin, v]); - const t = pointTranslate(origin, a, d * scale); - - p[i] = [t[0], v[1]]; - } - - return p; -}; - -export const polygonScaleY = ( - polygon: Polygon, - scale: number, - origin?: Point, -) => { - if (!origin) { - origin = polygonCentroid(polygon); - } - - const p: Polygon = []; - - for (let i = 0, l = polygon.length; i < l; i++) { - const v = polygon[i]; - const d = lineLength([origin, v]); - const a = lineAngle([origin, v]); - const t = pointTranslate(origin, a, d * scale); - - p[i] = [v[0], t[1]]; - } - - return p; -}; - -export const polygonReflectX = (polygon: Polygon, reflectFactor = 1) => { - const [[min], [max]] = polygonBounds(polygon); - const p: Point[] = []; - - for (let i = 0, l = polygon.length; i < l; i++) { - const [x, y] = polygon[i]; - const r: Point = [min + max - x, y]; - - if (reflectFactor === 0) { - p[i] = [x, y]; - } else if (reflectFactor === 1) { - p[i] = r; - } else { - const t = lineInterpolate([[x, y], r]); - p[i] = t(Math.max(Math.min(reflectFactor, 1), 0)); - } - } - - return p; -}; - -export const polygonReflectY = (polygon: Polygon, reflectFactor = 1) => { - const [[, min], [, max]] = polygonBounds(polygon); - const p: Point[] = []; - - for (let i = 0, l = polygon.length; i < l; i++) { - const [x, y] = polygon[i]; - const r: Point = [x, min + max - y]; - - if (reflectFactor === 0) { - p[i] = [x, y]; - } else if (reflectFactor === 1) { - p[i] = r; - } else { - const t = lineInterpolate([[x, y], r]); - p[i] = t(Math.max(Math.min(reflectFactor, 1), 0)); - } - } - - return p; -}; - -export const polygonTranslate = ( - polygon: Polygon, - angle: number, - distance: number, -) => { - return polygon.map((p) => pointTranslate(p, angle, distance)); -}; - -/** - * ellipses - */ - -export const ellipseAxes = (ellipse: Ellipse) => { - const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; - - const majorAxis = widthGreaterThanHeight - ? ellipse.halfWidth * 2 - : ellipse.halfHeight * 2; - const minorAxis = widthGreaterThanHeight - ? ellipse.halfHeight * 2 - : ellipse.halfWidth * 2; - - return { - majorAxis, - minorAxis, - }; -}; - -export const ellipseFocusToCenter = (ellipse: Ellipse) => { - const { majorAxis, minorAxis } = ellipseAxes(ellipse); - - return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); -}; - -export const ellipseExtremes = (ellipse: Ellipse) => { - const { center, angle } = ellipse; - const { majorAxis, minorAxis } = ellipseAxes(ellipse); - - const cos = Math.cos(angle); - const sin = Math.sin(angle); - - const sqSum = majorAxis ** 2 + minorAxis ** 2; - const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); - - const yMax = Math.sqrt((sqSum - sqDiff) / 2); - const xAtYMax = - (yMax * sqSum * sin * cos) / - (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); - - const xMax = Math.sqrt((sqSum + sqDiff) / 2); - const yAtXMax = - (xMax * sqSum * sin * cos) / - (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); - - return [ - pointAdd([xAtYMax, yMax], center), - pointAdd(pointInverse([xAtYMax, yMax]), center), - pointAdd([xMax, yAtXMax], center), - pointAdd([xMax, yAtXMax], center), - ]; -}; - -export const pointRelativeToCenter = ( - point: Point, - center: Point, - angle: number, -): Point => { - const translated = pointAdd(point, pointInverse(center)); - const rotated = pointRotate(translated, -angleToDegrees(angle)); - - return rotated; -}; - -/** - * relationships - */ - -const topPointFirst = (line: Line) => { - return line[1][1] > line[0][1] ? line : [line[1], line[0]]; -}; - -export const pointLeftofLine = (point: Point, line: Line) => { - const t = topPointFirst(line); - return cross(point, t[1], t[0]) < 0; -}; - -export const pointRightofLine = (point: Point, line: Line) => { - const t = topPointFirst(line); - return cross(point, t[1], t[0]) > 0; -}; - -export const distanceToSegment = (point: Point, line: Line) => { - const [x, y] = point; - const [[x1, y1], [x2, y2]] = line; - - const A = x - x1; - const B = y - y1; - const C = x2 - x1; - const D = y2 - y1; - - const dot = A * C + B * D; - const len_sq = C * C + D * D; - let param = -1; - if (len_sq !== 0) { - param = dot / len_sq; - } - - let xx; - let yy; - - if (param < 0) { - xx = x1; - yy = y1; - } else if (param > 1) { - xx = x2; - yy = y2; - } else { - xx = x1 + param * C; - yy = y1 + param * D; - } - - const dx = x - xx; - const dy = y - yy; - return Math.sqrt(dx * dx + dy * dy); -}; - -export const pointOnLine = ( - point: Point, - line: Line, - threshold = DEFAULT_THRESHOLD, -) => { - const distance = distanceToSegment(point, line); - - if (distance === 0) { - return true; - } - - return distance < threshold; -}; - -export const pointOnPolyline = ( - point: Point, - polyline: Polyline, - threshold = DEFAULT_THRESHOLD, -) => { - return polyline.some((line) => pointOnLine(point, line, threshold)); -}; - -export const lineIntersectsLine = (lineA: Line, lineB: Line) => { - const [[a0x, a0y], [a1x, a1y]] = lineA; - const [[b0x, b0y], [b1x, b1y]] = lineB; - - // shared points - if (a0x === b0x && a0y === b0y) { - return true; - } - if (a1x === b1x && a1y === b1y) { - return true; - } - - // point on line - if (pointOnLine(lineA[0], lineB) || pointOnLine(lineA[1], lineB)) { - return true; - } - if (pointOnLine(lineB[0], lineA) || pointOnLine(lineB[1], lineA)) { - return true; - } - - const denom = (b1y - b0y) * (a1x - a0x) - (b1x - b0x) * (a1y - a0y); - - if (denom === 0) { - return false; - } - - const deltaY = a0y - b0y; - const deltaX = a0x - b0x; - const numer0 = (b1x - b0x) * deltaY - (b1y - b0y) * deltaX; - const numer1 = (a1x - a0x) * deltaY - (a1y - a0y) * deltaX; - const quotA = numer0 / denom; - const quotB = numer1 / denom; - - return quotA > 0 && quotA < 1 && quotB > 0 && quotB < 1; -}; - -export const lineIntersectsPolygon = (line: Line, polygon: Polygon) => { - let intersects = false; - const closed = close(polygon); - - for (let i = 0, l = closed.length - 1; i < l; i++) { - const v0 = closed[i]; - const v1 = closed[i + 1]; - - if ( - lineIntersectsLine(line, [v0, v1]) || - (pointOnLine(v0, line) && pointOnLine(v1, line)) - ) { - intersects = true; - break; - } - } - - return intersects; -}; - -export const pointInBezierEquation = ( - p0: Point, - p1: Point, - p2: Point, - p3: Point, - [mx, my]: Point, - lineThreshold: number, -) => { - // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 - const equation = (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); - - const lineSegmentPoints: Point[] = []; - let t = 0; - while (t <= 1.0) { - const tx = equation(t, 0); - const ty = equation(t, 1); - - const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2)); - - if (diff < lineThreshold) { - return true; - } - - lineSegmentPoints.push([tx, ty]); - - t += 0.1; - } - - // check the distance from line segments to the given point - - return false; -}; - -export const cubicBezierEquation = (curve: Curve) => { - const [p0, p1, p2, p3] = curve; - // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 - return (t: number, idx: number) => - Math.pow(1 - t, 3) * p3[idx] + - 3 * t * Math.pow(1 - t, 2) * p2[idx] + - 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + - p0[idx] * Math.pow(t, 3); -}; - -export const polyLineFromCurve = (curve: Curve, segments = 10): Polyline => { - const equation = cubicBezierEquation(curve); - let startingPoint = [equation(0, 0), equation(0, 1)] as Point; - const lineSegments: Polyline = []; - let t = 0; - const increment = 1 / segments; - - for (let i = 0; i < segments; i++) { - t += increment; - if (t <= 1) { - const nextPoint: Point = [equation(t, 0), equation(t, 1)]; - lineSegments.push([startingPoint, nextPoint]); - startingPoint = nextPoint; - } - } - - return lineSegments; -}; - -export const pointOnCurve = ( - point: Point, - curve: Curve, - threshold = DEFAULT_THRESHOLD, -) => { - return pointOnPolyline(point, polyLineFromCurve(curve), threshold); -}; - -export const pointOnPolycurve = ( - point: Point, - polycurve: Polycurve, - threshold = DEFAULT_THRESHOLD, -) => { - return polycurve.some((curve) => pointOnCurve(point, curve, threshold)); -}; - -export const pointInPolygon = (point: Point, polygon: Polygon) => { - const x = point[0]; - const y = point[1]; - let inside = false; - - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const xi = polygon[i][0]; - const yi = polygon[i][1]; - const xj = polygon[j][0]; - const yj = polygon[j][1]; - - if ( - ((yi > y && yj <= y) || (yi <= y && yj > y)) && - x < ((xj - xi) * (y - yi)) / (yj - yi) + xi - ) { - inside = !inside; - } - } - - return inside; -}; - -export const pointOnPolygon = ( - point: Point, - polygon: Polygon, - threshold = DEFAULT_THRESHOLD, -) => { - let on = false; - const closed = close(polygon); - - for (let i = 0, l = closed.length - 1; i < l; i++) { - if (pointOnLine(point, [closed[i], closed[i + 1]], threshold)) { - on = true; - break; - } - } - - return on; -}; - -export const polygonInPolygon = (polygonA: Polygon, polygonB: Polygon) => { - let inside = true; - const closed = close(polygonA); - - for (let i = 0, l = closed.length - 1; i < l; i++) { - const v0 = closed[i]; - - // Points test - if (!pointInPolygon(v0, polygonB)) { - inside = false; - break; - } - - // Lines test - if (lineIntersectsPolygon([v0, closed[i + 1]], polygonB)) { - inside = false; - break; - } - } - - return inside; -}; - -export const polygonIntersectPolygon = ( - polygonA: Polygon, - polygonB: Polygon, -) => { - let intersects = false; - let onCount = 0; - const closed = close(polygonA); - - for (let i = 0, l = closed.length - 1; i < l; i++) { - const v0 = closed[i]; - const v1 = closed[i + 1]; - - if (lineIntersectsPolygon([v0, v1], polygonB)) { - intersects = true; - break; - } - - if (pointOnPolygon(v0, polygonB)) { - ++onCount; - } - - if (onCount === 2) { - intersects = true; - break; - } - } - - return intersects; -}; - -const distanceToEllipse = (point: Point, ellipse: Ellipse) => { - const { angle, halfWidth, halfHeight, center } = ellipse; - const a = halfWidth; - const b = halfHeight; - const [rotatedPointX, rotatedPointY] = pointRelativeToCenter( - point, - center, - angle, - ); - - const px = Math.abs(rotatedPointX); - const py = Math.abs(rotatedPointY); - - let tx = 0.707; - let ty = 0.707; - - for (let i = 0; i < 3; i++) { - const x = a * tx; - const y = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = x - ex; - const ry = y - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - } - - const [minX, minY] = [ - a * tx * Math.sign(rotatedPointX), - b * ty * Math.sign(rotatedPointY), - ]; - - return distanceToPoint([rotatedPointX, rotatedPointY], [minX, minY]); -}; - -export const pointOnEllipse = ( - point: Point, - ellipse: Ellipse, - threshold = DEFAULT_THRESHOLD, -) => { - return distanceToEllipse(point, ellipse) <= threshold; -}; - -export const pointInEllipse = (point: Point, ellipse: Ellipse) => { - const { center, angle, halfWidth, halfHeight } = ellipse; - const [rotatedPointX, rotatedPointY] = pointRelativeToCenter( - point, - center, - angle, - ); - - return ( - (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + - (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= - 1 - ); -}; - -/** - * Calculates the point two line segments with a definite start and end point - * intersect at. - */ -export const segmentsIntersectAt = ( - a: Readonly, - b: Readonly, -): Point | null => { - const r = subtractVectors(a[1], a[0]); - const s = subtractVectors(b[1], b[0]); - const denominator = crossProduct(r, s); - - if (denominator === 0) { - return null; - } - - const i = subtractVectors(b[0], a[0]); - const u = crossProduct(i, r) / denominator; - const t = crossProduct(i, s) / denominator; - - if (u === 0) { - return null; - } - - const p = addVectors(a[0], scaleVector(r, t)); - - if (t >= 0 && t < 1 && u >= 0 && u < 1) { - return p; - } - - return null; -}; - -/** - * Determine intersection of a rectangular shaped element and a - * line segment. - * - * @param element The rectangular element to test against - * @param segment The segment intersecting the element - * @param gap Optional value to inflate the shape before testing - * @returns An array of intersections - */ -// TODO: Replace with final rounded rectangle code -export const segmentIntersectRectangleElement = ( - element: ExcalidrawBindableElement, - segment: LineSegment, - gap: number = 0, -): Point[] => { - const bounds = [ - element.x - gap, - element.y - gap, - element.x + element.width + gap, - element.y + element.height + gap, - ]; - const center = [ - (bounds[0] + bounds[2]) / 2, - (bounds[1] + bounds[3]) / 2, - ] as Point; - - return [ - [ - rotatePoint([bounds[0], bounds[1]], center, element.angle), - rotatePoint([bounds[2], bounds[1]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[2], bounds[1]], center, element.angle), - rotatePoint([bounds[2], bounds[3]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[2], bounds[3]], center, element.angle), - rotatePoint([bounds[0], bounds[3]], center, element.angle), - ] as LineSegment, - [ - rotatePoint([bounds[0], bounds[3]], center, element.angle), - rotatePoint([bounds[0], bounds[1]], center, element.angle), - ] as LineSegment, - ] - .map((s) => segmentsIntersectAt(segment, s)) - .filter((i): i is Point => !!i); -}; diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index d14456ea4..f896f2e6f 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -12,9 +12,30 @@ * to pure shapes */ +import type { Curve, LineSegment, Polygon, Radians } from "../../math"; +import { + curve, + lineSegment, + point, + pointDistance, + pointFromArray, + pointFromVector, + pointRotateRads, + polygon, + polygonFromPoints, + PRECISION, + segmentsIntersectAt, + vector, + vectorAdd, + vectorFromPoint, + vectorScale, + type GlobalPoint, + type LocalPoint, +} from "../../math"; import { getElementAbsoluteCoords } from "../../excalidraw/element"; import type { ElementsMap, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawEllipseElement, @@ -28,67 +49,54 @@ import type { ExcalidrawSelectionElement, ExcalidrawTextElement, } from "../../excalidraw/element/types"; -import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry"; import { pointsOnBezierCurves } from "points-on-curve"; import type { Drawable, Op } from "roughjs/bin/core"; - -// a point is specified by its coordinate (x, y) -export type Point = [number, number]; -export type Vector = Point; - -// a line (segment) is defined by two endpoints -export type Line = [Point, Point]; +import { invariant } from "../../excalidraw/utils"; // a polyline (made up term here) is a line consisting of other line segments // this corresponds to a straight line element in the editor but it could also // be used to model other elements -export type Polyline = Line[]; - -// cubic bezier curve with four control points -export type Curve = [Point, Point, Point, Point]; +export type Polyline = + LineSegment[]; // a polycurve is a curve consisting of ther curves, this corresponds to a complex // curve on the canvas -export type Polycurve = Curve[]; - -// a polygon is a closed shape by connecting the given points -// rectangles and diamonds are modelled by polygons -export type Polygon = Point[]; +export type Polycurve = Curve[]; // an ellipse is specified by its center, angle, and its major and minor axes // but for the sake of simplicity, we've used halfWidth and halfHeight instead // in replace of semi major and semi minor axes -export type Ellipse = { +export type Ellipse = { center: Point; - angle: number; + angle: Radians; halfWidth: number; halfHeight: number; }; -export type GeometricShape = +export type GeometricShape = | { type: "line"; - data: Line; + data: LineSegment; } | { type: "polygon"; - data: Polygon; + data: Polygon; } | { type: "curve"; - data: Curve; + data: Curve; } | { type: "ellipse"; - data: Ellipse; + data: Ellipse; } | { type: "polyline"; - data: Polyline; + data: Polyline; } | { type: "polycurve"; - data: Polycurve; + data: Polycurve; }; type RectangularElement = @@ -102,32 +110,32 @@ type RectangularElement = | ExcalidrawSelectionElement; // polygon -export const getPolygonShape = ( +export const getPolygonShape = ( element: RectangularElement, -): GeometricShape => { +): GeometricShape => { const { angle, width, height, x, y } = element; - const angleInDegrees = angleToDegrees(angle); + const cx = x + width / 2; const cy = y + height / 2; - const center: Point = [cx, cy]; + const center: Point = point(cx, cy); - let data: Polygon = []; + let data: Polygon; if (element.type === "diamond") { - data = [ - pointRotate([cx, y], angleInDegrees, center), - pointRotate([x + width, cy], angleInDegrees, center), - pointRotate([cx, y + height], angleInDegrees, center), - pointRotate([x, cy], angleInDegrees, center), - ] as Polygon; + data = polygon( + pointRotateRads(point(cx, y), center, angle), + pointRotateRads(point(x + width, cy), center, angle), + pointRotateRads(point(cx, y + height), center, angle), + pointRotateRads(point(x, cy), center, angle), + ); } else { - data = [ - pointRotate([x, y], angleInDegrees, center), - pointRotate([x + width, y], angleInDegrees, center), - pointRotate([x + width, y + height], angleInDegrees, center), - pointRotate([x, y + height], angleInDegrees, center), - ] as Polygon; + data = polygon( + pointRotateRads(point(x, y), center, angle), + pointRotateRads(point(x + width, y), center, angle), + pointRotateRads(point(x + width, y + height), center, angle), + pointRotateRads(point(x, y + height), center, angle), + ); } return { @@ -137,7 +145,7 @@ export const getPolygonShape = ( }; // return the selection box for an element, possibly rotated as well -export const getSelectionBoxShape = ( +export const getSelectionBoxShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, padding = 10, @@ -153,29 +161,29 @@ export const getSelectionBoxShape = ( y1 -= padding; y2 += padding; - const angleInDegrees = angleToDegrees(element.angle); - const center: Point = [cx, cy]; - const topLeft = pointRotate([x1, y1], angleInDegrees, center); - const topRight = pointRotate([x2, y1], angleInDegrees, center); - const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); - const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + //const angleInDegrees = angleToDegrees(element.angle); + const center = point(cx, cy); + const topLeft = pointRotateRads(point(x1, y1), center, element.angle); + const topRight = pointRotateRads(point(x2, y1), center, element.angle); + const bottomLeft = pointRotateRads(point(x1, y2), center, element.angle); + const bottomRight = pointRotateRads(point(x2, y2), center, element.angle); return { type: "polygon", data: [topLeft, topRight, bottomRight, bottomLeft], - } as GeometricShape; + } as GeometricShape; }; // ellipse -export const getEllipseShape = ( +export const getEllipseShape = ( element: ExcalidrawEllipseElement, -): GeometricShape => { +): GeometricShape => { const { width, height, angle, x, y } = element; return { type: "ellipse", data: { - center: [x + width / 2, y + height / 2], + center: point(x + width / 2, y + height / 2), angle, halfWidth: width / 2, halfHeight: height / 2, @@ -193,32 +201,34 @@ export const getCurvePathOps = (shape: Drawable): Op[] => { }; // linear -export const getCurveShape = ( +export const getCurveShape = ( roughShape: Drawable, - startingPoint: Point = [0, 0], - angleInRadian: number, + startingPoint: Point = point(0, 0), + angleInRadian: Radians, center: Point, -): GeometricShape => { - const transform = (p: Point) => - pointRotate( - [p[0] + startingPoint[0], p[1] + startingPoint[1]], - angleToDegrees(angleInRadian), +): GeometricShape => { + const transform = (p: Point): Point => + pointRotateRads( + point(p[0] + startingPoint[0], p[1] + startingPoint[1]), center, + angleInRadian, ); const ops = getCurvePathOps(roughShape); - const polycurve: Polycurve = []; - let p0: Point = [0, 0]; + const polycurve: Polycurve = []; + let p0 = point(0, 0); for (const op of ops) { if (op.op === "move") { - p0 = transform(op.data as Point); + const p = pointFromArray(op.data); + invariant(p != null, "Ops data is not a point"); + p0 = transform(p); } if (op.op === "bcurveTo") { - const p1: Point = transform([op.data[0], op.data[1]]); - const p2: Point = transform([op.data[2], op.data[3]]); - const p3: Point = transform([op.data[4], op.data[5]]); - polycurve.push([p0, p1, p2, p3]); + const p1 = transform(point(op.data[0], op.data[1])); + const p2 = transform(point(op.data[2], op.data[3])); + const p3 = transform(point(op.data[4], op.data[5])); + polycurve.push(curve(p0, p1, p2, p3)); p0 = p3; } } @@ -229,61 +239,72 @@ export const getCurveShape = ( }; }; -const polylineFromPoints = (points: Point[]) => { - let previousPoint = points[0]; - const polyline: Polyline = []; +const polylineFromPoints = ( + points: Point[], +): Polyline => { + let previousPoint: Point = points[0]; + const polyline: LineSegment[] = []; for (let i = 1; i < points.length; i++) { const nextPoint = points[i]; - polyline.push([previousPoint, nextPoint]); + polyline.push(lineSegment(previousPoint, nextPoint)); previousPoint = nextPoint; } return polyline; }; -export const getFreedrawShape = ( +export const getFreedrawShape = ( element: ExcalidrawFreeDrawElement, center: Point, isClosed: boolean = false, -): GeometricShape => { - const angle = angleToDegrees(element.angle); +): GeometricShape => { const transform = (p: Point) => - pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center); + pointRotateRads( + pointFromVector( + vectorAdd(vectorFromPoint(p), vector(element.x, element.y)), + ), + center, + element.angle, + ); const polyline = polylineFromPoints( element.points.map((p) => transform(p as Point)), ); - return isClosed - ? { - type: "polygon", - data: close(polyline.flat()) as Polygon, - } - : { - type: "polyline", - data: polyline, - }; + return ( + isClosed + ? { + type: "polygon", + data: polygonFromPoints(polyline.flat()), + } + : { + type: "polyline", + data: polyline, + } + ) as GeometricShape; }; -export const getClosedCurveShape = ( +export const getClosedCurveShape = ( element: ExcalidrawLinearElement, roughShape: Drawable, - startingPoint: Point = [0, 0], - angleInRadian: number, + startingPoint: Point = point(0, 0), + angleInRadian: Radians, center: Point, -): GeometricShape => { +): GeometricShape => { const transform = (p: Point) => - pointRotate( - [p[0] + startingPoint[0], p[1] + startingPoint[1]], - angleToDegrees(angleInRadian), + pointRotateRads( + point(p[0] + startingPoint[0], p[1] + startingPoint[1]), center, + angleInRadian, ); if (element.roundness === null) { return { type: "polygon", - data: close(element.points.map((p) => transform(p as Point))), + data: polygonFromPoints( + element.points.map((p) => transform(p as Point)) as Point[], + ), }; } @@ -295,27 +316,218 @@ export const getClosedCurveShape = ( if (operation.op === "move") { odd = !odd; if (odd) { - points.push([operation.data[0], operation.data[1]]); + points.push(point(operation.data[0], operation.data[1])); } } else if (operation.op === "bcurveTo") { if (odd) { - points.push([operation.data[0], operation.data[1]]); - points.push([operation.data[2], operation.data[3]]); - points.push([operation.data[4], operation.data[5]]); + points.push(point(operation.data[0], operation.data[1])); + points.push(point(operation.data[2], operation.data[3])); + points.push(point(operation.data[4], operation.data[5])); } } else if (operation.op === "lineTo") { if (odd) { - points.push([operation.data[0], operation.data[1]]); + points.push(point(operation.data[0], operation.data[1])); } } } const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) => - transform(p), - ); + transform(p as Point), + ) as Point[]; return { type: "polygon", - data: polygonPoints, + data: polygonFromPoints(polygonPoints), }; }; + +/** + * Determine intersection of a rectangular shaped element and a + * line segment. + * + * @param element The rectangular element to test against + * @param segment The segment intersecting the element + * @param gap Optional value to inflate the shape before testing + * @returns An array of intersections + */ +// TODO: Replace with final rounded rectangle code +export const segmentIntersectRectangleElement = < + Point extends LocalPoint | GlobalPoint, +>( + element: ExcalidrawBindableElement, + segment: LineSegment, + gap: number = 0, +): Point[] => { + const bounds = [ + element.x - gap, + element.y - gap, + element.x + element.width + gap, + element.y + element.height + gap, + ]; + const center = point( + (bounds[0] + bounds[2]) / 2, + (bounds[1] + bounds[3]) / 2, + ); + + return [ + lineSegment( + pointRotateRads(point(bounds[0], bounds[1]), center, element.angle), + pointRotateRads(point(bounds[2], bounds[1]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[2], bounds[1]), center, element.angle), + pointRotateRads(point(bounds[2], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[2], bounds[3]), center, element.angle), + pointRotateRads(point(bounds[0], bounds[3]), center, element.angle), + ), + lineSegment( + pointRotateRads(point(bounds[0], bounds[3]), center, element.angle), + pointRotateRads(point(bounds[0], bounds[1]), center, element.angle), + ), + ] + .map((s) => segmentsIntersectAt(segment, s)) + .filter((i): i is Point => !!i); +}; + +const distanceToEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { angle, halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + -angle as Radians, + ); + + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); + + let tx = 0.707; + let ty = 0.707; + + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + const [minX, minY] = [ + a * tx * Math.sign(rotatedPointX), + b * ty * Math.sign(rotatedPointY), + ]; + + return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY)); +}; + +export const pointOnEllipse = ( + point: Point, + ellipse: Ellipse, + threshold = PRECISION, +) => { + return distanceToEllipse(point, ellipse) <= threshold; +}; + +export const pointInEllipse = ( + p: Point, + ellipse: Ellipse, +) => { + const { center, angle, halfWidth, halfHeight } = ellipse; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + const [rotatedPointX, rotatedPointY] = pointRotateRads( + pointFromVector(translatedPoint), + point(0, 0), + -angle as Radians, + ); + + return ( + (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + + (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= + 1 + ); +}; + +export const ellipseAxes = ( + ellipse: Ellipse, +) => { + const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight; + + const majorAxis = widthGreaterThanHeight + ? ellipse.halfWidth * 2 + : ellipse.halfHeight * 2; + const minorAxis = widthGreaterThanHeight + ? ellipse.halfHeight * 2 + : ellipse.halfWidth * 2; + + return { + majorAxis, + minorAxis, + }; +}; + +export const ellipseFocusToCenter = ( + ellipse: Ellipse, +) => { + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + return Math.sqrt(majorAxis ** 2 - minorAxis ** 2); +}; + +export const ellipseExtremes = ( + ellipse: Ellipse, +) => { + const { center, angle } = ellipse; + const { majorAxis, minorAxis } = ellipseAxes(ellipse); + + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + const sqSum = majorAxis ** 2 + minorAxis ** 2; + const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle); + + const yMax = Math.sqrt((sqSum - sqDiff) / 2); + const xAtYMax = + (yMax * sqSum * sin * cos) / + (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2); + + const xMax = Math.sqrt((sqSum + sqDiff) / 2); + const yAtXMax = + (xMax * sqSum * sin * cos) / + (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2); + const centerVector = vectorFromPoint(center); + + return [ + vectorAdd(vector(xAtYMax, yMax), centerVector), + vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + vectorAdd(vector(xMax, yAtXMax), centerVector), + ]; +}; diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts index 02d316243..1920c15cd 100644 --- a/packages/utils/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -11,16 +11,21 @@ import { isLinearElement, isTextElement, } from "../excalidraw/element/typeChecks"; -import { isValueInRange, rotatePoint } from "../excalidraw/math"; -import type { Point } from "../excalidraw/types"; import type { Bounds } from "../excalidraw/element/bounds"; import { getElementBounds } from "../excalidraw/element/bounds"; import { arrayToMap } from "../excalidraw/utils"; +import type { LocalPoint } from "../math"; +import { + rangeIncludesValue, + point, + pointRotateRads, + rangeInclusive, +} from "../math"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; -type Points = readonly Point[]; +type Points = readonly LocalPoint[]; /** @returns vertices relative to element's top-left [0,0] position */ const getNonLinearElementRelativePoints = ( @@ -28,20 +33,25 @@ const getNonLinearElementRelativePoints = ( Element, ExcalidrawLinearElement | ExcalidrawFreeDrawElement >, -): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => { +): [ + TopLeft: LocalPoint, + TopRight: LocalPoint, + BottomRight: LocalPoint, + BottomLeft: LocalPoint, +] => { if (element.type === "diamond") { return [ - [element.width / 2, 0], - [element.width, element.height / 2], - [element.width / 2, element.height], - [0, element.height / 2], + point(element.width / 2, 0), + point(element.width, element.height / 2), + point(element.width / 2, element.height), + point(0, element.height / 2), ]; } return [ - [0, 0], - [0 + element.width, 0], - [0 + element.width, element.height], - [0, element.height], + point(0, 0), + point(0 + element.width, 0), + point(0 + element.width, element.height), + point(0, element.height), ]; }; @@ -84,10 +94,10 @@ const getRotatedBBox = (element: Element): Bounds => { const points = getElementRelativePoints(element); const { cx, cy } = getMinMaxPoints(points); - const centerPoint: Point = [cx, cy]; + const centerPoint = point(cx, cy); - const rotatedPoints = points.map((point) => - rotatePoint([point[0], point[1]], centerPoint, element.angle), + const rotatedPoints = points.map((p) => + pointRotateRads(p, centerPoint, element.angle), ); const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints); @@ -135,10 +145,16 @@ export const elementPartiallyOverlapsWithOrContainsBBox = ( const elementBBox = getRotatedBBox(element); return ( - (isValueInRange(elementBBox[0], bbox[0], bbox[2]) || - isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) && - (isValueInRange(elementBBox[1], bbox[1], bbox[3]) || - isValueInRange(bbox[1], elementBBox[1], elementBBox[3])) + (rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) || + rangeIncludesValue( + bbox[0], + rangeInclusive(elementBBox[0], elementBBox[2]), + )) && + (rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) || + rangeIncludesValue( + bbox[1], + rangeInclusive(elementBBox[1], elementBBox[3]), + )) ); }; diff --git a/scripts/buildMath.js b/scripts/buildMath.js new file mode 100644 index 000000000..47c191af5 --- /dev/null +++ b/scripts/buildMath.js @@ -0,0 +1,108 @@ +const fs = require("fs"); +const { build } = require("esbuild"); + +const browserConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", +}; + +// Will be used later for treeshaking + +// function getFiles(dir, files = []) { +// const fileList = fs.readdirSync(dir); +// for (const file of fileList) { +// const name = `${dir}/${file}`; +// if ( +// name.includes("node_modules") || +// name.includes("config") || +// name.includes("package.json") || +// name.includes("main.js") || +// name.includes("index-node.ts") || +// name.endsWith(".d.ts") || +// name.endsWith(".md") +// ) { +// continue; +// } + +// if (fs.statSync(name).isDirectory()) { +// getFiles(name, files); +// } else if ( +// name.match(/\.(sa|sc|c)ss$/) || +// name.match(/\.(woff|woff2|eot|ttf|otf)$/) || +// name.match(/locales\/[^/]+\.json$/) +// ) { +// continue; +// } else { +// files.push(name); +// } +// } +// return files; +// } +const createESMBrowserBuild = async () => { + // Development unminified build with source maps + const browserDev = await build({ + ...browserConfig, + outdir: "dist/browser/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync( + "meta-browser-dev.json", + JSON.stringify(browserDev.metafile), + ); + + // production minified build without sourcemaps + const browserProd = await build({ + ...browserConfig, + outdir: "dist/browser/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync( + "meta-browser-prod.json", + JSON.stringify(browserProd.metafile), + ); +}; + +const rawConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", + packages: "external", +}; + +const createESMRawBuild = async () => { + // Development unminified build with source maps + const rawDev = await build({ + ...rawConfig, + outdir: "dist/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile)); + + // production minified build without sourcemaps + const rawProd = await build({ + ...rawConfig, + outdir: "dist/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile)); +}; + +createESMRawBuild(); +createESMBrowserBuild(); From 21fff26d31b38189a4f193582ed571b26890ee9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:46:37 +0530 Subject: [PATCH 06/13] build(deps): bump micromatch from 4.0.5 to 4.0.8 in /dev-docs (#8458) Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8. - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8) --- updated-dependencies: - dependency-name: micromatch dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-docs/yarn.lock | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/dev-docs/yarn.lock b/dev-docs/yarn.lock index 05367267c..5b684e32b 100644 --- a/dev-docs/yarn.lock +++ b/dev-docs/yarn.lock @@ -1547,7 +1547,7 @@ "@docusaurus/theme-search-algolia" "2.2.0" "@docusaurus/types" "2.2.0" -"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": +"@docusaurus/react-loadable@5.5.2": version "5.5.2" resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== @@ -2789,7 +2789,14 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.2, braces@~3.0.2: +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -4011,6 +4018,13 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + finalhandler@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" @@ -5207,11 +5221,11 @@ methods@~1.1.2: integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: - braces "^3.0.2" + braces "^3.0.3" picomatch "^2.3.1" mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": @@ -6190,14 +6204,13 @@ react-dev-utils@^12.0.1: strip-ansi "^6.0.1" text-table "^0.2.0" -react-dom@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" - integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== +react-dom@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" - scheduler "^0.20.2" + scheduler "^0.23.0" react-error-overlay@^6.0.11: version "6.0.11" @@ -6260,6 +6273,14 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" +"react-loadable@npm:@docusaurus/react-loadable@5.5.2": + version "5.5.2" + resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" + integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== + dependencies: + "@types/react" "*" + prop-types "^15.6.2" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -6310,13 +6331,12 @@ react-textarea-autosize@^8.3.2: use-composed-ref "^1.3.0" use-latest "^1.2.1" -react@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" - integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== +react@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" readable-stream@^2.0.1: version "2.3.7" @@ -6664,13 +6684,12 @@ sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -scheduler@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" - integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== +scheduler@^0.23.0: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== dependencies: loose-envify "^1.1.0" - object-assign "^4.1.1" schema-utils@2.7.0: version "2.7.0" From 51ea184938d57637faa345de85028aa8a0930ed2 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 5 Sep 2024 18:35:36 +0530 Subject: [PATCH 07/13] build: upgrade vite to 5.4.x, vitest to 2.x and related vite packages (#8459) * build: upgrade vite to 5.x, vitest to 2.x and related vite packages * upgrade vitest-ui and coverage * pass empty set to fix type error and update snap * set ignoreEmptyLines to false * update threshold * update coverage threshold * downgrade vite-plugin-pwa as its better to push separately with testing * add package resolutions for strip-ansi, string-width and wrap-ansi * disable pwa * only add resolution for strip-ansi --- package.json | 17 +- .../excalidraw/tests/packages/events.test.tsx | 2 +- .../scene/__snapshots__/export.test.ts.snap | 20 +- setupTests.ts | 2 +- vitest.config.mts | 16 +- yarn.lock | 1224 ++++++++++++----- 6 files changed, 893 insertions(+), 388 deletions(-) diff --git a/package.json b/package.json index 45a07b42c..be906c840 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "@types/react-dom": "18.2.0", "@types/socket.io-client": "3.0.0", "@vitejs/plugin-react": "3.1.0", - "@vitest/coverage-v8": "0.33.0", - "@vitest/ui": "0.32.2", + "@vitest/coverage-v8": "2.0.5", + "@vitest/ui": "2.0.5", "chai": "4.3.6", "dotenv": "16.0.1", "eslint-config-prettier": "8.5.0", @@ -36,13 +36,13 @@ "prettier": "2.6.2", "rewire": "6.0.0", "typescript": "4.9.4", - "vite": "5.0.12", - "vite-plugin-checker": "0.6.1", + "vite": "5.4.2", + "vite-plugin-checker": "0.7.2", "vite-plugin-ejs": "1.7.0", "vite-plugin-pwa": "0.17.4", - "vite-plugin-svgr": "2.4.0", - "vitest": "1.6.0", - "vitest-canvas-mock": "0.3.2" + "vite-plugin-svgr": "4.2.0", + "vitest": "2.0.5", + "vitest-canvas-mock": "0.3.3" }, "engines": { "node": "18.0.0 - 20.x.x" @@ -83,6 +83,7 @@ "clean-install": "yarn rm:node_modules && yarn install" }, "resolutions": { - "@types/react": "18.2.0" + "@types/react": "18.2.0", + "strip-ansi": "6.0.1" } } diff --git a/packages/excalidraw/tests/packages/events.test.tsx b/packages/excalidraw/tests/packages/events.test.tsx index 16a938e63..34a1cc062 100644 --- a/packages/excalidraw/tests/packages/events.test.tsx +++ b/packages/excalidraw/tests/packages/events.test.tsx @@ -43,7 +43,7 @@ describe("event callbacks", () => { // files {}, ); - expect(onChange.mock.lastCall[1].viewBackgroundColor).not.toBe( + expect(onChange.mock?.lastCall?.[1].viewBackgroundColor).not.toBe( origBackgroundColor, ); }); diff --git a/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap b/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap index a75a36d0b..bab78a832 100644 --- a/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap +++ b/packages/excalidraw/tests/scene/__snapshots__/export.test.ts.snap @@ -27,23 +27,23 @@ exports[`exportToSvg > with default arguments 1`] = ` } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgp4Mt6IIcJZFNCfWmZY6KzqazTt6AiVHCFawzJ7V0CQBKoSQQEpFKrCVxiSFbqqqsaTevKs/q7s6uBYmujMyA9wxb6a7XnEOcNnDLgG4nW8PoPzHzYpS2uqp51pL3eB/xZoxIllCQc80B9o4j/4xMbxQB+j+SC3hsm6JmI8RMaHj+aJApW6ZbkXlg4vXSE5FECg0og6LzxP9pOarug4tF1RLpbHeZqLX0pIt2mfy3pNG6eyGaRIjrnrr/gv2c//yGdjpJ/7DuJLin5eIZRLaObBMM/NpYpuXJ8z3SE088mEFANcCARESfwChCioAwESsVxBgeyxp6+vZ3Xzv8uz7Ae8tRk+p6RUTPOR7BmlEgh+STsAimNuKibilyluhBNe5/wCACSBKrcykVfgyb+RYcK/TGq9yMyCt3bOskSnR1FqTLMSVBsMGLQVltDKKw2IYA+wcGxHJCINY9mDOCaN4IZEo1ChY4xNg8DaB0RxDqnbMNjXJJRzF9iInqahtm67M8dOHFvXaYbn+wrGxKG3YZI+2V4CW58ovdfV1tFHXFJJiPH7T1FAJxEgYo5BKkA5iDIVQluOqZYWhQapGF6TAFhaFAAoTBIZsCFHi/0oV3gpVKwbAA==); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAI0AA4AAAAABLQAAAHeAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgpsY9TYDjGuJ1F+MUed8Q+Do8lDKF1uhErOw/f7/X7tcy+iEk0SUSWRLDGExBCqZYuETCJVS3jlW/rvPv/y4Fra6szKjvjBubd/Nui7lGEMuAbidbw+g/MfFilLa6qnnWkvd4H/FmjEiWUJBzzQH2jiP/jExvFAH6P5ILeGybomYjxExoeP5okC5TpFmecWVieTSw4FIFBuSJ4Vngd7SlWTd4eom7xMLI/zNBO/FJHO0i6T1Zo29kTTT5Ecc9Nf4b9kP/+RT8dQL/MVxGuKXlYhlEpoZMEgy8mRiGYc/001h9DIJhNS9DEhEBBF8QsQokANCJCI5QoKZLddfb/er5n9XZpln/DYYniZkJR+fpjdo1gCwS/pEkByzEzFRPwywYywwWaOVQCAJFDhTCbyMtz5NzKM+ZdJtQeZmfPVzlkWafcoio0oVoJK/QGDNsMiWn6lPh7sAJo3ujXOJwmjmUNvVJIPNzgSgQhtMDoJAmM/EPVmRd2Cwb7gUMZdoCNqiprKqu32bGxtjOs0w8O+gFG9obdhkj7ZHgJXvyi9V1VWUUeciDjE+P07FJBxPiCij0EiQBmINBFCV4aukhKBBImYd0UfgKZEAIBAxeEBHTKU6I9yhpVChXUCAAAA); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPILgps9xRFkFEihAtyRcm3sWFhmEeo5Dz8tx/rvpmPqESTBE0lkSBySERCJxSLhLR5U7Uk2jzt3GfnB66lLWNU8Qd04uTtzX70HcgYcA3E63iUZs1n9bQz7eUu8N8CjTixwAIOeKA/0MR/8IlNNx7oYzQf5NYwWddEjIfI+PDR3FGg0bAs9+b23pkrpIJSQKDRjCovPE/2jZqu6Di0XVEulttVWotfakj36YLLQQ0bl7KZpEiOueu9/Jf8839UqzEzzv0A8SF9YJxXCPUSunkwzXN3raIbN+dMdwjdfLKQYoILgYCoiV+AEKVoAwESscqBAvl55Pju68dV2/rv+py/wJu+4f2SMlM2UqnaudRKIPgl5Qwg9QWdJuKXJW6EE163fAIAZIEm97KoGvDi38iw4F8mrV7LbNNzvWJZY9CdqDWnXgiaTQYM2gtraOOVJkRwDlg4tn0SkYaxbEMck0ZwSaJRqNABxqZBYOkVojh71KwZ7AsSyrkLbERHXV9Tu92JA4cOLMs0w5N1BWPi0Fs3SZ8sz4GNN5Tea2tq6SLOqCTE+P1HKKCTCBAxxSAVoBxEkQqhDcdYQ4NCg1QMr04ALA0KABQmiQzYkKPBfpRLvBQqrwwDAA==); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIoAA4AAAAABLQAAAHVAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wMRFZQHZF8mb0PNXjkIbeHQ4UpbWDTlYMnK5cOfavAw/zzrESo5EbTf6+zdCyBIAqHiUlkJRMJHmIKskVWRFZ4Uelb50Iz7S0IKNNGB0oyDDbgkjnxA+VCvYpk1vog5la7RoxGwSoMFQHNIwf6bvMXbb7uB/zbQEScWUOABB/oDJf6DJ5YWB/oY5UFuDZN1TcR4iIwPH80dBd1mzcrh6sxZnRpoDQTd1qyp8Hzy6v/JPaP30LMHRmG53ZSD/CblWFLgqIbNBWE2y5SSY+mk9V/qr9Nr9rO2mFcC8rUMi6qITgWjClaVl+6tZxG4/ezyvYhiVKOKkiUQBNKW3wCR1gQCConGgYL6Vik5uYHBH+T6E3yc6OHLTplP/6g2k2hXQPgla1sH3lJf6DTJbxPEedV9bQIAqqDHcZWmC+/+j44tH3TR772uDr2oN6zbTDun2m3YKIReywFCxkEb6b7Skm4ghBglR9vyebxlKPLNt+27HjvxxGew/DGCDtQsGvyXTehdSPR6qVWpddg/nU/LMs3gtu7yCJFbt56xvjyH9E/ovVql2tAfnq0bv/9CILBNpk8584BQPeMxltJeuez6zONGyYS47AK4ke1Awmg5eZRnZSUd); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHXAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPILgacMo33iNXw4rQ+nvxRQyQajY97hEpO8P1+7PfcdxGVaJKIKtmThcIQKqFYJCQypZO+JO2e/tu0qRygmchjGCYuED6gcn65sG8dyxhwDcTreH0G11fptDPt5S7w3wKNOLEs4YAH+gNN/Aef2Dge6GM0H+TWMFnXRIyHyPjw0TxRoFa/PLaydngJERnKEgRqTajzw/N074QN8V2QC3HA8jinpfglSLpPt8B2TRun8hmlSI45/JX/pfj5H3kxJoaxB4gvKYb5hVAtoZ0P43y3lzoxXP/NZiG0CyiEFCMAgYCoiF8AhChLTSBAIpYVFCieB7aWTjUt/64uih/woes7fJ67WFaVy3kiNwOVEgh+SQcAqsYYFRPxSxdDOBZ0IyAAQBaoc68QuQYv/o0MM/5l0uhNFlZ8tTPLCr0eRaUpzUpQbzRg0E5YQWuvNCKGI8jxxnVE8ckgOfaQNzbDhxsKg0ZHjkkuA0FrP4jw5pC6DYN9TSF4d5GL62kaauu2O3PsxLF1nWZ4vq+RbG/EbZinz7eX0NYvQe91tXX0cZd0Cm78/lMCMik+EG5OIjokeLgyHSFbnqmWFo2B6KR3TR+Qo0WDkMamUCEX8bS4j3KFn0Llq34AAA==); } @@ -153,23 +153,23 @@ exports[`exportToSvg > with exportEmbedScene 1`] = ` } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgp4Mt6IIcJZFNCfWmZY6KzqazTt6AiVHCFawzJ7V0CQBKoSQQEpFKrCVxiSFbqqqsaTevKs/q7s6uBYmujMyA9wxb6a7XnEOcNnDLgG4nW8PoPzHzYpS2uqp51pL3eB/xZoxIllCQc80B9o4j/4xMbxQB+j+SC3hsm6JmI8RMaHj+aJApW6ZbkXlg4vXSE5FECg0og6LzxP9pOarug4tF1RLpbHeZqLX0pIt2mfy3pNG6eyGaRIjrnrr/gv2c//yGdjpJ/7DuJLin5eIZRLaObBMM/NpYpuXJ8z3SE088mEFANcCARESfwChCioAwESsVxBgeyxp6+vZ3Xzv8uz7Ae8tRk+p6RUTPOR7BmlEgh+STsAimNuKibilyluhBNe5/wCACSBKrcykVfgyb+RYcK/TGq9yMyCt3bOskSnR1FqTLMSVBsMGLQVltDKKw2IYA+wcGxHJCINY9mDOCaN4IZEo1ChY4xNg8DaB0RxDqnbMNjXJJRzF9iInqahtm67M8dOHFvXaYbn+wrGxKG3YZI+2V4CW58ovdfV1tFHXFJJiPH7T1FAJxEgYo5BKkA5iDIVQluOqZYWhQapGF6TAFhaFAAoTBIZsCFHi/0oV3gpVKwbAA==); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAI0AA4AAAAABLQAAAHeAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPIrgpsY9TYDjGuJ1F+MUed8Q+Do8lDKF1uhErOw/f7/X7tcy+iEk0SUSWRLDGExBCqZYuETCJVS3jlW/rvPv/y4Fra6szKjvjBubd/Nui7lGEMuAbidbw+g/MfFilLa6qnnWkvd4H/FmjEiWUJBzzQH2jiP/jExvFAH6P5ILeGybomYjxExoeP5okC5TpFmecWVieTSw4FIFBuSJ4Vngd7SlWTd4eom7xMLI/zNBO/FJHO0i6T1Zo29kTTT5Ecc9Nf4b9kP/+RT8dQL/MVxGuKXlYhlEpoZMEgy8mRiGYc/001h9DIJhNS9DEhEBBF8QsQokANCJCI5QoKZLddfb/er5n9XZpln/DYYniZkJR+fpjdo1gCwS/pEkByzEzFRPwywYywwWaOVQCAJFDhTCbyMtz5NzKM+ZdJtQeZmfPVzlkWafcoio0oVoJK/QGDNsMiWn6lPh7sAJo3ujXOJwmjmUNvVJIPNzgSgQhtMDoJAmM/EPVmRd2Cwb7gUMZdoCNqiprKqu32bGxtjOs0w8O+gFG9obdhkj7ZHgJXvyi9V1VWUUeciDjE+P07FJBxPiCij0EiQBmINBFCV4aukhKBBImYd0UfgKZEAIBAxeEBHTKU6I9yhpVChXUCAAAA); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHYAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPILgps9xRFkFEihAtyRcm3sWFhmEeo5Dz8tx/rvpmPqESTBE0lkSBySERCJxSLhLR5U7Uk2jzt3GfnB66lLWNU8Qd04uTtzX70HcgYcA3E63iUZs1n9bQz7eUu8N8CjTixwAIOeKA/0MR/8IlNNx7oYzQf5NYwWddEjIfI+PDR3FGg0bAs9+b23pkrpIJSQKDRjCovPE/2jZqu6Di0XVEulttVWotfakj36YLLQQ0bl7KZpEiOueu9/Jf8839UqzEzzv0A8SF9YJxXCPUSunkwzXN3raIbN+dMdwjdfLKQYoILgYCoiV+AEKVoAwESscqBAvl55Pju68dV2/rv+py/wJu+4f2SMlM2UqnaudRKIPgl5Qwg9QWdJuKXJW6EE163fAIAZIEm97KoGvDi38iw4F8mrV7LbNNzvWJZY9CdqDWnXgiaTQYM2gtraOOVJkRwDlg4tn0SkYaxbEMck0ZwSaJRqNABxqZBYOkVojh71KwZ7AsSyrkLbERHXV9Tu92JA4cOLMs0w5N1BWPi0Fs3SZ8sz4GNN5Tea2tq6SLOqCTE+P1HKKCTCBAxxSAVoBxEkQqhDcdYQ4NCg1QMr04ALA0KABQmiQzYkKPBfpRLvBQqrwwDAA==); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,d09GMgABAAAAADx0AA8AAAAAoXwAADwRAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoM+G7JeHIUOBmA/U1RBVEQAiHoRCAqBwQiBnV4LhlAAATYCJAONHAQgBYUgB6AYG3uLN8Tdd4kC3QEk3dNOBBSwYy+4HYBUpe0SRf2inNpn////WQtqyNAH5w5Ioka17TrhRUYZVKkIaL3G1ttOUyszSlVlQgnR0ZHhMqfptnTMmbZXlU5Tl0u4LMDfnc3ez/r5Hdq+2vP1bhEmbVj8iYtlc0LXz3zQjT4iXHDBBRccdMSCiNO+qrYwg/TeTzfNEOR+TNUgQwiF/sIM5VeRaSgh0FdMOxQ4ucJ/U7WrLmVHsSBX0P0+1Jx8rrZjasXp3y6wkiM09kkufE/7/c7c3e+I+JNMp3kjFCohkfGSvSazEv/8PD+3P+e+t7fHoCVSxzAQqTKD+Mr3E0aPKmHaG2P4mYIftDHAH4k22HwdJkZTZdRowREbDDI42Hb3caAxRhxTllmgo/h+vyd7zvuLwqwFDpJemzgA1HFRhI4so0XhImO3fNCl6g7Pz+n/KVUI5GruvSEQBwIxEqyE6I0QJEKEBPNgHqgYUFlXsZXayjbaaUfr88rUa+v7XSdO+1cqtvL/9Be7+5clYAxDqGkK3lmasxHk0o4oQ0ZtXij1FO33bOeeYEm2arUEEUP7gqGX238389RkumUUHysqgR/EUvxxQ2sTKltbJ1dKF/EXAyZnmPjLSl7boE6VWpaaT7IcZ+MC8HEPDAmXBnQ86eO88gvUcsIjYNCGsd7TpAiCi+BmgniEhEcnMGzIqIoUrW1MpCAvSc1JuDNF01ZHAQBuG+bP8Gsb+UcGAWTCsBW6vrJQ2Wn8ApPNpSnJgjRelYK0BW2NNhv1iG5gybxwcD6b+rUzWo0j6Uv/WSH2Z2wlfQAsqv+vw/HMrkar3bFWaFgrdoDkgL8cB/3+eVZxbiXbAZR9wOT4E0KZ+rgiKq+76ory6pahPJ6P9r/lt4fvoP9mKMGsjIm7CUzQDKHaDSkK/9/v92r/zgqr3AC5yEgan4lSMfLlvtD9JwCgXl4QS6hRVrgIXcLxlairXKeqwvkao8rD/19N3512wWUV+yfgaAXMi9aHRRIF1hmwNLKB/769zib/ZaiqFRUVY/c65EiUTIO+tKIQpimOL23ociUeoQxGA1Fj2u8dQkpnkDgGDS3t4Xn39s1zb2aqeydNxSchL1dEREIagp1Frdvy5f1+6/+o3HpV7rYBAjooKWPd+HxRiyJEsNk9hp75ge3/1QwgmNapDfSgxqhSheO0y1gG7bEu47bZ0ygIE0dMh1iIhwfp0YMMjYLMWELWbCA7jpATN8iTN+RrGjTDDGiWWdAcc6B5gqHFFkMhlkErxELxUqE0OyCJLOhn+dB+h6AjjkMnFUG/+gP6SzF0xhnonHPQRZehq65C112HbiqDbnkIPfIYeuoVVKUWqtcMtWqF3nkPffQZ+qob+uYb1EcNjcQCxkN9wAzRDGYRHyZIAnNKBfMqA+aXBjYtA2w2c9h89rDbgRQOfrElpOcSJVGQujFAl6tw1NlncCiY3p+kJTL6CRQlXw4agLV5C5/3+CctGQTvHZPB9kkzSTXNwpCjGso1AfngARIaPAyjQUL0K3hqiwdq518FWucQpHvVHZ836LVuA6N16UsYabraqjv5SNiCzv/Lkf7nKtnFbLMQ/LZsoXk9ovGDw7Ngmde0vT78IPvR8d4vsGe+qKjLXbrC2RbouaDGaZZbQs2lzRznmR2HkZ6bRlMB6cMyCqAPxYw44Dfbmz7Z6vPrrfS5FW6FSLxjmfAuAu3rFgXmUtSCOpHu065tVL9OzTT12UFgnxmyFlRuFns3tLZEZEy3+Uv/FvVubsmMMSiitwfAzHVpVe8l1xAcyjIkxdGIEBIR6auim1FKPpjN1CIw6jVPL/wDFNygDasXnPCNBONPEnZ4X2WRICEc2FPVlhB9eNbteQU+nNiJN2m0wEJgpypFBB/ugdx6Cm3MAYfhgGzwhhiA7mtnv7Co11AYEoqumT+0moCXNCxsrRuYj3tP+7/VvpvbbLkIiujtn2DKhVJLY7xBoYxy19p3oF1hqRbft76VMixYj1lYGwKjjNUsn2sioQl1r/DJ0wcPIHebiXIsVxTxRlbQ5G/8/WzdL682YPl5ZX2aj27P+zZOGkpfG8DZfhbpyVA1PWPoYoul3pbUc7LUj1CgMSZjfC60OC6c3eTN5jlToD8SZCRi5HkEZHu1uV2buXkt8FWhwH6rWcYcZTmTiXmzXY98Z0WBJhNSnyeAjM+4WgDtSE55p2Kcx4Axn3hn8Il3fS6c42IN559xBGvHkxyRPJIvuWdMd1+gNTOsOU0KtCRChIsQnoyzZCJTZtJTpUuXnnM4N703sweZRWaOR4WHyTqTNbLOSfxd4trwLmT/zKGCjkdKpMz5awUdiSiDiIiILLs0X7zPM2Wt4FNvhO/H2cCxEksHj6FRzNibwIkHT168n6f70QwLhFklWqItdpDKIveLPPkOOeyIAr/7U7FzSpS64rpydzxWpU4rpR6DQ1OBMufPmw92gMvl/aOdgLMbgr0FfgM6F8E9ivURlwFDJv96UwSLHFwaD9k7Kxq0P7tXNpa5Gc7a7vW8cq0Cd+l5w63ppTsH3FbDwVWuJ96MuDUeDvNv4cD9VIYMUvseTLSJw+aw9pLW5jNY+7bewPCxM+62X7ZXCa0XFWDC42PWeVbzKoz7/DHUAJypwqr0+aC3VDSsDQ4qok3jeoL2hvfb5oXMBnljbitxNfA1X6LWJQ0Z69S4IAfebIhuW0ilqZfCtthblqB4u66oDR52qKrDTa/SJdeQT6SBiwKgBM4B9ulq5wYyR3+Vmt1OkyEbrI0qyZ4kIZ0aBbpUaH/qS2nubIpS2+wUawtvWbCrlEqqTehKm7a97Fe9uqh0erXLqoP0dGj0sEfOKlZZWskNMMKPysjgVZU3778lvVWS/tKoD+lmcAxs/8fE6tPcxEq1h4HaNlipWn28yXTv2r3tv2n0qvDQ88Af9MXN7NKb/nok9qBVdft6FlQ+LTt4ita7DFMamO42ffcD4xvqS3K/Zsut9fV13UH09Y4zXm9wT8R3Kd0NFUP3YFbfZ7g+mu3X9INP3o02tFjXHbl7qWeaufUatxsXuWrPdDd978ZN7W6idzZ8yxoYvCq2O7hBVNbnOChcPDQ9+tiDhGz8EQJzJ+Cx25GfGZwPD3jLJwngKwimHZ8hRJiZIpT5LFR42U6riSVZ2yIQb8922mk5WnZern75kjIH3PJYgade+0ulWmfUa3XxkMA1bvC/7pdpPOAF9TTzUV3TGVTHg6IiZszmHQxSJhB+nYEDR5zIBY6b0Wc2iUosEVZXMztyiSQTEI4Y1TEoCIRRIjNBiDgiBAIFmeLZKUZ6hQEsRuhiLJMIZ+K80WrYMWFfxbfwzLBvILTo5B1T7xEdPoXw+WpMD6HFhD5xKpprfjsPBj3T72QOt5hOcTpFGTIPLtyWXEbL1O+37easIRswgANiBgWFpcaEeXM6XwDDXYihqNMhLP0oDLUXoUAz6jC62Ms8yaZDft1tZbd8Ot3GGj1sYGEgsohoyHfNdbQbzsFNRyszgwPvFKMCI6MwgyHksX4HQ8suUmdM2VCgTQdjnbtpXboZD0RsMog1xG6amgY7FggEE/QnRyVnhC3w4H3eAApGA1Z3sdsPp5q+f92ftWB+Htnw52e3lCu2ULWn9B3g10T2PCxwxFEFjjnuhJMKFanXoFGTZq3eU+rOoawWlp0mbbr0GTIyoZkzSICfiGtZxrbwaIFsGlo6egZGJnSYX0jWB0vn8VSiSrUateo1aNSkWYtW7330yVdKbdp16NSle5mbgF0iYgkSJYXkDRbcFNgAAAAAAABwV1xVVVVVVW2lP0mSJEmSJEmSWtm2bdu2bdu27dYAAAAAAAAAAKAFSZIkSZIkSZJstneky4MViekyhFQmmazhfr4OOOjQHRMvhq6/rp+EVCaZrOGRda6jChxz3AknFSpaJvQ5skL2PAK6Foj/wQruee6nTeH1r2A+vKOisDgtFP7QRd2b7n8npYL1uvIECxMPeU64Ss9PGGxruLlTknozgfFnsggDtK0yu6H5ohdtJo2T+AcPP1ERxl5pllnAmQmKKQNsxyPtMR5pl92RckwdLSuwnR27CT1M2Zd3j6Zps4vS79YZHIgWpZeJdNPTUcA2qNVowNSt05Ud+bHPeMyWTmVrEU+LuhZGXJkpRugXNQZ0lmYE9c0CEDQ0/GMQXEDYl5krxtY7HYrwULCv9vckmCLMRDx//IOHHsZYMtY58cUVP/P/BhKT3RwhzCVYK8t22kAS6Sb5iIpsILedPBPpPtS3Rbahx7eZ2BzipQkGHF2su7JNaH7qDpx8aev/T57vAgqzj4xTN7cd5CuTHQZ8MwCdm/KAFRTcazPAxJrZOPXUN1aEAPj7gVmMzZiikzZI9qDV+EXtHuyrL3a0TaR45+hd9L/mlhSDMVt57qkrstQK62y0xYQpx3yu0mdtusZmeCMZ+aRN1ngnMCWz5cyiaIqMDuXwV9KHfuQgN/kpSkkqUpXG3rLmzkWjJcrElusf1x8AKyipauoYmvzxjxYEVbnb1f+82rVuXfLxvhbdUNvAE3lSdTopiv06CnrTl+zkIh9FKEEFqlCnsLSyLby8eOl7/JX9QI0rGtr6xiWJC08fBdMA9F9Z/o653jziCCP84TM/xv55n27V5PF+FhU++G3Nv+H79a9m8HXB5zng85vPK59/2Suf14Gavbwoy9rfBT0kpEVboKetDPRMoN9UeJ62p5nHkf+KnnqtVr1GzVq988EnfYcQmlgPfxInddQA4pJJ13l3+qpZOJrRjlH/s9hWjPIXHacZushEqVEuM3KJuassXDPWPXZuEbhjgodGu83Zc06ecvWSixe81PBQaYZW0zTzU2+6FlPM8t4cn3w231cLtVlAaZF2wbr9qM9P+i2l8t0yGuubCyuMWNtsiA+CxBAJwdIibSnO5jhEsUkTy0hEklBe6XJK9XMpfinN7hT2pnaghY6kddSvWfu93H7L1poiQJ7ciYyZsVFmnkCdQoQbQvvba5M881FkC4DnDD3nRBUFHP/g+o+9cg7u8tdgqiYraeHdHEgKlRIuO5k9KW0tXmYJGP8aY1UATouB1c2Cwmhd+lQLZ6yEpeus3TTeA+Pcx6fg5hVvtTxVmaxRgA5Buiz2TagBYQZFUIuOATFFg7D5sKF5kBwmNZZtcW2PJz3KpmLtiE9Wkqwk8suQm8qhNPaX5WDZCtL5I3vHMziW3slMFWVWxM+nUikPL6cK5YoFlIVwaXsC1D8BpBsC/sH0GwDzvwMMPRe0HwmAQNY8FsuTQiOfTjwJtoBcVwwhEZMZ4mJxWdhipVZarLeyGtriSlEwupE1Im6rJP3bhLjM1CQhR2i0QLFaKG/Rwh7W5nS426WEja5nKy3KlUZWiRKQSDhWT1CwQHESypSiKEmK/ZqU2MZXvRKbLCtLyfxKZVijdqrd8ayrIMGAyacLu0Ja9CtLTZMzbA9ScXkOYdiJWQxhLBrDAfoKGAADGLw+a9kX1aIWwZGN++L1qN3vSEHeHoLmKYuYHt5Li3ZhFoIapJQzE5amAAwAYfEzxgzQX64GrDyYwe8CcDF0vSqC0WCe/TJWIwfGoMWyFHLIgxgDerzM5WUY4WPEaJ4jnXEvZ7IPk4N8VjjyI5eCEl6F8AWPyyMXJXWmbU52yaVob6g7foU/UatJ1GYEqwR6/dbEJGXAQTINM4e+SILsGw0wwYs5dsnBOHo+CyBBrS/W+7JiNsmNbxO/4b8UlUBoEFgQvwOdy1e5BoicphuGUgXlUPjc4I3z4hpK5VX4ZPQyV6sNraYNm6uebPFuOS88VnmWK6FSdW6e6sBCZhp5KuuWrsuV8JoU0SsnvWZaubrWSUoBdut+lQI7ogq5wdXpHZxD7VMuAmc5fxNV/YD9gClLFYCHmE5IW79hiHgRAu5YAJvOkYdyWOv0IhWq30V2ISx1ua2OqJJFOh4pR6obVs5KUkg8RHoDUbG8dtVxTE+EYJtUJkxXHF9GbKHg7XAkP7Z2oiMPLdfV9eGFalAF+8iV88gYfM7BF3XJ2uEzVvY8BglnkR23pZiNNdkdDJ9B5wI2rFzBURPwVcZxQyeqMnoBqUvqHBcvcTmP/MBhH26IgYvuEbVoetkZeritFPMXh4ULNuBpc3FRLK48Uj7H9SOvApKEH67oZAtBrUQfKQX9PMNV66I8UJQ9wpQAUKe8AgJYoXKYzRUgNWCpWE7NxnnwHW90PUCFWu9ajNbhUy9kDiEJO5M5TCntj+O3TaFMsEuJycHF0xgiuiWPxG3RcGYMJVzyMTwSHavTy+O0NTRrtdmXYdAOIGUKnEHud9N0fDPyg8glIMDXCQN6rngeOpyYJ/IyxgGaL10uw4fuut1F5JbxAn3pSdgAbYA35pXrvXPGVNFmzlcbTwQLYsBKsLACsCbUc449uqMtHK/hVRXQAWowRHLq81CRR2zDJAfGRfQhmGQ4dmyTgAHbRXV7rHGaWTgHaN/vLIglpgHTVd2m9mW4wHcdxMr+S5WymAreEMI7Ehklm1Zq5nEBLMq+sxrqGlsNpIscRGfYOMjhaSds1EPqQOvKas+wU5YScQTa6IkQM9KjLPBOACS1N8oBEg714hVgFC6Hs8DPuNlQPbwB0RFFSEhG3xpJP+vr+itPmO7WRXdCVcseZAIdmWk3nV9pveKw6TngUKSGUdk1VWQFJAlR9mS5DDQlmxOhOD33akelOH1L2gydyHDiA6gYgepBqiZQOyOQthhtKv0s0r4XODWp36XtBy8trc6ERj0RzMUm/06X3Rua9E6eAbpUlws1QdHf6pQVPAKLxvUyEU4h7JH29h5ZDyzggO5iqOGk+n5U8IkjHBxbznAyMTPIarFRGiZhEgXa9GA8miNmW0B7x6MC8qjciJtyfauu0mgbSqNwSelEqQp9j3gCC0+gNoTIeS8vSnF/pkjA8IIBS/bw3CHPL5VxHAXKVJ9poFOqsddvIOmCcnVbKeY4UMu4P+qCN8dk59xkjRCmc2FurW51agrJyL22bbWST6FmyYIKYFlZgmfdHBIdNoi3a6fwu4X8zJcFRDCiOy7FmxHeoBe2l0IoIYtlTkXQEzjWimn1Oe2Fiz7InFn9YGVO4i4tXk15v8Jh2RsNZXAZLLBZ9PwPQNtC1GgoKM98BuYt17tUgFk5ts/nWoSIFn7OOTeiW3NY01qUhcJmHHmQ8DuSrAIVwmYYsqq6ki7ZpJCLEY0Wj05M7WBCt3nMJJ0UCyeoYRa9KYe6hpY2DuM2hhxnLWyAzLzd16+ww/LIkESd2CLIG7ynTCfd9G+jk94REYS7ttU3/WfGtUOHGK/NtqGui3+F/VawPr8EMMc3t6lyHdpN2GS8ciPOeiRsh2kE1EAZBox1iytjOelkjPJu2GFe4akOrDbdAZwY2DmDjA0TogPj67s0cUJ5uh6e44Gk4dDktss5waawNpO6vCqXPa9CrJPeKZcwLpNHNxlfNv2m6d2pjpR26rlw4LmtJm+UFOvUpZDxG1Bls1+Up0ASqbGw8DL6/l7CK/H60+1vXeTxYMFu1TAW1sVbMc8pRu/KyAgNqV7GC8Hc4qOBj45sQfWH97fymYgwQsXFvbJyVBq+VeTRwm44cJfjyYa8Bme0bI1WfPGKjgdtxsVTmAZzKw4TTBbtWEoMmzGqhO1q59XHBmS6qKVpEpNXiTF5Z24Y/FTBbtpS2NGxIWMCvCMLJB7scqEdHxvSHE2YdBbaKIAYet/IfA1p1RZrpQJVLC5fzCzPUItUBp7O6axdQlXPU7TdRkE4mZbgDMTxHC/KrklYo6DKd/LsFm18KpiwxbaI4GpjuGTtQB7H8ESsdYHah7prtaAWYc+3Qncfuuvl4GDcUbcjV2Z/AsdLqfVFdx0uYv3jDGMGNUUeA07uch2qOVBkSa9+P2nOsCBfe1I3qFTOo2sRhx2AJm79IfLwcXW/GyKaVNqOb89s5JGBGNLExplgVUGtJ5rhu8YjWNnKNl1phjuxgSwhFly9H94SHboO29uqmFkfWHWNnCFSugwAhZABMZHwTh1zem3gEiEyDZDKWDJqSAFUkXpipO2Ttj/xbqXK3uIIR8LeczD5JhX0l5i/wRxsecSUwH27RGIWGr3axsoOqaYY6a2cPLwKBpauKrwr2EJQohZEhgcoEM5jmIblzlkjNwE8h26y3p82dcAkm/4kcEDhBO9dmxfXbYxE7aGG1b+5TNc2CT1Zj1ewfsSKRzzb+2ZmNUtFIHYcmLUphUBnvgasIuWExitlr94XmSNVV6IXy7EgATd0MVHVUhQb1r2rYMGX9zA1eJK4snZ8/2VJZDhGwHDt3KPHG60frUe+ULyOu10RUJxNu8ZIEymO+G/LxucpzaJc8k9C2oezVLQus0g9ISkl0TBiMDbgCs9Y585CY3Tj/yjpKz9dMUsq8QF8vtTB7QTriSpZZZNSnRafrF5WFV6UTNm2rVj1FYH0UFQVPXmcIZn0mq4Quq2K9TuxR3W0f1N/hUqGyEAgg7pyuuDmkSMI8sy2+AR1yfQo3rfKNnnw5YO2yVEPVLm7q6+lfk8XeFahnpTzI4/FN+zLq4qgb44q6EJu70rH+5NP8XAOg70gouen++Ytx375Gveym//mf9/MyFm6tHvJYvgjnEYxM77PoeXdDeJpKKbG7QGDRJuC0xhG4zJxr+hA1Ji9+fCGyp6yix2j1vnOLhBpOU7l8TJV6VhkdxQzhrEiiuWp8ldn5jVKM4o0psKAkQnlLfCV5lUO13d9af/AWaA1GApynB/knyws1Go0hdrCkyCHbd4ecD/T6ZWP+CsXq1zaDsHS+7tD/Kn7KR5K6WgxZg6XBDMXtxgdSh9bc3+CGxq6uLJLCOSW41SF//k+88rrx17yK1eVBUcUge3OTZY6AW0WNFrXjq0e2wTvbSr8ZUiDYTucmyEfjMY2i010YRrJYfY/0rD7HJY2Y8fufy9jQM56F6V5q5NHPVgEkuAEo6vSljYanbkNpowlZaXaRT1Gn6469q27W8Ai+YWI5cL81l3F/olur2zEV9mvAsfY7j3F7okun3wkWLlU5db38mdONQVmrbzwZJCd4Wo3Zi4uWbv8yWXKILJR9f9rUDOCmNDDqBmbBZ/4dlMvC8gYIkx2UoMEOUgxdjKoQZIYpDrJMEGGSdZOFhkCOxgyTP6x258oQtRoYVRj9azGS8cvHS5F40oUIyo0+NlfPJXQOPS+xJPjy8ZF53eSIfCkQuVnC74lLcaookHg97eVZsdxGFRkULnSVt6ba+B7axBlzoGvzc5Jv0wOEsQgeTlH4nEpjknzdJGPCklG5Mh+eAJiibWf5mUwg+b/YAsLaZ/OS48QkiEwYr6WaKTalmx6lhnDLSwgZfAAPshqxFHHcsZtHuP2ch2KF7MG8VwWKxfnENdPL46GmPLWf89YxuG5YBmDN7F6yQCKcA4dVLH5RCOLVUz0s3IRuPDQARUpYAXA5f9BUKT4v9NzqxZmzD9hu+6WxXHj9tWU+8EKtn5z0P1Mh1c+4qsMqwoSf8A1cS9GfVWDqvwie4Mpc0lwdGBzh73RmLG4ZGRwa2//UmwmVxPTPcMttcas0mZlwV5CcrC17PmN8WoM71re+l7JsDL8iAwRfYfdLtpkcpnch8FUVhlgOuJ/+fAF+6zei/+r5+iCHkO6dMoW08yqwMrLHMqEQmtWZbxG3yaYOhXOhVb8schLqdPyeWsSFb2mmBBeh1cX5ynEeeb0mjhQwqaVUMTJAdtV78mbRkRsttI5OQV6ZzXiRTEV7DDmG3K0bp2jqjpY9jQZJokwOeEzdy/PLitsl+V4NUZzxduQOE8hKUwzpAdqFcCNIcNk3mXMh2FeDMPNGGbBL+eRIdDO2lrEuXZxm30ZNUCQA9Ti1ppQVVVNqHVxCoiX87Iq44qlJpDD4L39F9vHOjlYUf2HDvFnBX1eFDM3fJTP/9hR58HJqoHLHfW9OKu3/6ySOJfaVwekDBGmzLo7gb1MLwYtefUzDvkZZ385gtIx79wJ6Myq2qFDFA24D2fMrf/t/ZgkJn7TflPDEzrZUUvf75p3Yy55S/qJQEerwUqTjjUbp3ZmC1axBhBnvSLuZacrt7C8T5+PBd6zaneSzJySWbEstk2pkTuCMWpeUk1q4jZucm/OTLbAinh+9Z2dN/nt8WI00eSVJPvoBWToLE/mRvYdN8aBv0fLouKNGWarK+pydSeLa4X//F1pIudyf6jIf1d+E5W25blbX4OSzfQkyc0pme3L2O3ybCGDqSTb+aaUNi0rS3jjqz+y7dv92ffZaxpzvkx18NvjggtnM+WoPVv5d1q8bVImNyULnVnzS90VRfEW+Kffl/+X/tQFIkil+PsKiHxWjLXODXJZusJ051CQbCJYg+S/3kC5T/QNhvx8et6ZSOk11bOMWxVGk6lam97ldGT0VGn9uVJjssiVE3SU0TKjRLh/OZYDDpvfqs/ucWr4ZTpjULBQ5GAtOOizTtd80O2iEla3YUap31WdR+lXfOCZTm84cKeABQhrrNZm9Dic6V3VWpOpwniLISAbCWKA7CEHgVxa2cRvlSZprsyYLOwKSKEIOBOzWThhPofxF+ah/YgKhlTIe9ZytJ9F9KPl1moUgWAEzXls0UQQTWTp5l8rTFLVC6rxQ2zCW1zuE3+DITOnOV9FplxTzSj0qCezri6trstRqO1uMVhVTv6SuOBCmI8Wawwx6Vz7pCzVLBO5M+aV+suK+DT0szXw46AUQNZavTCtrQBKpbxyXXq8/bhCYUoRuLLml/jL3X/7hWfudU9n7viAfMmlOLu1uk5noaY7ZAAOZZn15JoPRqxXLQdOuRBxrs1ua4s8X20y0jXajDZ7Fje4wSVUm6pM518JkgMEa4Dc1d3TPQjLjRJhYbrLUWqe8kQFGU5nmaDKOmLcZbv+oDX68NTUa9d3VjHhmBiY2dwbSzBGIn+rMJhMtZrMJlrN9e5181WmxbRfGXZC6MuA2I0o4gJqJtDXBwaLSjBri7U6RWZMFNqUdoePlpkSRFaV3e0D/QxrkOwhB1jEABkfY6/M1uUUpCT4TfNshLOXMsvEyTIWOs1/K5kGe+yPCGCBuNDgxem/FnCv4TCGQMKEvS/ytaZZQ+RGKkx+mNpINoPl43VxYcUco783uigP+eDAxl/ZGye2vBmztqa39m8q9k9O/Mex1KdA9/8bZX3p06dpNPMz3XV8Xy9xupLmHMVoXHGXDt8P4jDvy7qIc29PL8MwdfyVLz6SX7t0JYZG8I03vnkY8TrRd1JJU8eF4pdCNoEf2QJH0Hf77vqvpz8jifyyev9eFjZHuBwGW0Q8aqxG0j+R6MvJ0RdoPmES5QZ9li1F5KPnkYMsYpCMML6Rnk07rX+ajJ1W05k75TlkXJB8GPQVTrs1S9uXPv2lT7kws6QHCs6bm66XON0SYMHam3XZ3Q4Nv1xnKhZmnsEuR0W3GSNqVdMPpKlY3Jsz+KR09pKeceESMMiQcH5zOKIeBHT07BupsyMyTbBpni3X8CfZBG4o5KX40IER8f/RJxrsiDhrUBDnbCpHr+iNafJUOiDKljmJ2yfqLVc87587zc1sFsbOyipHjTEmbbqlxQlQNseZnOjS3DaiahhRocZ2coAgGsn2IUyFwGpsaDrrfAa2I2r5Zxhz7NzRAXC7WZP6LEW7jvP0sZnafBf9z+lX006ljGgiA6ezg1qm7+BvZL3nbMYdvjAZcyF6raNlPXvigORbz7Xi3w/+83clScuObrCcwd47B77LsCP929bXllWhiKF+9TLqiRU11QjaZVq7gry1rquEUURWVVu6/hyGnevbsFrBw9N3FFy7ARtlnebxxDdPVufU4Ihh1Z6VcU+v7K3CEEHOsge2bc73+zHJO8w7E986Bbrcrt7ftyOGrzoGkyRnUfSbmLk/RnFXxH5/Y6QSBA3jG5pfxtvIuYgQRgSICnE0BF+iutioWAQLEfChTFNLBs6PZl73fnizEFO4HDazrtCY54ZO23df/JPgojQqTvLA8uLmF768NqQzV8rUnhzzwqqWNDBmLGcWmwNsbG5w2P8PdssQHwRbYEymO1lxKrIZ8UKQF+lTMK1IUVVJEQmLYZjFxJQFp2JM/2ut+adsKKXqrUlVYWFSBvjzMCyEiz0iHqSAPSUiEYwgJR4RlwkjnmIRmM3mmCyGdW+3r039e846Z2BuCtc0kqgc0yYSBWnFpyYOJr4fB73GvDTybcrHo5PW9daWdJGoOCU7gdrBA6u2QX6kjvbNGQ6adre4EpbbyurVN+k6xA8hVqQ2YsrEvLFzTdYos/qjT2yE1FxqUAwV+xXDpUaz1ELEnXIzR+wvDD5uQ1MRtYgVtG2D79CZ77tapaOBkG5HT6BgPszrT37AQDJpdlnV/o+zfxcuTWZEoYm0aMXRE5uu7MxqYHo+umwjpHSZQS72psrN1Ienuh+OZr0w3STRxhQtL+d2Jj9gvPHi59R3jKyHv5ftl8oAzepL9cpuR6Gyq1Sn1/q0sTGvDkI0EzJB611e/CCGr8a9kB5WQUw1LJ+Nj2H4GB54ie3TfT8KeZiQB+qEaYhJw29/Jnme59QGbeXa5PtddGlLrKVgL4PvwoeSzVAak6mGTNFXED/SifhgW6f5McGQ9NYmLDkOqZnMNOgj7WJ8F1i6DfYjLXleM+a3mHYPB7xziArnBqjDqht4Y+P2Bu5O/6uvQHmTAsrhMiMtKIQEnX+2olYIMqNbex+uuVGbt6b6Wec+bkGsgMcaS4VKWsHbdUWphtVMiMba48c6Cy7DsB5dOMF1cfZhlmhbVoqdsF/o+c/fcPJ6B18XqLQYxf/zpi6EaSbTDA+9vrJD93N6N6SK37/lJtApBnkRVXah+r+Ghqf6bHw/Co4Pd7KYUwTR94KGijhifBzH1+JURKTiCTYDqi74/AYe/WPNjb/KuI5+VeCGGI7d82URFL4OLDFqhJEGeFn/PvipDqA8L+jX9zMVfLCY/vkWU2pZjSovtTou58NwI+iuO29hSYyPG6ZNe6BaJrMO2lK85V4JMWvv9SQPaUDgRgQ81y87HvNNUr7k7uoT6xbUZP65OClPsmDN8cZ8hb+AM3OOxawfy4986viiBc+VBq3KbnoKI58t/bZDo8jgYmMYNsYSlFT66kAtwwpTE0XkmoxGv+74zQbUgiAWtOHme2G69EZyTdEEFWIR1Q41sauyc2/9Mu+2zvLLL0k9Z1VN5VaAHQweYF3WeVABBAtQj7bnHeRfxXdUGEcm+n6GOFGsJqwld1HrRVI3ivVeXY84VY/ieSpIIJtHfmZyIlkBsIshQ9RmA8KHYT5i2E4OEkP2emVW5axc2lJwbyyWCGtWdYPKVvAZFSIcbpEqTZKUzjP9o3RzAgsBgqkJiHDYl/N2GAjrUj96+/2Nmj7Kyy55w4QKYEiA+pwE3Um7ZZ07EaZCUHytr+c3qzLKjLqsYEOqdUtgo5hHdWOICrH6VuU79SbaYSxYBWoZyP5FcOV79xR7uB2YBUHN2AtejynC/G6tvhat/bnflWE/2lT/93qvL814DuXzg7fCT/KyKlpUhZGY5Nynnc8fUngft6Xe+LIf+sNvi3z5BhXCR+j1hyIoy90mT3ieyLvTvGPtr0vNlQ0Zd0Krw78tpT/7CrdgmBn/D39k1oR/+XSpXO8a+vXS3ibD4DjDTY2/lCtypj6qOM3FN4riz4jFCKLC6Nz4arfiUXTMI2404s1dHdUwyhLxZyina5uVjOKcyQXBt8wcfymVG++qLeTVFbpAcAU1TlE7PzWv/Zh6IWjPH+/sOLArWKO+72nRh5aEGq4SL7/7HfUcC3+OorbhrG3jYKRtA46ALXdgHpPJhW8gLM6EkC9GqUkqsL1i+7dxFKyweoNin2LHPs9mv8gRNPCgA4Fz3VNPZsH94WXufeV1Gi8QOQ+bNb3lRfs6w6OwZDFgBnj3LV3TDyetb3hq2JttIA6zW9mOTd601a5588T7pCkTBj1bM9rx/tPZBH7oEbiepZy+SkBooj5lbBKJkH9hdGMi+HlRW12yuaV4XfG6FnNyXZtr6+ZXEeSdFcDaeGKza0trvYSuaKYl9a3pla44DPjGb0wVDQRVf2Lh2s7rnDXYJVWQt9n/CfR5D15645zvJtmref9OxTziNmYmJdmKuTHRp6/x3El2owQI7Dtv8QUzHQJh5YyAf6uKywtxuU087iCXGwY8e+dPfP5PDgEWeSGXfrH2+pydhcb7x1nOaF4TzYtyEOwNiP5ckXnnkJqiih/ewoirBdTF6KnPFY5nDvEpf/QtlLhewL4I5A77v9CN3BLGjQdxpMKtubR/6h3dyFxB3AtBDGk3pvwp3kBN4vgkRW0P220f91Ps0S42+3aO94dV9zyt+qYlof4b816y6aY8LuK9ErA1mc9l/gff5MczIcg4ZkGeA0p80QYKHKjYAXbAMdwUPPnbuq3SXfcScb0/tMQQKqMjVXX7Ka0cUkXSZYZnM9R/nXgJ2Bs8YLAVjSIms4KM9D7z7ZzknOpfbjewlLSQ3f5eCYvxJxjvSj9d2a9hOw0pE9JDOLE68nyY3iCdB6SCqX8tachkPiKJjzjv2unoGG6xLSkps5H7CIDWV7jYMPX2OvOfovWHtkBKzkU2+RFwu/gfwXtycBu1dr01ljw3OGf1OsV+jR37Apt6ERipWV1bma7LJH72S+YHHjY8HxAFVn/76CoPdezoNpjzIxodjy+4urKbYVZzIhmOrOiCp470RiXcFCT8A0wU6km5JN127tvb5/kvjd8JUDPdbXxd+fXU8qO72HXZSZLkOn3U4dNnla/TB4QL3TDACs75nse7sd8vLY3XDFnGfgOcgzxqnCLHKWqcRAo8Sy9g2uOb3iFVFB0Cb3/u7vfPBxPN3u8o4GbtxQrvAdyF7/DuwHeDjwG+1Yvghbc/zqZ2NQkaeNgAEUY09O+ed7VpdL1/aYs5rghEpEesrYkztxQvXd8/eHXebkDeHGRD8LDJq8ieHKph+J4oXrZvddM1qvIS9ooYEt0pyqNHKkL104nr5YvC6F1VvnVVe5NvmtgDbGpippkvY2kYKsKhFxmA82bWrhoOvcjXt/8tBHlrytu3iGbU7KroADOat1719Q+bpZ5d5Z0Vnbs8HT/D3v5XPwPfNS9NFQ2EVJewNbWddZ1h7JIq5EaMOz9QK7cybYabjE3vDxNbjckJife2EsPfL2fcNDBt5VZwy3lnBCS3SKRb3mvOA7xQMz33hQLjvSIGzU1jHIycui5/7r487tabo3gsL0/6x6boxfjLirh5F0u5BOTD+6dkUbvZ2o2IMR/f94ZNiPApafiRtefW92KsHbtJj4uYt8ZmyfnxzMcwwueadx1wszioQYGZ7rrdOIHPH7PVc19d63/1toCbhV8kSiqXQNITghv+3UD11wFOdisZjDJyAb3lJ1yT3P3tL6MshSqOCJ71YQvugfCK8A6tT5yuD1XQI3lFd0Rr5gcXrSemfYElllX5pt9RsE+1/6aemVtu0WorLDG5+pv7I78bJrbeS0y49ywx/B5wy30s+Tu5EK3CcCem4NO/gqLZqBBBBKgOSUURNQL20udX/mcb+01/8vyq/2zbL4BL1oty/Y+Di5Nte3j/WFLsrbkNjwNLkm0v8/62AEIv6pPo5SIRHMFHlke+AdLrIFYb+5UA/c7nMh/DwD737nIPmvy9jIfSOGZBpUKzMVEGShcN0/ew9a9uYSo5f5LUx7Gxn/Gw4TugOb5AQECrHJ0FX8r9zGch5SxigR3MurX0WzYgHUnzfytaZmU85lHJa7luelE4NT+UGL3BW6Ys1ud/7XoU2+syVgstZJm/JhLOZdmW+1lhfpqh4p5kFkY5LGIBT/D4hNR1TfT7rWM+87zE5ii3cwFy95mW+jxLNsTUcuWzYGbz5FPuLwsW8mfKWcQCO6SZE3k9zLM3s2lsozF3dAo1Vzkr4n8h9BLprUBDjS/F0sv5tzM2ynKav3UBZT/oUF6i+qWx5LteZT4llFfiVeVbk2l5ud8KWMhs7vZ/+TDutWMRLOAJHp+ReuO86Jmb9K14PCI8bEcV53MUM99LBGKhTTGLTKaNZQ4tzGU+9/kM+pgHJZSn4s2CW3GJy1+lwnxmUEKxybTL79fBQn9qzHGvxiJYwGNe6mA8QDyEHdE+nzMS5nGX7dFhn0fGXZR7MXncQzwJC+yQTvkc6cynfEIsQ3/6JAgTuI1CbqfIUnR2Itp+u5CSacJY0bJ74jEeE+qKjxuFF0uqZMq9j59WoBz9XyZhkyT0GnmLHY/bhEJup4hbuHlMqulsLwaE+sleG6O15yf1XyYrExjPHRSaSG98Jh7zmWfiet1aU15cPt2Wrk+3pyKl6t1I/D9+qNzXCUkjm19SDnIu7TER10v78vKm+kCzN8S25aXe5hJlOc2spEube7apqdy7C//KA/lUBzYDxF7o1SphNaCEwkqs3Ps4qWnWK56GyyQU3MYNjPQ7OWOsxu1QxC3c3NbKe1RkF25pTFNubKSPxvTR1acJP5pJfia3pWJulywTZTUkIsYIE5joM+qJ8TYlFKbizYJbcQmu/+xfxpR/pNUyowYoae1q37oRxWQZSm4liYVM4DbGcjtF3MRkplqaMlvRDgSM5zqLUo6PN5tCL7YuRhG3QpGlyuKj62YChZX4P9n5uP+qmdWh8/+Dj/be7v3u/EcpTvj4PUxGrdc97L3Z1LVFh/nmS7llqbPi10osy7Z/G7GzZxpqzzM91znCNqmXRIUMlWmdHX8eXAgupjxTGZSTBk7vIXcQ9Xh4FpMcg/BSzixeCspG/zpjtz8jqqYz5a1kcyyWgBLX0jwOeTb7j82NFv9QAUpbqEGMDot7NZPdLN7di+QCVboBqgxooyzJnqSyY/X/rkGqnI3dVRXhqfVGV5caP9YmnMNeQqylSSoUA+5748OwoTZqfPsMAmnpUryx5eh+qKO1MMyC2ZzQHI+hGbJ1raJHxKHSULWlAmLoX2fs2mcEEO2t9qeKmrG1JG2Jb2wVbSzfSLllJ4T/SlZTRZ1Xkx1SO0vcZwm0Fo+ttReCbB8cdNjwygbKxW2JLqrfoeIbDh8sbiHXYqm0TEv1hIL+RbGwCShNBbwV56nLOKOM03O21KGSmMwCsIsgBSSD2YA0FaBSfgEUe/BTSQ6gLUUlINuFy6A86dIXtDukRlqkQEpnsceZu9FXArLte9uN2q6Qa3EoxpBOxTAgyDvkuEQ9vp20UwLFtSAFUjqLj64vUF67w3g896/3ydBS/g0OhYVIi3qRYtni5Fn9TgWgR7ckijXA6qg7iva9ZDf5kmi17TOLlC/1yjVRZUTxbl+SKy17YHa5RFvC0WwMWnpe6mQpOKUWctJWC0f807eTRdyw9JSp7QugabtbayDaDk2h0QTqUhhfuzUFMlVDN3ckk9FkadLukDZ4pD8dkhhDSr2Yo1O+QLQk+NZHEYkxkJbUeCFut3GoY9z0fkt6uKMWHFC35y0o12xDe7n0igTn3LVRA+Rttwvt0QcUPjiIT5PLJiETWKFLqt9h6naak9FzN7mTm22nQ52aYxF8tPHTcBpuAGrt6BcH1VGCD23sFlWUvV0yUAFItRMWQwO8WFq3RBH0TWR8qIHChSe2n7/hLYvy0z1OyANOg/ExwMKI8c7sA2CTv+nnIe9An2QRmHVWMLI7w/xsLgSkdx5Xfd6EmAy1/7EU9SWAl0PPVQDwftmzj5bb/zSO3lGAJgIAgX/6aTQurLhM+kLyNaIPzeQ5V+IvcMyO/O1C1IWfCOLdpk7+q7A9cs/8lViVG7u1B8buLU7EyX+FbrNh6lxUH9YbhLgSlnjzcBTFtTXAu2AgDUtCtWGGgtyFq2B7ZdzZXhgQJ7cdToRaFay89fleUQTG/IJ7l9rNL9Ru30Pbf73J+Z+Jws6cltzVT3njqnaYM62hkHPduRt33lOSUanGsP+iJnKGWLy9W+UrOQUF9FXOOM+ALkyNXTwn1hB7JDk2k+75n8U5V2oFJFaHE/DocK86+JK3e9DkZrbgdj3rAee8NAWEkut+ksN1EQfylvJeXZCyeoXqr73Jw7doKmc9e+0FDGUWb3Ni+tFs8/U+l7UbV3PQ42Phxq0wh80zFyx/ZCz429e5ch57oxnrw1kWv7z04zVJOlNNY8fDFDyOPPmu4jGgg57ygDeJzidI1ebX+HsAmY4UQrvlCijXiSmj/Z4r0uPRE2p45E/SFObUxbXSeSq+0905I6GTUIZy/l6/1C3DH2ZlaAnT+CU3lMj5vkvfrsCSINCHrUZJwoFmLdEw5NwpvtOZRzzk3hm5x9+ZMMhkZ8o8z+10fGcOa6U7M1xl72zAV8jORgRcdzKmRxBHAJlDRDw7h5tijwc4liLJeskCiOL+E8URihBVHyN2rf6gaqRbdpMki22SjCp6Y/98820lkhYLTiQLtrtPlW4Kt7nphNIqaqqtT+eq3404iVylSBPDzRILBJcsfFns0oAaPmtpRb7ZFMn4vLjy5c7ztN+pFltisfmrsjBcqsagJtZ7m7bZYn5m/lwpUm17wmJb8+TOgze+0BWMsiiohnS2TREvinAyZ9vURS1lg/3zOXbUjBEXzd9kA1fC8VEDK0zB0hAlOtMs1NzlbpI7apqI/FSyDdn68CdGHwZseBRBCmnkIINcZFEH9hWFujDeMC0OoD4awIQgNAoJDQuPxTEiMip2sXGKKz5uvPgJOBlIkChxCSWaVFKSkkvhnDRZ8hSGcis1VWoupXEtnVsZZf5skkiO+xzecU/H42eU3EMWVrY8y2WXJ1+BQg5OLm5F35N5Rx9/Pv8XElSiVJnyfKtQ2YfWpm6dGrXq1GvIr0ZNQpq1aM2/Nu06dOrSrUevPv0GmtKgsCHDTWtRP+UmKxs7BycXdzpZ/crLl4VfQFBIWERUrP3iEtkaoZOUki5Xhl5PoTHY7Dg8gUiSlZP36cu3H6D8BkEIRhRwKFbgtLKKqlrnXdDQ1NLW0c3hqn5O1w2NcikzMTUzV+5O7wtxtx/dc98DD1V45LEnnrbUM8+98NIrr71RqUq1mn7zq069hsI0atKsRau33hXuvQ999DGNTz774itlEdq069CpS7eeor7p1ec7lX4DBg1R0xguboQWcCn3oDTdMC07FbL5DXZcLwciA/k0tAmMnoGRiZmFlY2dA3FycfPw8vELCAoJi4iKiUtISknLyMrJKygqKauoqqlraGqpqWtoamnr6OrpGxgaGZuYmplbWFpZ29ja2Ts4Ojm7uEISmUAUiFKhJnkUUSp09FdsPR9OMXpJscuGmv0fuMVE+bccaDCztV8wAC8rw4D0VkKbewp+zLcRy7HxZ/OF/z1Rc7INwYElvx2B0skUgeb/HUp1RmzSGDTgTQAyPhjNBC2ISWhlimJ6eQ2RKlguh1Yb9DKroQVapuOaRyQn90JpzZzQkbpWrgNYLn4rokNHIuXE+/mbroTeSbkdu+zydTtZ9frQMyoFoajpo5G10AOl99LPF3qBbgnK9VtPs2qgIcNQyxhpqNTM981QMChGx8u5YkKfUKPBuHngOC2JgUselsD9/GOVdctRYdIXvWVesqU5bicae07BYr2Yil5oWxia/xQjTNKJMj22seKw3MS30NeUUtrjLeOji8YGc9CBc558M41RGrdzROUDFu/TKnm5Hhcalul5KecxQ6SUffY6NksJPVi27VPixsZbVj5gZabpKNuVWQ5jdQ7UsDKypbIv4H4yupKw8gilwiyw6ctauQ30981shE5/OjqW0aqHUcBOfpcqI58+P2FXLUNPOxfxb/dxOjd/Yd/G+rL7kH+bSoATBRoIUR7BAREG4NGQRlDik9ejPEROg9A2ZeCdA2U+QZgKlK0NxIjw7cZ0IEw4ZQ6E8slToEZKNec1dmaMCSpGhFZPlQ8qR46ytNGwgrvT+59HDTP6JNDvUUVzhAcd6ZKuuK7ffDQUSo2a43zyZP7mE8r/dbdRBmskBVpvxW1joQjKqCJvZHD8D4/kN6jAb77PTSNwkAQpSiij5q9rCmLUkQ/DLR8Wn3IGRozw1D3HqKM5vIMX5KGMEDniU0zwUTr1fPRRRYi4oW4CBAkKEDvdVzPvyckmhFnsQmumbWiewwhJIAuJK7uCKFIYFA6vEAfGQ2JnHDpB8xRfkCBBP4iH/8F/yH+59zNk4Of3+ODyv2+K/OivhkZtmVa+I5L8baKn838nUrXPkcDUJU/iztViTrlT2lt/snQP3yJDbXGFG/kU3CHkPUXVWXw2aNn8V47lmFR7Ox651KEMc1L2xhH/1xBaRgNkr2QQeWZHPr99f/+Cax+FcHlnVq4JMmgJ3FtWkA65tjxLSl46t7PCSKDYyzEwdsSzxmyixINRuPSZ+xARtfhJcPALmHSiTJMmKKPiHD7GdD5plfyvJuxlY1teSMLzb4WaITlWdIB8yrQg0FJXCIKCnDpFqJEI5UU6SyFFmgQZNAljRRsJdZClUuLFhoWZRD0ECXWo5UXIKGFx1cKCOGnyvK2Fk0A/BqegLJWigjET//sz0iD9fwmZ5PfnlYwhCW8AAAA=); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIoAA4AAAAABLQAAAHVAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wMRFZQHZF8mb0PNXjkIbeHQ4UpbWDTlYMnK5cOfavAw/zzrESo5EbTf6+zdCyBIAqHiUlkJRMJHmIKskVWRFZ4Uelb50Iz7S0IKNNGB0oyDDbgkjnxA+VCvYpk1vog5la7RoxGwSoMFQHNIwf6bvMXbb7uB/zbQEScWUOABB/oDJf6DJ5YWB/oY5UFuDZN1TcR4iIwPH80dBd1mzcrh6sxZnRpoDQTd1qyp8Hzy6v/JPaP30LMHRmG53ZSD/CblWFLgqIbNBWE2y5SSY+mk9V/qr9Nr9rO2mFcC8rUMi6qITgWjClaVl+6tZxG4/ezyvYhiVKOKkiUQBNKW3wCR1gQCConGgYL6Vik5uYHBH+T6E3yc6OHLTplP/6g2k2hXQPgla1sH3lJf6DTJbxPEedV9bQIAqqDHcZWmC+/+j44tH3TR772uDr2oN6zbTDun2m3YKIReywFCxkEb6b7Skm4ghBglR9vyebxlKPLNt+27HjvxxGew/DGCDtQsGvyXTehdSPR6qVWpddg/nU/LMs3gtu7yCJFbt56xvjyH9E/ovVql2tAfnq0bv/9CILBNpk8584BQPeMxltJeuez6zONGyYS47AK4ke1Awmg5eZRnZSUd); } @font-face { font-family: Nunito; - src: url(data:font/woff2;base64,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); + src: url(data:font/woff2;base64,d09GMgABAAAAAAIsAA4AAAAABLQAAAHXAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGx4cLgZgP1NUQVREAAQRCAoAKgsEAAE2AiQDBAQgBYQkByAb5wPILgacMo33iNXw4rQ+nvxRQyQajY97hEpO8P1+7PfcdxGVaJKIKtmThcIQKqFYJCQypZO+JO2e/tu0qRygmchjGCYuED6gcn65sG8dyxhwDcTreH0G11fptDPt5S7w3wKNOLEs4YAH+gNN/Aef2Dge6GM0H+TWMFnXRIyHyPjw0TxRoFa/PLaydngJERnKEgRqTajzw/N074QN8V2QC3HA8jinpfglSLpPt8B2TRun8hmlSI45/JX/pfj5H3kxJoaxB4gvKYb5hVAtoZ0P43y3lzoxXP/NZiG0CyiEFCMAgYCoiF8AhChLTSBAIpYVFCieB7aWTjUt/64uih/woes7fJ67WFaVy3kiNwOVEgh+SQcAqsYYFRPxSxdDOBZ0IyAAQBaoc68QuQYv/o0MM/5l0uhNFlZ8tTPLCr0eRaUpzUpQbzRg0E5YQWuvNCKGI8jxxnVE8ckgOfaQNzbDhxsKg0ZHjkkuA0FrP4jw5pC6DYN9TSF4d5GL62kaauu2O3PsxLF1nWZ4vq+RbG/EbZinz7eX0NYvQe91tXX0cZd0Cm78/lMCMik+EG5OIjokeLgyHSFbnqmWFo2B6KR3TR+Qo0WDkMamUCEX8bS4j3KFn0Llq34AAA==); } diff --git a/setupTests.ts b/setupTests.ts index 0b2ad48dc..56a16db5c 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -76,7 +76,7 @@ vi.mock( const url = this.urls[0]; if (url.protocol !== "file:") { - return super.getContent(); + return super.getContent(new Set()); } // read local assets directly, without running a server diff --git a/vitest.config.mts b/vitest.config.mts index cd822ca3b..6702e6a61 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -2,19 +2,27 @@ import { defineConfig } from "vitest/config"; import { woff2BrowserPlugin } from "./scripts/woff2/woff2-vite-plugins"; export default defineConfig({ - // @ts-ignore + //@ts-ignore plugins: [woff2BrowserPlugin()], test: { + // Since hooks are running in stack in v2, which means all hooks run serially whereas + // we need to run them in parallel + sequence: { + hooks: 'parallel', + }, setupFiles: ["./setupTests.ts"], globals: true, environment: "jsdom", coverage: { reporter: ["text", "json-summary", "json", "html", "lcovonly"], + // Since v2, it ignores empty lines by default and we need to disable it as it affects the coverage + // Additionally the thresholds also needs to be updated slightly as a result of this change + ignoreEmptyLines: false, thresholds: { - lines: 70, + lines: 66, branches: 70, - functions: 68, - statements: 70, + functions: 63, + statements: 66, }, }, }, diff --git a/yarn.lock b/yarn.lock index 8d4be293c..9bc3c589e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.2.1": +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== @@ -34,7 +34,7 @@ "@babel/highlight" "^7.24.6" picocolors "^1.0.0" -"@babel/code-frame@^7.10.4": +"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== @@ -47,6 +47,11 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.6.tgz#b3600217688cabb26e25f8e467019e66d71b7ae2" integrity sha512-aC2DGhBq5eEdyXWqrDInSqQjO0k8xtPRf5YylULqx8MCd6jBtzqfta/3ETMRpuKIc5hyswfO80ObyA1MvkCcUQ== +"@babel/compat-data@^7.25.2": + version "7.25.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.25.4.tgz#7d2a80ce229890edcf4cc259d4d696cb4dae2fcb" + integrity sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ== + "@babel/core@7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" @@ -68,7 +73,7 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/core@^7.16.0", "@babel/core@^7.19.6", "@babel/core@^7.20.12", "@babel/core@^7.24.4": +"@babel/core@^7.16.0", "@babel/core@^7.20.12", "@babel/core@^7.24.4": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.6.tgz#8650e0e4b03589ebe886c4e4a60398db0a7ec787" integrity sha512-qAHSfAdVyFmIvl0VHELib8xar7ONuSHrE2hLnsaWkYNTI68dmi1x8GYDhJjMI/e7XWal9QBlZkwbOnkcw7Z8gQ== @@ -89,6 +94,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.21.3": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.2.tgz#ed8eec275118d7613e77a352894cd12ded8eba77" + integrity sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.0" + "@babel/helper-compilation-targets" "^7.25.2" + "@babel/helper-module-transforms" "^7.25.2" + "@babel/helpers" "^7.25.0" + "@babel/parser" "^7.25.0" + "@babel/template" "^7.25.0" + "@babel/traverse" "^7.25.2" + "@babel/types" "^7.25.2" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/eslint-parser@^7.16.3": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.24.6.tgz#7f0ecc0f29307b8696e83ff6a9d8b4f3e0421ad2" @@ -108,6 +134,16 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" +"@babel/generator@^7.25.0", "@babel/generator@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.6.tgz#0df1ad8cb32fe4d2b01d8bf437f153d19342a87c" + integrity sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw== + dependencies: + "@babel/types" "^7.25.6" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.22.5", "@babel/helper-annotate-as-pure@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.6.tgz#517af93abc77924f9b2514c407bbef527fb8938d" @@ -133,6 +169,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.25.2": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz#e1d9410a90974a3a5a66e84ff55ef62e3c02d06c" + integrity sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw== + dependencies: + "@babel/compat-data" "^7.25.2" + "@babel/helper-validator-option" "^7.24.8" + browserslist "^4.23.1" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.24.5", "@babel/helper-create-class-features-plugin@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.6.tgz#c50b86fa1c4ca9b7a890dc21884f097b6c4b5286" @@ -202,6 +249,14 @@ dependencies: "@babel/types" "^7.24.6" +"@babel/helper-module-imports@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz#f2f980392de5b84c3328fc71d38bd81bbb83042b" + integrity sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-module-transforms@^7.24.5", "@babel/helper-module-transforms@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.6.tgz#22346ed9df44ce84dee850d7433c5b73fab1fe4e" @@ -213,6 +268,16 @@ "@babel/helper-split-export-declaration" "^7.24.6" "@babel/helper-validator-identifier" "^7.24.6" +"@babel/helper-module-transforms@^7.25.2": + version "7.25.2" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz#ee713c29768100f2776edf04d4eb23b8d27a66e6" + integrity sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ== + dependencies: + "@babel/helper-module-imports" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + "@babel/traverse" "^7.25.2" + "@babel/helper-optimise-call-expression@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.6.tgz#f7836e3ccca3dfa02f15d2bc8b794efe75a5256e" @@ -250,6 +315,14 @@ dependencies: "@babel/types" "^7.24.6" +"@babel/helper-simple-access@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" + integrity sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-skip-transparent-expression-wrappers@^7.20.0", "@babel/helper-skip-transparent-expression-wrappers@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.6.tgz#c47e9b33b7ea50d1073e125ebc26661717cb7040" @@ -269,6 +342,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.6.tgz#28583c28b15f2a3339cfafafeaad42f9a0e828df" integrity sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q== +"@babel/helper-string-parser@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz#5b3329c9a58803d5df425e5785865881a81ca48d" + integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ== + "@babel/helper-validator-identifier@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.6.tgz#08bb6612b11bdec78f3feed3db196da682454a5e" @@ -284,6 +362,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.6.tgz#59d8e81c40b7d9109ab7e74457393442177f460a" integrity sha512-Jktc8KkF3zIkePb48QO+IapbXlSapOW9S+ogZZkcO6bABgYAxtZcjZ/O005111YLf+j4M84uEgwYoidDkXbCkQ== +"@babel/helper-validator-option@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz#3725cdeea8b480e86d34df15304806a06975e33d" + integrity sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q== + "@babel/helper-wrap-function@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.24.6.tgz#c27af1006e310683fdc76b668a0a1f6003e36217" @@ -301,6 +384,14 @@ "@babel/template" "^7.24.6" "@babel/types" "^7.24.6" +"@babel/helpers@^7.25.0": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.25.6.tgz#57ee60141829ba2e102f30711ffe3afab357cc60" + integrity sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q== + dependencies: + "@babel/template" "^7.25.0" + "@babel/types" "^7.25.6" + "@babel/highlight@^7.10.4", "@babel/highlight@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.6.tgz#6d610c1ebd2c6e061cade0153bf69b0590b7b3df" @@ -326,6 +417,13 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.6.tgz#5e030f440c3c6c78d195528c3b688b101a365328" integrity sha512-eNZXdfU35nJC2h24RznROuOpO94h6x8sg9ju0tT9biNtLZ2vuP8SduLqqV+/8+cebSLV9SJEAN5Z3zQbJG/M+Q== +"@babel/parser@^7.25.0", "@babel/parser@^7.25.4", "@babel/parser@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.6.tgz#85660c5ef388cbbf6e3d2a694ee97a38f18afe2f" + integrity sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q== + dependencies: + "@babel/types" "^7.25.6" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.5", "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.6.tgz#283a74ef365b1e954cda6b2724c678a978215e88" @@ -1330,6 +1428,15 @@ "@babel/parser" "^7.24.6" "@babel/types" "^7.24.6" +"@babel/template@^7.25.0": + version "7.25.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.0.tgz#e733dc3134b4fede528c15bc95e89cb98c52592a" + integrity sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.25.0" + "@babel/types" "^7.25.0" + "@babel/traverse@^7.24.5", "@babel/traverse@^7.24.6": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.6.tgz#0941ec50cdeaeacad0911eb67ae227a4f8424edc" @@ -1346,7 +1453,29 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/types@^7.20.0", "@babel/types@^7.24.5", "@babel/types@^7.24.6", "@babel/types@^7.4.4": +"@babel/traverse@^7.24.7", "@babel/traverse@^7.25.2": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.6.tgz#04fad980e444f182ecf1520504941940a90fea41" + integrity sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.6" + "@babel/parser" "^7.25.6" + "@babel/template" "^7.25.0" + "@babel/types" "^7.25.6" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.21.3", "@babel/types@^7.24.7", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.25.4", "@babel/types@^7.25.6": + version "7.25.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.6.tgz#893942ddb858f32ae7a004ec9d3a76b3463ef8e6" + integrity sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw== + dependencies: + "@babel/helper-string-parser" "^7.24.8" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + +"@babel/types@^7.24.5", "@babel/types@^7.24.6", "@babel/types@^7.4.4": version "7.24.6" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.6.tgz#ba4e1f59870c10dc2fa95a274ac4feec23b21912" integrity sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ== @@ -1390,6 +1519,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + "@esbuild/android-arm64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.10.tgz#ef31015416dd79398082409b77aaaa2ade4d531a" @@ -1405,6 +1539,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + "@esbuild/android-arm@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.10.tgz#1c23c7e75473aae9fb323be5d9db225142f47f52" @@ -1420,6 +1559,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + "@esbuild/android-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.10.tgz#df6a4e6d6eb8da5595cfce16d4e3f6bc24464707" @@ -1435,6 +1579,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + "@esbuild/darwin-arm64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.10.tgz#8462a55db07c1b2fad61c8244ce04469ef1043be" @@ -1450,6 +1599,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb" integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + "@esbuild/darwin-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.10.tgz#d1de20bfd41bb75b955ba86a6b1004539e8218c1" @@ -1465,6 +1619,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + "@esbuild/freebsd-arm64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.10.tgz#16904879e34c53a2e039d1284695d2db3e664d57" @@ -1480,6 +1639,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + "@esbuild/freebsd-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.10.tgz#8ad9e5ca9786ca3f1ef1411bfd10b08dcd9d4cef" @@ -1495,6 +1659,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + "@esbuild/linux-arm64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.10.tgz#d82cf2c590faece82d28bbf1cfbe36f22ae25bd2" @@ -1510,6 +1679,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + "@esbuild/linux-arm@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.10.tgz#477b8e7c7bcd34369717b04dd9ee6972c84f4029" @@ -1525,6 +1699,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + "@esbuild/linux-ia32@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.10.tgz#d55ff822cf5b0252a57112f86857ff23be6cab0e" @@ -1540,6 +1719,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + "@esbuild/linux-loong64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.10.tgz#a9ad057d7e48d6c9f62ff50f6f208e331c4543c7" @@ -1555,6 +1739,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + "@esbuild/linux-mips64el@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.10.tgz#b011a96924773d60ebab396fbd7a08de66668179" @@ -1570,6 +1759,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + "@esbuild/linux-ppc64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.10.tgz#5d8b59929c029811e473f2544790ea11d588d4dd" @@ -1585,6 +1779,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + "@esbuild/linux-riscv64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.10.tgz#292b06978375b271bd8bc0a554e0822957508d22" @@ -1600,6 +1799,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + "@esbuild/linux-s390x@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.10.tgz#d30af63530f8d4fa96930374c9dd0d62bf59e069" @@ -1615,6 +1819,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + "@esbuild/linux-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.10.tgz#898c72eeb74d9f2fb43acf316125b475548b75ce" @@ -1630,6 +1839,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + "@esbuild/netbsd-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.10.tgz#fd473a5ae261b43eab6dad4dbd5a3155906e6c91" @@ -1645,6 +1859,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + "@esbuild/openbsd-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.10.tgz#96eb8992e526717b5272321eaad3e21f3a608e46" @@ -1660,6 +1879,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + "@esbuild/sunos-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.10.tgz#c16ee1c167f903eaaa6acf7372bee42d5a89c9bc" @@ -1675,6 +1899,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + "@esbuild/win32-arm64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.10.tgz#7e417d1971dbc7e469b4eceb6a5d1d667b5e3dcc" @@ -1690,6 +1919,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + "@esbuild/win32-ia32@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.10.tgz#2b52dfec6cd061ecb36171c13bae554888b439e5" @@ -1705,6 +1939,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + "@esbuild/win32-x64@0.19.10": version "0.19.10" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.10.tgz#bd123a74f243d2f3a1f046447bb9b363ee25d072" @@ -1720,6 +1959,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -2070,6 +2314,18 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@istanbuljs/schema@^0.1.2": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -2128,12 +2384,17 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -2224,6 +2485,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@polka/url@^1.0.0-next.20", "@polka/url@^1.0.0-next.24": version "1.0.0-next.25" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817" @@ -2576,7 +2842,7 @@ estree-walker "^2.0.1" picomatch "^2.2.2" -"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.0.2": +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.0.5": version "5.1.0" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz#7e53eddc8c7f483a4ad0b94afb1f7f5fd3c771e0" integrity sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g== @@ -2590,81 +2856,161 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz#bbd0e616b2078cd2d68afc9824d1fadb2f2ffd27" integrity sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ== +"@rollup/rollup-android-arm-eabi@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz#0412834dc423d1ff7be4cb1fc13a86a0cd262c11" + integrity sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg== + "@rollup/rollup-android-arm64@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz#97255ef6384c5f73f4800c0de91f5f6518e21203" integrity sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA== +"@rollup/rollup-android-arm64@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz#baf1a014b13654f3b9e835388df9caf8c35389cb" + integrity sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA== + "@rollup/rollup-darwin-arm64@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz#b6dd74e117510dfe94541646067b0545b42ff096" integrity sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w== +"@rollup/rollup-darwin-arm64@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz#0a2c364e775acdf1172fe3327662eec7c46e55b1" + integrity sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q== + "@rollup/rollup-darwin-x64@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz#e07d76de1cec987673e7f3d48ccb8e106d42c05c" integrity sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA== +"@rollup/rollup-darwin-x64@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz#a972db75890dfab8df0da228c28993220a468c42" + integrity sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w== + "@rollup/rollup-linux-arm-gnueabihf@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz#9f1a6d218b560c9d75185af4b8bb42f9f24736b8" integrity sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA== +"@rollup/rollup-linux-arm-gnueabihf@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz#1609d0630ef61109dd19a278353e5176d92e30a1" + integrity sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w== + "@rollup/rollup-linux-arm-musleabihf@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz#53618b92e6ffb642c7b620e6e528446511330549" integrity sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A== +"@rollup/rollup-linux-arm-musleabihf@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz#3c1dca5f160aa2e79e4b20ff6395eab21804f266" + integrity sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w== + "@rollup/rollup-linux-arm64-gnu@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz#99a7ba5e719d4f053761a698f7b52291cefba577" integrity sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw== +"@rollup/rollup-linux-arm64-gnu@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz#c2fe376e8b04eafb52a286668a8df7c761470ac7" + integrity sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw== + "@rollup/rollup-linux-arm64-musl@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz#f53db99a45d9bc00ce94db8a35efa7c3c144a58c" integrity sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ== +"@rollup/rollup-linux-arm64-musl@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz#e62a4235f01e0f66dbba587c087ca6db8008ec80" + integrity sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w== + "@rollup/rollup-linux-powerpc64le-gnu@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz#cbb0837408fe081ce3435cf3730e090febafc9bf" integrity sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA== +"@rollup/rollup-linux-powerpc64le-gnu@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz#24b3457e75ee9ae5b1c198bd39eea53222a74e54" + integrity sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ== + "@rollup/rollup-linux-riscv64-gnu@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz#8ed09c1d1262ada4c38d791a28ae0fea28b80cc9" integrity sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg== +"@rollup/rollup-linux-riscv64-gnu@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz#38edfba9620fe2ca8116c97e02bd9f2d606bde09" + integrity sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg== + "@rollup/rollup-linux-s390x-gnu@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz#938138d3c8e0c96f022252a28441dcfb17afd7ec" integrity sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg== +"@rollup/rollup-linux-s390x-gnu@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz#a3bfb8bc5f1e802f8c76cff4a4be2e9f9ac36a18" + integrity sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ== + "@rollup/rollup-linux-x64-gnu@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz#1a7481137a54740bee1ded4ae5752450f155d942" integrity sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w== +"@rollup/rollup-linux-x64-gnu@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz#0dadf34be9199fcdda44b5985a086326344f30ad" + integrity sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw== + "@rollup/rollup-linux-x64-musl@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz#f1186afc601ac4f4fc25fac4ca15ecbee3a1874d" integrity sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg== +"@rollup/rollup-linux-x64-musl@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz#7b7deddce240400eb87f2406a445061b4fed99a8" + integrity sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg== + "@rollup/rollup-win32-arm64-msvc@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz#ed6603e93636a96203c6915be4117245c1bd2daf" integrity sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA== +"@rollup/rollup-win32-arm64-msvc@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz#a0ca0c5149c2cfb26fab32e6ba3f16996fbdb504" + integrity sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ== + "@rollup/rollup-win32-ia32-msvc@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz#14e0b404b1c25ebe6157a15edb9c46959ba74c54" integrity sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg== +"@rollup/rollup-win32-ia32-msvc@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz#aae2886beec3024203dbb5569db3a137bc385f8e" + integrity sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw== + "@rollup/rollup-win32-x64-msvc@4.18.0": version "4.18.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz#5d694d345ce36b6ecf657349e03eb87297e68da4" integrity sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g== +"@rollup/rollup-win32-x64-msvc@4.21.2": + version "4.21.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz#e4291e3c1bc637083f87936c333cdbcad22af63b" + integrity sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA== + "@rushstack/eslint-patch@^1.1.0": version "1.10.3" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz#391d528054f758f81e53210f1a1eebcf1a8b1d20" @@ -2791,87 +3137,87 @@ magic-string "^0.25.0" string.prototype.matchall "^4.0.6" -"@svgr/babel-plugin-add-jsx-attribute@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz#74a5d648bd0347bda99d82409d87b8ca80b9a1ba" - integrity sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ== +"@svgr/babel-plugin-add-jsx-attribute@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz#4001f5d5dd87fa13303e36ee106e3ff3a7eb8b22" + integrity sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g== -"@svgr/babel-plugin-remove-jsx-attribute@*": +"@svgr/babel-plugin-remove-jsx-attribute@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz#69177f7937233caca3a1afb051906698f2f59186" integrity sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA== -"@svgr/babel-plugin-remove-jsx-empty-expression@*": +"@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0": version "8.0.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz#c2c48104cfd7dcd557f373b70a56e9e3bdae1d44" integrity sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA== -"@svgr/babel-plugin-replace-jsx-attribute-value@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.5.1.tgz#fb9d22ea26d2bc5e0a44b763d4c46d5d3f596c60" - integrity sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg== +"@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz#8fbb6b2e91fa26ac5d4aa25c6b6e4f20f9c0ae27" + integrity sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ== -"@svgr/babel-plugin-svg-dynamic-title@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.5.1.tgz#01b2024a2b53ffaa5efceaa0bf3e1d5a4c520ce4" - integrity sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw== +"@svgr/babel-plugin-svg-dynamic-title@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz#1d5ba1d281363fc0f2f29a60d6d936f9bbc657b0" + integrity sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og== -"@svgr/babel-plugin-svg-em-dimensions@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.5.1.tgz#dd3fa9f5b24eb4f93bcf121c3d40ff5facecb217" - integrity sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA== +"@svgr/babel-plugin-svg-em-dimensions@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz#35e08df300ea8b1d41cb8f62309c241b0369e501" + integrity sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g== -"@svgr/babel-plugin-transform-react-native-svg@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.5.1.tgz#1d8e945a03df65b601551097d8f5e34351d3d305" - integrity sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg== +"@svgr/babel-plugin-transform-react-native-svg@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz#90a8b63998b688b284f255c6a5248abd5b28d754" + integrity sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q== -"@svgr/babel-plugin-transform-svg-component@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.5.1.tgz#48620b9e590e25ff95a80f811544218d27f8a250" - integrity sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ== +"@svgr/babel-plugin-transform-svg-component@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz#013b4bfca88779711f0ed2739f3f7efcefcf4f7e" + integrity sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw== -"@svgr/babel-preset@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-6.5.1.tgz#b90de7979c8843c5c580c7e2ec71f024b49eb828" - integrity sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw== +"@svgr/babel-preset@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-8.1.0.tgz#0e87119aecdf1c424840b9d4565b7137cabf9ece" + integrity sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug== dependencies: - "@svgr/babel-plugin-add-jsx-attribute" "^6.5.1" - "@svgr/babel-plugin-remove-jsx-attribute" "*" - "@svgr/babel-plugin-remove-jsx-empty-expression" "*" - "@svgr/babel-plugin-replace-jsx-attribute-value" "^6.5.1" - "@svgr/babel-plugin-svg-dynamic-title" "^6.5.1" - "@svgr/babel-plugin-svg-em-dimensions" "^6.5.1" - "@svgr/babel-plugin-transform-react-native-svg" "^6.5.1" - "@svgr/babel-plugin-transform-svg-component" "^6.5.1" + "@svgr/babel-plugin-add-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "8.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value" "8.0.0" + "@svgr/babel-plugin-svg-dynamic-title" "8.0.0" + "@svgr/babel-plugin-svg-em-dimensions" "8.0.0" + "@svgr/babel-plugin-transform-react-native-svg" "8.1.0" + "@svgr/babel-plugin-transform-svg-component" "8.0.0" -"@svgr/core@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/core/-/core-6.5.1.tgz#d3e8aa9dbe3fbd747f9ee4282c1c77a27410488a" - integrity sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw== +"@svgr/core@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/core/-/core-8.1.0.tgz#41146f9b40b1a10beaf5cc4f361a16a3c1885e88" + integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== dependencies: - "@babel/core" "^7.19.6" - "@svgr/babel-preset" "^6.5.1" - "@svgr/plugin-jsx" "^6.5.1" + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" camelcase "^6.2.0" - cosmiconfig "^7.0.1" + cosmiconfig "^8.1.3" + snake-case "^3.0.4" -"@svgr/hast-util-to-babel-ast@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.5.1.tgz#81800bd09b5bcdb968bf6ee7c863d2288fdb80d2" - integrity sha512-1hnUxxjd83EAxbL4a0JDJoD3Dao3hmjvyvyEV8PzWmLK3B9m9NPlW7GKjFyoWE8nM7HnXzPcmmSyOW8yOddSXw== +"@svgr/hast-util-to-babel-ast@8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz#6952fd9ce0f470e1aded293b792a2705faf4ffd4" + integrity sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q== dependencies: - "@babel/types" "^7.20.0" + "@babel/types" "^7.21.3" entities "^4.4.0" -"@svgr/plugin-jsx@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-6.5.1.tgz#0e30d1878e771ca753c94e69581c7971542a7072" - integrity sha512-+UdQxI3jgtSjCykNSlEMuy1jSRQlGC7pqBCPvkG/2dATdWo082zHTTK3uhnAju2/6XpE6B5mZ3z4Z8Ns01S8Gw== +"@svgr/plugin-jsx@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz#96969f04a24b58b174ee4cd974c60475acbd6928" + integrity sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA== dependencies: - "@babel/core" "^7.19.6" - "@svgr/babel-preset" "^6.5.1" - "@svgr/hast-util-to-babel-ast" "^6.5.1" + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" + "@svgr/hast-util-to-babel-ast" "8.0.0" svg-parser "^2.0.4" "@swc/helpers@0.5.2": @@ -2992,7 +3338,7 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== @@ -3279,88 +3625,87 @@ magic-string "^0.27.0" react-refresh "^0.14.0" -"@vitest/coverage-v8@0.33.0": - version "0.33.0" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-0.33.0.tgz#dfcb36cf51624a89d33ab0962a6eec8a41346ef2" - integrity sha512-Rj5IzoLF7FLj6yR7TmqsfRDSeaFki6NAJ/cQexqhbWkHEV2htlVGrmuOde3xzvFsCbLCagf4omhcIaVmfU8Okg== +"@vitest/coverage-v8@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz#411961ce4fd1177a32b4dd74ab576ed3b859155e" + integrity sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg== dependencies: - "@ampproject/remapping" "^2.2.1" + "@ampproject/remapping" "^2.3.0" "@bcoe/v8-coverage" "^0.2.3" - istanbul-lib-coverage "^3.2.0" - istanbul-lib-report "^3.0.0" - istanbul-lib-source-maps "^4.0.1" - istanbul-reports "^3.1.5" - magic-string "^0.30.1" - picocolors "^1.0.0" - std-env "^3.3.3" - test-exclude "^6.0.0" - v8-to-istanbul "^9.1.0" + debug "^4.3.5" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.10" + magicast "^0.3.4" + std-env "^3.7.0" + test-exclude "^7.0.1" + tinyrainbow "^1.2.0" -"@vitest/expect@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.0.tgz#0b3ba0914f738508464983f4d811bc122b51fb30" - integrity sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ== +"@vitest/expect@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.0.5.tgz#f3745a6a2c18acbea4d39f5935e913f40d26fa86" + integrity sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA== dependencies: - "@vitest/spy" "1.6.0" - "@vitest/utils" "1.6.0" - chai "^4.3.10" + "@vitest/spy" "2.0.5" + "@vitest/utils" "2.0.5" + chai "^5.1.1" + tinyrainbow "^1.2.0" -"@vitest/runner@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.0.tgz#a6de49a96cb33b0e3ba0d9064a3e8d6ce2f08825" - integrity sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg== +"@vitest/pretty-format@2.0.5", "@vitest/pretty-format@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.0.5.tgz#91d2e6d3a7235c742e1a6cc50e7786e2f2979b1e" + integrity sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ== dependencies: - "@vitest/utils" "1.6.0" - p-limit "^5.0.0" - pathe "^1.1.1" + tinyrainbow "^1.2.0" -"@vitest/snapshot@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.0.tgz#deb7e4498a5299c1198136f56e6e0f692e6af470" - integrity sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ== +"@vitest/runner@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.0.5.tgz#89197e712bb93513537d6876995a4843392b2a84" + integrity sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig== dependencies: - magic-string "^0.30.5" - pathe "^1.1.1" - pretty-format "^29.7.0" + "@vitest/utils" "2.0.5" + pathe "^1.1.2" -"@vitest/spy@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.0.tgz#362cbd42ccdb03f1613798fde99799649516906d" - integrity sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw== +"@vitest/snapshot@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.0.5.tgz#a2346bc5013b73c44670c277c430e0334690a162" + integrity sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew== dependencies: - tinyspy "^2.2.0" + "@vitest/pretty-format" "2.0.5" + magic-string "^0.30.10" + pathe "^1.1.2" -"@vitest/ui@0.32.2": - version "0.32.2" - resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-0.32.2.tgz#3a39ef1e23e7a10c2c37d7f570e94b435d34de4c" - integrity sha512-N5JKftnB8qzKFtpQC5OcUGxYTLo6wiB/95Lgyk6MF52t74Y7BJOWbf6EFYhXqt9J0MSbhOR2kapq+WKKUGDW0g== +"@vitest/spy@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.0.5.tgz#590fc07df84a78b8e9dd976ec2090920084a2b9f" + integrity sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA== dependencies: - "@vitest/utils" "0.32.2" - fast-glob "^3.2.12" - fflate "^0.7.4" - flatted "^3.2.7" - pathe "^1.1.0" - picocolors "^1.0.0" - sirv "^2.0.3" + tinyspy "^3.0.0" -"@vitest/utils@0.32.2": - version "0.32.2" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.32.2.tgz#809c720cafbf4b35ce651deb8570d57785e77819" - integrity sha512-lnJ0T5i03j0IJaeW73hxe2AuVnZ/y1BhhCOuIcl9LIzXnbpXJT9Lrt6brwKHXLOiA7MZ6N5hSJjt0xE1dGNCzQ== +"@vitest/ui@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-2.0.5.tgz#cfae5f6c7a1cc8cd1be87c88153215cb60a2cc0d" + integrity sha512-m+ZpVt/PVi/nbeRKEjdiYeoh0aOfI9zr3Ria9LO7V2PlMETtAXJS3uETEZkc8Be2oOl8mhd7Ew+5SRBXRYncNw== dependencies: - diff-sequences "^29.4.3" - loupe "^2.3.6" - pretty-format "^27.5.1" + "@vitest/utils" "2.0.5" + fast-glob "^3.3.2" + fflate "^0.8.2" + flatted "^3.3.1" + pathe "^1.1.2" + sirv "^2.0.4" + tinyrainbow "^1.2.0" -"@vitest/utils@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" - integrity sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw== +"@vitest/utils@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.0.5.tgz#6f8307a4b6bc6ceb9270007f73c67c915944e926" + integrity sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ== dependencies: - diff-sequences "^29.6.3" + "@vitest/pretty-format" "2.0.5" estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" + loupe "^3.1.1" + tinyrainbow "^1.2.0" "@webassemblyjs/ast@1.11.1": version "1.11.1" @@ -3651,7 +3996,7 @@ acorn-jsx@^5.3.1: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.0.0, acorn-walk@^8.3.2: +acorn-walk@^8.0.0: version "8.3.2" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== @@ -3661,7 +4006,7 @@ acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.4, acorn@^8.11.3, acorn@^8.7.1, acorn@^8.8.2: +acorn@^8.0.4, acorn@^8.7.1, acorn@^8.8.2: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -3742,11 +4087,6 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== - ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -3771,7 +4111,7 @@ ansi-styles@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -ansi-styles@^6.0.0: +ansi-styles@^6.0.0, ansi-styles@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== @@ -3919,6 +4259,11 @@ assertion-error@^1.1.0: resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" @@ -4239,6 +4584,16 @@ browserslist@^4.14.5, browserslist@^4.20.3, browserslist@^4.21.10, browserslist@ node-releases "^2.0.14" update-browserslist-db "^1.0.13" +browserslist@^4.23.1: + version "4.23.3" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.3.tgz#debb029d3c93ebc97ffbc8d9cbb03403e227c800" + integrity sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA== + dependencies: + caniuse-lite "^1.0.30001646" + electron-to-chromium "^1.5.4" + node-releases "^2.0.18" + update-browserslist-db "^1.1.0" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -4313,6 +4668,11 @@ caniuse-lite@^1.0.30001335, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.300015 resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001621.tgz#4adcb443c8b9c8303e04498318f987616b8fea2e" integrity sha512-+NLXZiviFFKX0fk8Piwv3PfLPGtRqJeq2TiNoUff/qB5KJgwecJTvCXDpmlyP/eCI/GUEmp/h/y5j0yckiiZrA== +caniuse-lite@^1.0.30001646: + version "1.0.30001655" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz#0ce881f5a19a2dcfda2ecd927df4d5c1684b982f" + integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg== + canvas-roundrect-polyfill@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz#70bf107ebe2037f26d839d7f809a26f4a95f5696" @@ -4331,18 +4691,16 @@ chai@4.3.6: pathval "^1.1.1" type-detect "^4.0.5" -chai@^4.3.10: - version "4.4.1" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" - integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g== +chai@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.1.tgz#f035d9792a22b481ead1c65908d14bb62ec1c82c" + integrity sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA== dependencies: - assertion-error "^1.1.0" - check-error "^1.0.3" - deep-eql "^4.1.3" - get-func-name "^2.0.2" - loupe "^2.3.6" - pathval "^1.1.1" - type-detect "^4.0.8" + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" chalk@^1.1.3: version "1.1.3" @@ -4385,13 +4743,18 @@ character-entities@^2.0.0: resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== -check-error@^1.0.2, check-error@^1.0.3: +check-error@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== dependencies: get-func-name "^2.0.2" +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.1, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -4556,11 +4919,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -confbox@^0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.7.tgz#ccfc0a2bcae36a84838e83a3b7f770fb17d6c579" - integrity sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA== - confusing-browser-globals@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" @@ -4615,7 +4973,7 @@ cose-base@^1.0.0: dependencies: layout-base "^1.0.0" -cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: +cosmiconfig@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" integrity sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA== @@ -4626,6 +4984,16 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +cosmiconfig@^8.1.3: + version "8.3.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz#060a2b871d66dba6c8538ea1118ba1ac16f5fae3" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + crc-32@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" @@ -4645,7 +5013,7 @@ cross-fetch@3.1.5: dependencies: node-fetch "2.6.7" -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -5088,6 +5456,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.5: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" @@ -5112,12 +5487,10 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" -deep-eql@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== - dependencies: - type-detect "^4.0.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== deep-is@^0.1.3: version "0.1.4" @@ -5179,7 +5552,7 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== -diff-sequences@^29.4.3, diff-sequences@^29.6.3: +diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== @@ -5314,6 +5687,11 @@ electron-to-chromium@^1.4.668: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.783.tgz#933887165b8b6025a81663d2d97cf4b85cde27b2" integrity sha512-bT0jEz/Xz1fahQpbZ1D7LgmPYZ3iHVY39NcWWro1+hA2IvjiPeaXtfSqrQ+nXjApMvQRE2ASt1itSLRrebHMRQ== +electron-to-chromium@^1.5.4: + version "1.5.13" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz#1abf0410c5344b2b829b7247e031f02810d442e6" + integrity sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q== + elkjs@^0.9.0: version "0.9.3" resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.3.tgz#16711f8ceb09f1b12b99e971b138a8384a529161" @@ -5621,6 +5999,35 @@ esbuild@^0.20.1: "@esbuild/win32-ia32" "0.20.2" "@esbuild/win32-x64" "0.20.2" +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + escalade@^3.1.1, escalade@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" @@ -6032,7 +6439,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -6087,10 +6494,10 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fflate@^0.7.4: - version "0.7.4" - resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50" - integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw== +fflate@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" + integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== file-entry-cache@^6.0.1: version "6.0.1" @@ -6177,7 +6584,7 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -flatted@^3.2.7, flatted@^3.2.9: +flatted@^3.2.9, flatted@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== @@ -6208,6 +6615,14 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +foreground-child@^3.1.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77" + integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -6381,7 +6796,19 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^10.4.1: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3, glob@^7.1.6: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -6685,7 +7112,7 @@ immutable@^4.0.0: resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447" integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ== -import-fresh@^3.0.0, import-fresh@^3.2.1: +import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -7011,12 +7438,12 @@ isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== -istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== -istanbul-lib-report@^3.0.0: +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== @@ -7025,16 +7452,16 @@ istanbul-lib-report@^3.0.0: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" -istanbul-reports@^3.1.5: +istanbul-reports@^3.1.7: version "3.1.7" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== @@ -7053,6 +7480,15 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jake@^10.8.5: version "10.9.1" resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.1.tgz#8dc96b7fcc41cb19aa502af506da4e1d56f5e62b" @@ -7063,10 +7499,10 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" -jest-canvas-mock@~2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341" - integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ== +jest-canvas-mock@~2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.5.2.tgz#7e21ebd75e05ab41c890497f6ba8a77f915d2ad6" + integrity sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A== dependencies: cssfontparser "^1.2.1" moo-color "^1.0.2" @@ -7162,11 +7598,6 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg== -js-tokens@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.0.tgz#0f893996d6f3ed46df7f0a3b12a03f5fd84223c1" - integrity sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ== - js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -7175,6 +7606,13 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + jsdom@22.1.0: version "22.1.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-22.1.0.tgz#0fca6d1a37fbeb7f4aac93d1090d782c56b611c8" @@ -7414,14 +7852,6 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -local-pkg@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" - integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== - dependencies: - mlly "^1.4.2" - pkg-types "^1.0.3" - localforage@^1.8.1: version "1.10.0" resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" @@ -7456,11 +7886,6 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash.pick@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" - integrity sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q== - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -7508,13 +7933,20 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.1, loupe@^2.3.6, loupe@^2.3.7: +loupe@^2.3.1: version "2.3.7" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== dependencies: get-func-name "^2.0.1" +loupe@^3.1.0, loupe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.1.tgz#71d038d59007d890e3247c5db97c1ec5a92edc54" + integrity sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw== + dependencies: + get-func-name "^2.0.1" + lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -7522,6 +7954,11 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -7555,12 +7992,21 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.1, magic-string@^0.30.5: - version "0.30.10" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.10.tgz#123d9c41a0cb5640c892b041d4cfb3bd0aa4b39e" - integrity sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ== +magic-string@^0.30.10: + version "0.30.11" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.11.tgz#301a6f93b3e8c2cb13ac1a7a673492c0dfd12954" + integrity sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A== dependencies: - "@jridgewell/sourcemap-codec" "^1.4.15" + "@jridgewell/sourcemap-codec" "^1.5.0" + +magicast@^0.3.4: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== + dependencies: + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" @@ -7892,11 +8338,23 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" +minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -7909,16 +8367,6 @@ mkdirp@^0.5.6: dependencies: minimist "^1.2.6" -mlly@^1.4.2, mlly@^1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.0.tgz#587383ae40dda23cadb11c3c3cc972b277724271" - integrity sha512-U9SDaXGEREBYQgfejV97coK0UL1r+qnF2SyO9A3qcI8MzKnsIFKHNVEkrDyNncQTKQQumsasmeq84eNMdBfsNQ== - dependencies: - acorn "^8.11.3" - pathe "^1.1.2" - pkg-types "^1.1.0" - ufo "^1.5.3" - moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" @@ -8071,6 +8519,11 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +node-releases@^2.0.18: + version "2.0.18" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" + integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== + non-layered-tidy-tree-layout@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804" @@ -8233,13 +8686,6 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" - integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -8259,6 +8705,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" + integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== + pako@1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -8279,7 +8730,7 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-json@^5.0.0: +parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -8334,6 +8785,14 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -8349,7 +8808,7 @@ pathe@^0.2.0: resolved "https://registry.yarnpkg.com/pathe/-/pathe-0.2.0.tgz#30fd7bbe0a0d91f0e60bae621f5d19e9e225c339" integrity sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw== -pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2: +pathe@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== @@ -8359,6 +8818,11 @@ pathval@^1.1.1: resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +pathval@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" + integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -8407,15 +8871,6 @@ pkg-dir@4.2.0, pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-types@^1.0.3, pkg-types@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.1.1.tgz#07b626880749beb607b0c817af63aac1845a73f2" - integrity sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ== - dependencies: - confbox "^0.1.7" - mlly "^1.7.0" - pathe "^1.1.2" - png-chunk-text@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/png-chunk-text/-/png-chunk-text-1.0.0.tgz#1c6006d8e34ba471d38e1c9c54b3f53e1085e18f" @@ -8536,6 +8991,15 @@ postcss@^8.4.32, postcss@^8.4.38, postcss@^8.4.7: picocolors "^1.0.0" source-map-js "^1.2.0" +postcss@^8.4.41: + version "8.4.43" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.43.tgz#a5ddf22f4cc38e64c6ae030182b43e539d316419" + integrity sha512-gJAQVYbh5R3gYm33FijzCZj7CHyQ3hWMgJMprLUlIYqCwTeZhBQ19wp0e9mA25BUbEvY5+EXuuaAjqQsrBxQBQ== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.1" + source-map-js "^1.2.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -9011,6 +9475,31 @@ rollup@^4.13.0, rollup@^4.2.0: "@rollup/rollup-win32-x64-msvc" "4.18.0" fsevents "~2.3.2" +rollup@^4.20.0: + version "4.21.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.21.2.tgz#f41f277a448d6264e923dd1ea179f0a926aaf9b7" + integrity sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.21.2" + "@rollup/rollup-android-arm64" "4.21.2" + "@rollup/rollup-darwin-arm64" "4.21.2" + "@rollup/rollup-darwin-x64" "4.21.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.21.2" + "@rollup/rollup-linux-arm-musleabihf" "4.21.2" + "@rollup/rollup-linux-arm64-gnu" "4.21.2" + "@rollup/rollup-linux-arm64-musl" "4.21.2" + "@rollup/rollup-linux-powerpc64le-gnu" "4.21.2" + "@rollup/rollup-linux-riscv64-gnu" "4.21.2" + "@rollup/rollup-linux-s390x-gnu" "4.21.2" + "@rollup/rollup-linux-x64-gnu" "4.21.2" + "@rollup/rollup-linux-x64-musl" "4.21.2" + "@rollup/rollup-win32-arm64-msvc" "4.21.2" + "@rollup/rollup-win32-ia32-msvc" "4.21.2" + "@rollup/rollup-win32-x64-msvc" "4.21.2" + fsevents "~2.3.2" + roughjs@4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.6.4.tgz#b6f39b44645854a6e0a4a28b078368701eb7f939" @@ -9176,7 +9665,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.2.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.0, semver@^7.5.3: +semver@^7.2.1, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -9249,7 +9738,7 @@ signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -signal-exit@^4.1.0: +signal-exit@^4.0.1, signal-exit@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== @@ -9263,7 +9752,7 @@ sirv@^1.0.7: mrmime "^1.0.0" totalist "^1.0.0" -sirv@^2.0.3: +sirv@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/sirv/-/sirv-2.0.4.tgz#5dd9a725c578e34e449f332703eb2a74e46a29b0" integrity sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ== @@ -9325,6 +9814,14 @@ smob@^1.0.0: resolved "https://registry.yarnpkg.com/smob/-/smob-1.5.0.tgz#85d79a1403abf128d24d3ebc1cdc5e1a9548d3ab" integrity sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig== +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + socket.io-client@*: version "4.7.5" resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.5.tgz#919be76916989758bdc20eec63f7ee0ae45c05b7" @@ -9408,7 +9905,7 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -std-env@^3.3.3, std-env@^3.5.0: +std-env@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== @@ -9428,7 +9925,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9437,7 +9934,16 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^5.0.0: +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" + integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.0" + +string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== @@ -9508,27 +10014,13 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -strip-ansi@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg== - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^3.0.0, strip-ansi@^6.0.0, strip-ansi@^6.0.1, strip-ansi@^7.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== - dependencies: - ansi-regex "^6.0.1" - strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -9561,13 +10053,6 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-literal@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.0.tgz#6d82ade5e2e74f5c7e8739b6c84692bd65f0bd2a" - integrity sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw== - dependencies: - js-tokens "^9.0.0" - style-loader@3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff" @@ -9704,14 +10189,14 @@ terser@^5.10.0, terser@^5.17.4, terser@^5.26.0: commander "^2.20.0" source-map-support "~0.5.20" -test-exclude@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" - integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== dependencies: "@istanbuljs/schema" "^0.1.2" - glob "^7.1.4" - minimatch "^3.0.4" + glob "^10.4.1" + minimatch "^9.0.4" text-table@^0.2.0: version "0.2.0" @@ -9728,20 +10213,25 @@ tiny-invariant@^1.1.0: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== -tinybench@^2.5.1: - version "2.8.0" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.8.0.tgz#30e19ae3a27508ee18273ffed9ac7018949acd7b" - integrity sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw== +tinybench@^2.8.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== -tinypool@^0.8.3: - version "0.8.4" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" - integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== +tinypool@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.1.tgz#c64233c4fac4304e109a64340178760116dbe1fe" + integrity sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA== -tinyspy@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" - integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== +tinyrainbow@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" + integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== + +tinyspy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.0.tgz#cb61644f2713cd84dee184863f4642e06ddf0585" + integrity sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA== to-fast-properties@^1.0.3: version "1.0.3" @@ -9862,7 +10352,7 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: +type-detect@^4.0.0, type-detect@^4.0.5: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== @@ -9950,11 +10440,6 @@ typeson@^6.0.0, typeson@^6.1.0: resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== -ufo@^1.5.3: - version "1.5.3" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.3.tgz#3325bd3c977b6c6cd3160bf4ff52989adc9d3344" - integrity sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw== - unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -10045,6 +10530,14 @@ update-browserslist-db@^1.0.13: escalade "^3.1.2" picocolors "^1.0.1" +update-browserslist-db@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz#7ca61c0d8650766090728046e416a8cde682859e" + integrity sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.1" + uri-js@^4.2.2, uri-js@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" @@ -10115,30 +10608,21 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== -v8-to-istanbul@^9.1.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" - integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA== - dependencies: - "@jridgewell/trace-mapping" "^0.3.12" - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^2.0.0" - -vite-node@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.0.tgz#2c7e61129bfecc759478fa592754fd9704aaba7f" - integrity sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw== +vite-node@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.0.5.tgz#36d909188fc6e3aba3da5fc095b3637d0d18e27b" + integrity sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q== dependencies: cac "^6.7.14" - debug "^4.3.4" - pathe "^1.1.1" - picocolors "^1.0.0" + debug "^4.3.5" + pathe "^1.1.2" + tinyrainbow "^1.2.0" vite "^5.0.0" -vite-plugin-checker@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.6.1.tgz#51a0e654e033b5b9ad6301ae4d0ed0f5886d437c" - integrity sha512-4fAiu3W/IwRJuJkkUZlWbLunSzsvijDf0eDN6g/MGh6BUK4SMclOTGbLJCPvdAcMOQvVmm8JyJeYLYd4//8CkA== +vite-plugin-checker@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.7.2.tgz#093ffdf9ccf51b2c9eab7101480bd0217ae99536" + integrity sha512-xeYeJbG0gaCaT0QcUC4B2Zo4y5NR8ZhYenc5gPbttrZvraRFwkEADCYwq+BfEHl9zYz7yf85TxsiGoYwyyIjhw== dependencies: "@babel/code-frame" "^7.12.13" ansi-escapes "^4.3.0" @@ -10147,10 +10631,7 @@ vite-plugin-checker@0.6.1: commander "^8.0.0" fast-glob "^3.2.7" fs-extra "^11.1.0" - lodash.debounce "^4.0.8" - lodash.pick "^4.4.0" npm-run-path "^4.0.1" - semver "^7.5.0" strip-ansi "^6.0.0" tiny-invariant "^1.1.0" vscode-languageclient "^7.0.0" @@ -10194,13 +10675,14 @@ vite-plugin-pwa@0.17.4: workbox-build "^7.0.0" workbox-window "^7.0.0" -vite-plugin-svgr@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/vite-plugin-svgr/-/vite-plugin-svgr-2.4.0.tgz#9b14953955e79893ea7718089b9777a494e38fc6" - integrity sha512-q+mJJol6ThvqkkJvvVFEndI4EaKIjSI0I3jNFgSoC9fXAz1M7kYTVUin8fhUsFojFDKZ9VHKtX6NXNaOLpbsHA== +vite-plugin-svgr@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz#9f3bf5206b0ec510287e56d16f1915e729bb4e6b" + integrity sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA== dependencies: - "@rollup/pluginutils" "^5.0.2" - "@svgr/core" "^6.5.1" + "@rollup/pluginutils" "^5.0.5" + "@svgr/core" "^8.1.0" + "@svgr/plugin-jsx" "^8.1.0" vite@5.0.12: version "5.0.12" @@ -10213,6 +10695,17 @@ vite@5.0.12: optionalDependencies: fsevents "~2.3.3" +vite@5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.2.tgz#8acb6ec4bfab823cdfc1cb2d6c53ed311bc4e47e" + integrity sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.41" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + vite@^5.0.0: version "5.2.11" resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.11.tgz#726ec05555431735853417c3c0bfb36003ca0cbd" @@ -10224,38 +10717,37 @@ vite@^5.0.0: optionalDependencies: fsevents "~2.3.3" -vitest-canvas-mock@0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/vitest-canvas-mock/-/vitest-canvas-mock-0.3.2.tgz#d52031c423519e0c7bf2687ca6d7ad2e926ea182" - integrity sha512-lds7MKxvFFPDCGLXsQI2ym1fxvC93DaS0Bb6sdjvylFyL6NYrAAcPb6xZGF2sMOt5fSLHddqAQaujqpbc3p0Zg== +vitest-canvas-mock@0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/vitest-canvas-mock/-/vitest-canvas-mock-0.3.3.tgz#97e3b5f53003c5cbb9540204ff3122cd25be4dcd" + integrity sha512-3P968tYBpqYyzzOaVtqnmYjqbe13576/fkjbDEJSfQAkHtC5/UjuRHOhFEN/ZV5HVZIkaROBUWgazDKJ+Ibw+Q== dependencies: - jest-canvas-mock "~2.4.0" + jest-canvas-mock "~2.5.2" -vitest@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.0.tgz#9d5ad4752a3c451be919e412c597126cffb9892f" - integrity sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA== +vitest@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.0.5.tgz#2f15a532704a7181528e399cc5b754c7f335fd62" + integrity sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA== dependencies: - "@vitest/expect" "1.6.0" - "@vitest/runner" "1.6.0" - "@vitest/snapshot" "1.6.0" - "@vitest/spy" "1.6.0" - "@vitest/utils" "1.6.0" - acorn-walk "^8.3.2" - chai "^4.3.10" - debug "^4.3.4" + "@ampproject/remapping" "^2.3.0" + "@vitest/expect" "2.0.5" + "@vitest/pretty-format" "^2.0.5" + "@vitest/runner" "2.0.5" + "@vitest/snapshot" "2.0.5" + "@vitest/spy" "2.0.5" + "@vitest/utils" "2.0.5" + chai "^5.1.1" + debug "^4.3.5" execa "^8.0.1" - local-pkg "^0.5.0" - magic-string "^0.30.5" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.5.0" - strip-literal "^2.0.0" - tinybench "^2.5.1" - tinypool "^0.8.3" + magic-string "^0.30.10" + pathe "^1.1.2" + std-env "^3.7.0" + tinybench "^2.8.0" + tinypool "^1.0.0" + tinyrainbow "^1.2.0" vite "^5.0.0" - vite-node "1.6.0" - why-is-node-running "^2.2.2" + vite-node "2.0.5" + why-is-node-running "^2.3.0" vscode-jsonrpc@6.0.0: version "6.0.0" @@ -10594,10 +11086,10 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -why-is-node-running@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e" - integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA== +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== dependencies: siginfo "^2.0.0" stackback "0.0.2" @@ -10628,9 +11120,9 @@ workbox-broadcast-update@7.1.0: workbox-core "7.1.0" workbox-build@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.1.0.tgz#64d1532f1b9ad04d2b8b43ce0b9af06ba3fdd159" - integrity sha512-F6R94XAxjB2j4ETMkP1EXKfjECOtDmyvt0vz3BzgWJMI68TNSXIVNkgatwUKBlPGOfy9n2F/4voYRNAhEvPJNg== + version "7.1.1" + resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-7.1.1.tgz#bfbd4c44848c175b7773f921be3597cbfaaef827" + integrity sha512-WdkVdC70VMpf5NBCtNbiwdSZeKVuhTEd5PV3mAwpTQCGAB5XbOny1P9egEgNdetv4srAMmMKjvBk4RD58LpooA== dependencies: "@apideck/better-ajv-errors" "^0.3.1" "@babel/core" "^7.24.4" @@ -10770,6 +11262,15 @@ workbox-window@7.1.0, workbox-window@^7.0.0: "@types/trusted-types" "^2.0.2" workbox-core "7.1.0" +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" @@ -10779,14 +11280,14 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" wrappy@1: version "1.0.2" @@ -10879,11 +11380,6 @@ yauzl@^2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" -yocto-queue@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" - integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== - zustand@^4.3.2: version "4.5.2" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.2.tgz#fddbe7cac1e71d45413b3682cdb47b48034c3848" From 6ff56c36e3a19194d9990beaeab6bcac76fbbbc2 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 6 Sep 2024 16:41:37 +0530 Subject: [PATCH 08/13] fix: add partial mocking (#8473) * fix: add partial mocking * lint * Update packages/utils/export.test.ts --- packages/excalidraw/tests/flip.test.tsx | 17 ++++++++--------- packages/utils/export.test.ts | 1 - 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 5cf4cd55c..53cbc53c8 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -23,7 +23,6 @@ import { Excalidraw } from "../index"; import type { NormalizedZoomValue } from "../types"; import { ROUNDNESS } from "../constants"; import { vi } from "vitest"; -import * as blob from "../data/blob"; import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; @@ -33,15 +32,15 @@ import { point, type Radians } from "../../math"; const { h } = window; const mouse = new Pointer("mouse"); -// This needs to fixed in vitest mock, as when importActual used with mock -// the tests hangs - https://github.com/vitest-dev/vitest/issues/546. -// But fortunately spying and mocking the return value of spy works :p -const resizeImageFileSpy = vi.spyOn(blob, "resizeImageFile"); -const generateIdFromFileSpy = vi.spyOn(blob, "generateIdFromFile"); - -resizeImageFileSpy.mockImplementation(async (imageFile: File) => imageFile); -generateIdFromFileSpy.mockImplementation(async () => "fileId" as FileId); +vi.mock("../data/blob", async (actual) => { + const orig: Object = await actual(); + return { + ...orig, + resizeImageFile: (imageFile: File) => imageFile, + generateIdFromFile: () => "fileId" as FileId, + }; +}); beforeEach(async () => { // Unmount ReactDOM from root diff --git a/packages/utils/export.test.ts b/packages/utils/export.test.ts index aa1049cc1..b04ec44e2 100644 --- a/packages/utils/export.test.ts +++ b/packages/utils/export.test.ts @@ -32,7 +32,6 @@ describe("exportToCanvas", async () => { describe("exportToBlob", async () => { describe("mime type", () => { - // afterEach(vi.restoreAllMocks); it("should change image/jpg to image/jpeg", async () => { const blob = await utils.exportToBlob({ ...diagramFactory(), From 5a11c70714f7b743af8fd9c08258191afc752b8d Mon Sep 17 00:00:00 2001 From: Abhishek Mehandiratta <36722596+abhi12299@users.noreply.github.com> Date: Mon, 9 Sep 2024 03:26:00 +0530 Subject: [PATCH 09/13] fix: image rendering issue when passed in `initialData` (#8471) --- packages/excalidraw/components/App.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 00c0a882a..183df35cc 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2295,6 +2295,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 @@ -2360,6 +2363,15 @@ class App extends React.Component { return false; }; + private clearImageShapeCache() { + this.scene.getNonDeletedElements().forEach((element) => { + if (isInitializedImageElement(element) && this.files[element.fileId]) { + this.imageCache.delete(element.fileId); + ShapeCache.delete(element); + } + }); + } + public async componentDidMount() { this.unmounted = false; this.excalidrawContainerValue.container = @@ -3674,15 +3686,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(); this.scene.triggerUpdate(); this.addNewImagesToImageCache(); From 6959a363f0ba8d0e8b05442f4c8f3cc4a2e00ca6 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 9 Sep 2024 23:12:07 +0800 Subject: [PATCH 10/13] feat: canvas search (#8438) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .eslintignore | 1 + excalidraw-app/components/AppMainMenu.tsx | 1 + .../actions/actionToggleSearchMenu.ts | 51 ++ packages/excalidraw/actions/index.ts | 2 + packages/excalidraw/actions/shortcuts.ts | 4 +- packages/excalidraw/actions/types.ts | 6 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 28 +- .../CommandPalette/CommandPalette.tsx | 15 +- .../excalidraw/components/DefaultSidebar.tsx | 6 +- packages/excalidraw/components/HelpDialog.tsx | 4 + packages/excalidraw/components/HintViewer.tsx | 8 + packages/excalidraw/components/LayerUI.tsx | 28 +- .../excalidraw/components/SearchMenu.scss | 110 +++ packages/excalidraw/components/SearchMenu.tsx | 671 ++++++++++++++++++ .../excalidraw/components/SearchSidebar.tsx | 29 + packages/excalidraw/components/TextField.scss | 28 +- packages/excalidraw/components/TextField.tsx | 10 +- .../components/canvases/InteractiveCanvas.tsx | 1 + packages/excalidraw/components/icons.tsx | 8 + .../components/main-menu/DefaultItems.tsx | 24 +- packages/excalidraw/constants.ts | 5 + packages/excalidraw/css/theme.scss | 6 +- packages/excalidraw/element/textElement.ts | 7 +- packages/excalidraw/locales/en.json | 8 + .../excalidraw/renderer/interactiveScene.ts | 49 +- .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../__snapshots__/excalidraw.test.tsx.snap | 49 ++ .../tests/__snapshots__/history.test.tsx.snap | 58 ++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/tests/helpers/ui.ts | 20 +- packages/excalidraw/tests/queries/dom.ts | 2 +- packages/excalidraw/tests/search.test.tsx | 143 ++++ packages/excalidraw/types.ts | 17 + .../utils/__snapshots__/export.test.ts.snap | 1 + 35 files changed, 1424 insertions(+), 47 deletions(-) create mode 100644 packages/excalidraw/actions/actionToggleSearchMenu.ts create mode 100644 packages/excalidraw/components/SearchMenu.scss create mode 100644 packages/excalidraw/components/SearchMenu.tsx create mode 100644 packages/excalidraw/components/SearchSidebar.tsx create mode 100644 packages/excalidraw/tests/search.test.tsx 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/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/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts new file mode 100644 index 000000000..6072fd30c --- /dev/null +++ b/packages/excalidraw/actions/actionToggleSearchMenu.ts @@ -0,0 +1,51 @@ +import { KEYS } from "../keys"; +import { register } from "./register"; +import type { AppState } from "../types"; +import { searchIcon } from "../components/icons"; +import { StoreAction } from "../store"; +import { CLASSES, SEARCH_SIDEBAR } from "../constants"; + +export const actionToggleSearchMenu = register({ + name: "searchMenu", + icon: searchIcon, + keywords: ["search", "find"], + label: "search.title", + viewMode: true, + trackEvent: { + category: "search_menu", + action: "toggle", + predicate: (appState) => appState.gridModeEnabled, + }, + perform(elements, appState, _, app) { + if (appState.openSidebar?.name === SEARCH_SIDEBAR.name) { + const searchInput = + app.excalidrawContainerValue.container?.querySelector( + `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`, + ); + + if (searchInput?.matches(":focus")) { + return { + appState: { ...appState, openSidebar: null }, + storeAction: StoreAction.NONE, + }; + } + + searchInput?.focus(); + return false; + } + + return { + appState: { + ...appState, + openSidebar: { name: SEARCH_SIDEBAR.name }, + openDialog: null, + }, + storeAction: StoreAction.NONE, + }; + }, + checked: (appState: AppState) => appState.gridModeEnabled, + predicate: (element, appState, props) => { + return props.gridModeEnabled === undefined; + }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 092060425..eff5de297 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; + +export { actionToggleSearchMenu } from "./actionToggleSearchMenu"; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index a5c3bad66..025d91037 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -51,7 +51,8 @@ export type ShortcutName = > | "saveScene" | "imageExport" - | "commandPalette"; + | "commandPalette" + | "searchMenu"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -112,6 +113,7 @@ const shortcutMap: Record = { saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], toggleShortcuts: [getShortcutKey("?")], + searchMenu: [getShortcutKey("CtrlOrCmd+F")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 2d0275bb3..15364d21f 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -137,7 +137,8 @@ export type ActionName = | "wrapTextInContainer" | "commandPalette" | "autoResize" - | "elementStats"; + | "elementStats" + | "searchMenu"; export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; @@ -191,7 +192,8 @@ export interface Action { | "history" | "menu" | "collab" - | "hyperlink"; + | "hyperlink" + | "search_menu"; action?: string; predicate?: ( appState: Readonly, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index faad34057..cb80c6cd8 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit< objectsSnapModeEnabled: false, userToFollow: null, followedBy: new Set(), + searchMatches: [], }; }; @@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (< objectsSnapModeEnabled: { browser: true, export: false, server: false }, userToFollow: { browser: false, export: false, server: false }, followedBy: { browser: false, export: false, server: false }, + searchMatches: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 183df35cc..e067bba7d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -440,6 +440,7 @@ import { FlowChartNavigator, getLinkDirectionFromKey, } from "../element/flowchart"; +import { searchItemInFocusAtom } from "./SearchMenu"; import type { LocalPoint, Radians } from "../../math"; import { point, pointDistance, vector } from "../../math"; @@ -548,6 +549,7 @@ class App extends React.Component { public scene: Scene; public fonts: Fonts; public renderer: Renderer; + public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; @@ -555,7 +557,7 @@ class App extends React.Component { public id: string; private store: Store; private history: History; - private excalidrawContainerValue: { + public excalidrawContainerValue: { container: HTMLDivElement | null; id: string; }; @@ -682,6 +684,7 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + this.visibleElements = []; this.store = new Store(); this.history = new History(); @@ -1480,6 +1483,7 @@ class App extends React.Component { newElementId: this.state.newElement?.id, pendingImageElementId: this.state.pendingImageElementId, }); + this.visibleElements = visibleElements; const allElementsMap = this.scene.getNonDeletedElementsMap(); @@ -3800,7 +3804,7 @@ class App extends React.Component { }, ); - private getEditorUIOffsets = (): { + public getEditorUIOffsets = (): { top: number; right: number; bottom: number; @@ -5973,6 +5977,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 @@ -6401,8 +6415,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(); } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 36c9a9a68..e732acfb5 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon"; import { SHAPES } from "../../shapes"; import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions"; import { useStableCallback } from "../../hooks/useStableCallback"; -import { actionClearCanvas, actionLink } from "../../actions"; +import { + actionClearCanvas, + actionLink, + actionToggleSearchMenu, +} from "../../actions"; import { jotaiStore } from "../../jotai"; import { activeConfirmDialogAtom } from "../ActiveConfirmDialog"; import type { CommandPaletteItem } from "./types"; @@ -382,6 +386,15 @@ function CommandPaletteInner({ } }, }, + { + label: t("search.title"), + category: DEFAULT_CATEGORIES.app, + icon: searchIcon, + viewMode: true, + perform: () => { + actionManager.executeAction(actionToggleSearchMenu); + }, + }, { label: t("labels.changeStroke"), keywords: ["color", "outline"], diff --git a/packages/excalidraw/components/DefaultSidebar.tsx b/packages/excalidraw/components/DefaultSidebar.tsx index 78b03007f..5cd588933 100644 --- a/packages/excalidraw/components/DefaultSidebar.tsx +++ b/packages/excalidraw/components/DefaultSidebar.tsx @@ -2,7 +2,6 @@ import clsx from "clsx"; import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants"; import { useTunnels } from "../context/tunnels"; import { useUIAppState } from "../context/ui-appState"; -import { t } from "../i18n"; import type { MarkOptional, Merge } from "../utility-types"; import { composeEventHandlers } from "../utils"; import { useExcalidrawSetAppState } from "./App"; @@ -10,6 +9,8 @@ import { withInternalFallback } from "./hoc/withInternalFallback"; import { LibraryMenu } from "./LibraryMenu"; import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common"; import { Sidebar } from "./Sidebar/Sidebar"; +import "../components/dropdownMenu/DropdownMenu.scss"; +import { t } from "../i18n"; const DefaultSidebarTrigger = withInternalFallback( "DefaultSidebarTrigger", @@ -68,8 +69,7 @@ export const DefaultSidebar = Object.assign( return ( void }) => { label={t("stats.fullTitle")} shortcuts={[getShortcutKey("Alt+/")]} /> + )} + @@ -362,16 +365,21 @@ const LayerUI = ({ const renderSidebars = () => { return ( - { - trackEvent( - "sidebar", - `toggleDock (${docked ? "dock" : "undock"})`, - `(${device.editor.isMobile ? "mobile" : "desktop"})`, - ); - }} - /> + <> + {appState.openSidebar?.name === SEARCH_SIDEBAR.name && ( + + )} + { + trackEvent( + "sidebar", + `toggleDock (${docked ? "dock" : "undock"})`, + `(${device.editor.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> + ); }; diff --git a/packages/excalidraw/components/SearchMenu.scss b/packages/excalidraw/components/SearchMenu.scss new file mode 100644 index 000000000..ae6bb5647 --- /dev/null +++ b/packages/excalidraw/components/SearchMenu.scss @@ -0,0 +1,110 @@ +@import "open-color/open-color"; + +.excalidraw { + .layer-ui__search { + flex: 1 0 auto; + display: flex; + flex-direction: column; + padding: 8px 0 0 0; + } + + .layer-ui__search-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 0.75rem; + .ExcTextField { + flex: 1 0 auto; + } + + .ExcTextField__input { + background-color: #f5f5f9; + @at-root .excalidraw.theme--dark#{&} { + background-color: #31303b; + } + + border-radius: var(--border-radius-md); + border: 0; + + input::placeholder { + font-size: 0.9rem; + } + } + } + + .layer-ui__search-count { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 0 8px; + margin: 0 0.75rem 0.25rem 0.75rem; + font-size: 0.8em; + + .result-nav { + display: flex; + + .result-nav-btn { + width: 36px; + height: 36px; + --button-border: transparent; + + &:active { + background-color: var(--color-surface-high); + } + + &:first { + margin-right: 4px; + } + } + } + } + + .layer-ui__search-result-container { + overflow-y: auto; + flex: 1 1 0; + display: flex; + flex-direction: column; + + gap: 0.125rem; + } + + .layer-ui__result-item { + display: flex; + align-items: center; + min-height: 2rem; + flex: 0 0 auto; + padding: 0.25rem 0.75rem; + cursor: pointer; + border: 1px solid transparent; + outline: none; + + margin: 0 0.75rem; + border-radius: var(--border-radius-md); + + .text-icon { + width: 1rem; + height: 1rem; + margin-right: 0.75rem; + } + + .preview-text { + flex: 1; + max-height: 48px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-all; + } + + &:hover { + background-color: var(--color-surface-high); + } + &:active { + border-color: var(--color-primary); + } + + &.active { + background-color: var(--color-surface-high); + } + } +} diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx new file mode 100644 index 000000000..f05660a52 --- /dev/null +++ b/packages/excalidraw/components/SearchMenu.tsx @@ -0,0 +1,671 @@ +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 "./SearchMenu.scss"; +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"; + +const searchKeywordAtom = atom(""); +export const searchItemInFocusAtom = atom(null); + +const SEARCH_DEBOUNCE = 350; + +type SearchMatchItem = { + textElement: ExcalidrawTextElement; + keyword: string; + index: number; + preview: { + indexInKeyword: number; + previewText: string; + moreBefore: boolean; + moreAfter: boolean; + }; + matchedLines: { + offsetX: number; + offsetY: number; + width: number; + height: number; + }[]; +}; + +type SearchMatches = { + nonce: number | null; + items: SearchMatchItem[]; +}; + +export const SearchMenu = () => { + const app = useApp(); + const setAppState = useExcalidrawSetAppState(); + + const searchInputRef = useRef(null); + + const [keyword, setKeyword] = useAtom(searchKeywordAtom, jotaiScope); + const [searchMatches, setSearchMatches] = useState({ + nonce: null, + items: [], + }); + const searchedKeywordRef = useRef(); + const lastSceneNonceRef = useRef(); + + const [focusIndex, setFocusIndex] = useAtom( + searchItemInFocusAtom, + jotaiScope, + ); + const elementsMap = app.scene.getNonDeletedElementsMap(); + + useEffect(() => { + const trimmedKeyword = keyword.trim(); + if ( + trimmedKeyword !== searchedKeywordRef.current || + app.scene.getSceneNonce() !== lastSceneNonceRef.current + ) { + searchedKeywordRef.current = null; + handleSearch(trimmedKeyword, app, (matchItems, index) => { + setSearchMatches({ + nonce: randomInteger(), + items: matchItems, + }); + setFocusIndex(index); + searchedKeywordRef.current = trimmedKeyword; + lastSceneNonceRef.current = app.scene.getSceneNonce(); + setAppState({ + searchMatches: matchItems.map((searchMatch) => ({ + id: searchMatch.textElement.id, + focus: false, + matchedLines: searchMatch.matchedLines, + })), + }); + }); + } + }, [ + keyword, + 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(() => { + if (searchMatches.items.length > 0 && focusIndex !== null) { + const match = searchMatches.items[focusIndex]; + + if (match) { + const matchAsElement = newTextElement({ + text: match.keyword, + 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, + }); + + 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(), + ) + ) { + app.scrollToContent(matchAsElement, { + fitToContent: true, + animate: true, + duration: 300, + }); + } + + const nextMatches = searchMatches.items.map((match, index) => { + if (index === focusIndex) { + return { + id: match.textElement.id, + focus: true, + matchedLines: match.matchedLines, + }; + } + return { + id: match.textElement.id, + focus: false, + matchedLines: match.matchedLines, + }; + }); + + setAppState({ + searchMatches: nextMatches, + }); + } + } + }, [app, focusIndex, searchMatches, setAppState]); + + useEffect(() => { + return () => { + setFocusIndex(null); + searchedKeywordRef.current = null; + lastSceneNonceRef.current = undefined; + setAppState({ + searchMatches: [], + }); + }; + }, [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(); + } 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]); + + /** + * NOTE: + * + * for testing purposes, we're manually focusing instead of + * setting `selectOnRender` on + */ + useEffect(() => { + searchInputRef.current?.focus(); + }, []); + + const matchCount = `${searchMatches.items.length} ${ + searchMatches.items.length === 1 + ? t("search.singleResult") + : t("search.multipleResults") + }`; + + return ( +

+
+ { + setKeyword(value); + }} + /> +
+ +
+ {searchMatches.items.length > 0 && ( + <> + {focusIndex !== null && focusIndex > -1 ? ( +
+ {focusIndex + 1} / {matchCount} +
+ ) : ( +
{matchCount}
+ )} +
+ + +
+ + )} + + {searchMatches.items.length === 0 && + keyword && + searchedKeywordRef.current && ( +
{t("search.noMatch")}
+ )} +
+ + +
+ ); +}; + +const ListItem = (props: { + preview: SearchMatchItem["preview"]; + trimmedKeyword: string; + highlighted: boolean; + onClick?: () => void; +}) => { + const preview = [ + props.preview.moreBefore ? "..." : "", + props.preview.previewText.slice(0, props.preview.indexInKeyword), + props.preview.previewText.slice( + props.preview.indexInKeyword, + props.preview.indexInKeyword + props.trimmedKeyword.length, + ), + props.preview.previewText.slice( + props.preview.indexInKeyword + props.trimmedKeyword.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; + trimmedKeyword: string; +} + +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, keyword: string) => { + const WORDS_BEFORE = 2; + const WORDS_AFTER = 5; + + const substrBeforeKeyword = text.slice(0, index); + const wordsBeforeKeyword = substrBeforeKeyword.split(/\s+/); + // text = "small", keyword = "mall", not complete before + // text = "small", keyword = "smal", complete before + const isKeywordCompleteBefore = substrBeforeKeyword.endsWith(" "); + const startWordIndex = + wordsBeforeKeyword.length - + WORDS_BEFORE - + 1 - + (isKeywordCompleteBefore ? 0 : 1); + let wordsBeforeAsString = + wordsBeforeKeyword + .slice(startWordIndex <= 0 ? 0 : startWordIndex) + .join(" ") + (isKeywordCompleteBefore ? " " : ""); + + const MAX_ALLOWED_CHARS = 20; + + wordsBeforeAsString = + wordsBeforeAsString.length > MAX_ALLOWED_CHARS + ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS) + : wordsBeforeAsString; + + const substrAfterKeyword = text.slice(index + keyword.length); + const wordsAfter = substrAfterKeyword.split(/\s+/); + // text = "small", keyword = "mall", complete after + // text = "small", keyword = "smal", not complete after + const isKeywordCompleteAfter = !substrAfterKeyword.startsWith(" "); + const numberOfWordsToTake = isKeywordCompleteAfter + ? WORDS_AFTER + 1 + : WORDS_AFTER; + const wordsAfterAsString = + (isKeywordCompleteAfter ? "" : " ") + + wordsAfter.slice(0, numberOfWordsToTake).join(" "); + + return { + indexInKeyword: wordsBeforeAsString.length, + previewText: wordsBeforeAsString + keyword + 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, + keyword: string, + 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 remainingKeyword = textElement.originalText.slice( + index, + index + keyword.length, + ); + const matchedLines: { + offsetX: number; + offsetY: number; + width: number; + height: number; + }[] = []; + + for (const lineIndexRange of lineIndexRanges) { + if (remainingKeyword === "") { + 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 = remainingKeyword.slice(0, matchCapacity); + remainingKeyword = remainingKeyword.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( + ( + keyword: string, + app: AppClassProperties, + cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void, + ) => { + if (!keyword || keyword === "") { + 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(keyword), "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, keyword); + const matchedLines = getMatchedLines(textEl, keyword, match.index); + + if (matchedLines.length > 0) { + matchItems.push({ + textElement: textEl, + keyword, + preview, + index: match.index, + matchedLines, + }); + } + } + } + + const visibleIds = new Set( + app.visibleElements.map((visibleElement) => visibleElement.id), + ); + + const focusIndex = + matchItems.findIndex((matchItem) => + visibleIds.has(matchItem.textElement.id), + ) ?? null; + + cb(matchItems, focusIndex); + }, + SEARCH_DEBOUNCE, +); diff --git a/packages/excalidraw/components/SearchSidebar.tsx b/packages/excalidraw/components/SearchSidebar.tsx new file mode 100644 index 000000000..7cb93ac5f --- /dev/null +++ b/packages/excalidraw/components/SearchSidebar.tsx @@ -0,0 +1,29 @@ +import { SEARCH_SIDEBAR } from "../constants"; +import { t } from "../i18n"; +import { SearchMenu } from "./SearchMenu"; +import { Sidebar } from "./Sidebar/Sidebar"; + +export const SearchSidebar = () => { + return ( + + + +
+ {t("search.title")} +
+
+ +
+
+ ); +}; diff --git a/packages/excalidraw/components/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..e1d346111 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, @@ -56,14 +60,16 @@ export const TextField = forwardRef( return (
{ innerRef.current?.focus(); }} > -
{label}
+ {icon} + {label &&
{label}
}
, tablerIconProps, ); + +export const upIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 26ef26000..bb3059db5 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -15,6 +15,7 @@ import { LoadIcon, MoonIcon, save, + searchIcon, SunIcon, TrashIcon, usersIcon, @@ -27,6 +28,7 @@ import { actionLoadScene, actionSaveToActiveFile, actionShortcuts, + actionToggleSearchMenu, actionToggleTheme, } from "../../actions"; import clsx from "clsx"; @@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten import { THEME } from "../../constants"; import type { Theme } from "../../element/types"; import { trackEvent } from "../../analytics"; - import "./DefaultItems.scss"; export const LoadScene = () => { @@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => { }; CommandPalette.displayName = "CommandPalette"; +export const SearchMenu = (opts?: { className?: string }) => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + + return ( + { + actionManager.executeAction(actionToggleSearchMenu); + }} + shortcut={getShortcutFromShortcutName("searchMenu")} + aria-label={t("search.title")} + className={opts?.className} + > + {t("search.title")} + + ); +}; +SearchMenu.displayName = "SearchMenu"; + export const Help = () => { const { t } = useI18n(); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 9601807f6..31982d4fb 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -113,6 +113,7 @@ export const ENV = { export const CLASSES = { SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", + SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", }; /** @@ -382,6 +383,10 @@ export const DEFAULT_SIDEBAR = { defaultTab: LIBRARY_SIDEBAR_TAB, } as const; +export const SEARCH_SIDEBAR = { + name: "search", +}; + export const LIBRARY_DISABLED_TYPES = new Set([ "iframe", "embeddable", diff --git a/packages/excalidraw/css/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/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/locales/en.json b/packages/excalidraw/locales/en.json index ebf9ff872..ff1fa2026 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -162,6 +162,13 @@ "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", "hint_emptyPrivateLibrary": "Select an item on canvas to add it here." }, + "search": { + "title": "Find on canvas", + "noMatch": "No matches found...", + "singleResult": "result", + "multipleResults": "results", + "placeholder": "Find text..." + }, "buttons": { "clearReset": "Reset the canvas", "exportJSON": "Export to file", @@ -297,6 +304,7 @@ "shapes": "Shapes" }, "hints": { + "dismissSearch": "Escape to dismiss search", "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool", "linearElement": "Click to start multiple points, drag for single line", "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 5a27a3312..0d03b0f5a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -30,8 +30,12 @@ import { shouldShowBoundingBox, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import type { InteractiveCanvasAppState } 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"; @@ -952,9 +956,48 @@ const _renderInteractiveScene = ({ context.restore(); } + appState.searchMatches.forEach(({ id, focus, matchedLines }) => { + const element = elementsMap.get(id); + + if (element && isTextElement(element)) { + const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + + context.save(); + if (appState.theme === THEME.LIGHT) { + if (focus) { + context.fillStyle = "rgba(255, 124, 0, 0.4)"; + } else { + context.fillStyle = "rgba(255, 226, 0, 0.4)"; + } + } else if (focus) { + context.fillStyle = "rgba(229, 82, 0, 0.4)"; + } else { + context.fillStyle = "rgba(99, 52, 0, 0.4)"; + } + + context.translate(appState.scrollX, appState.scrollY); + context.translate(cx, cy); + context.rotate(element.angle); + + matchedLines.forEach((matchedLine) => { + context.fillRect( + elementX1 + matchedLine.offsetX - cx, + elementY1 + matchedLine.offsetY - cy, + matchedLine.width, + matchedLine.height, + ); + }); + + context.restore(); + } + }); + renderSnaps(context, appState); - // Reset zoom context.restore(); renderRemoteCursors({ diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f9904a4d..3a5e14065 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0_copy": true, }, @@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, @@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, @@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, "id1": true, @@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": {}, @@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id0": true, }, @@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "scrollX": 0, "scrollY": 0, "scrolledOutside": false, + "searchMatches": [], "selectedElementIds": { "id1": true, }, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 2994cfc3e..e5e431dfc 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -239,6 +239,55 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende Ctrl+Shift+E
+
diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index e1d346111..c5bdc8260 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -51,6 +51,8 @@ export const TextField = forwardRef( useLayoutEffect(() => { if (selectOnRender) { + // focusing first is needed because vitest/jsdom + innerRef.current?.focus(); innerRef.current?.select(); } }, [selectOnRender]); diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 409d5825b..6c16e5190 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -76,11 +76,12 @@ export class API { }); }; - static updateElement = ( - ...[element, updates]: Parameters + // eslint-disable-next-line prettier/prettier + static updateElement = ( + ...args: Parameters> ) => { act(() => { - mutateElement(element, updates); + mutateElement(...args); }); }; diff --git a/packages/excalidraw/tests/search.test.tsx b/packages/excalidraw/tests/search.test.tsx index bed596c43..ae729b210 100644 --- a/packages/excalidraw/tests/search.test.tsx +++ b/packages/excalidraw/tests/search.test.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { render, waitFor } from "./test-utils"; -import { Excalidraw, mutateElement } from "../index"; +import { act, render, waitFor } from "./test-utils"; +import { Excalidraw } from "../index"; import { CLASSES, SEARCH_SIDEBAR } from "../constants"; import { Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; @@ -22,7 +22,7 @@ const querySearchInput = async () => { describe("search", () => { beforeEach(async () => { await render(); - h.setState({ + API.setAppState({ openSidebar: null, }); }); @@ -50,7 +50,9 @@ describe("search", () => { `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`, ); - searchInput?.blur(); + act(() => { + searchInput?.blur(); + }); expect(h.app.state.openSidebar).not.toBeNull(); expect(searchInput?.matches(":focus")).toBe(false); @@ -109,7 +111,7 @@ describe("search", () => { }), ]); - mutateElement(h.elements[0] as ExcalidrawTextElement, { + API.updateElement(h.elements[0] as ExcalidrawTextElement, { text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns", originalText: "test text split into multiple lines", }); From 72b7c937b1b9cd4d1155563f15ccbbac5c71f8dc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 10 Sep 2024 21:26:01 +0200 Subject: [PATCH 12/13] feat: smarter zooming when scrolling to match & only match on search/switch (#8488) --- excalidraw-app/data/LocalData.ts | 9 +- packages/excalidraw/components/SearchMenu.tsx | 200 +++++++++++------- 2 files changed, 127 insertions(+), 82 deletions(-) diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 468126b2b..df753c89b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -20,6 +20,7 @@ import { get, } from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import { SEARCH_SIDEBAR } from "../../packages/excalidraw/constants"; import type { LibraryPersistedData } from "../../packages/excalidraw/data/library"; import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; @@ -66,13 +67,19 @@ const saveDataStateToLocalStorage = ( appState: AppState, ) => { try { + const _appState = clearAppStateForLocalStorage(appState); + + if (_appState.openSidebar?.name === SEARCH_SIDEBAR.name) { + _appState.openSidebar = null; + } + localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, JSON.stringify(clearElementsForLocalStorage(elements)), ); localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, - JSON.stringify(clearAppStateForLocalStorage(appState)), + JSON.stringify(_appState), ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); } catch (error: any) { diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx index a7bf9b447..58dd622dd 100644 --- a/packages/excalidraw/components/SearchMenu.tsx +++ b/packages/excalidraw/components/SearchMenu.tsx @@ -21,17 +21,17 @@ import { useStable } from "../hooks/useStable"; import "./SearchMenu.scss"; -const searchKeywordAtom = atom(""); +const searchQueryAtom = atom(""); export const searchItemInFocusAtom = atom(null); const SEARCH_DEBOUNCE = 350; type SearchMatchItem = { textElement: ExcalidrawTextElement; - keyword: string; + searchQuery: SearchQuery; index: number; preview: { - indexInKeyword: number; + indexInSearchQuery: number; previewText: string; moreBefore: boolean; moreAfter: boolean; @@ -49,19 +49,25 @@ type SearchMatches = { items: SearchMatchItem[]; }; +type SearchQuery = string & { _brand: "SearchQuery" }; + export const SearchMenu = () => { const app = useApp(); const setAppState = useExcalidrawSetAppState(); const searchInputRef = useRef(null); - const [keyword, setKeyword] = useAtom(searchKeywordAtom, jotaiScope); + 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 searchedKeywordRef = useRef(); - const lastSceneNonceRef = useRef(); + const searchedQueryRef = useRef(null); + const lastSceneNonceRef = useRef(undefined); const [focusIndex, setFocusIndex] = useAtom( searchItemInFocusAtom, @@ -70,19 +76,20 @@ export const SearchMenu = () => { const elementsMap = app.scene.getNonDeletedElementsMap(); useEffect(() => { - const trimmedKeyword = keyword.trim(); + if (isSearching) { + return; + } if ( - trimmedKeyword !== searchedKeywordRef.current || + searchQuery !== searchedQueryRef.current || app.scene.getSceneNonce() !== lastSceneNonceRef.current ) { - searchedKeywordRef.current = null; - handleSearch(trimmedKeyword, app, (matchItems, index) => { + searchedQueryRef.current = null; + handleSearch(searchQuery, app, (matchItems, index) => { setSearchMatches({ nonce: randomInteger(), items: matchItems, }); - setFocusIndex(index); - searchedKeywordRef.current = trimmedKeyword; + searchedQueryRef.current = searchQuery; lastSceneNonceRef.current = app.scene.getSceneNonce(); setAppState({ searchMatches: matchItems.map((searchMatch) => ({ @@ -94,7 +101,8 @@ export const SearchMenu = () => { }); } }, [ - keyword, + isSearching, + searchQuery, elementsMap, app, setAppState, @@ -128,19 +136,35 @@ export const SearchMenu = () => { } }; + useEffect(() => { + setAppState((state) => { + return { + searchMatches: state.searchMatches.map((match, index) => { + if (index === focusIndex) { + return { ...match, focus: true }; + } + return { ...match, focus: false }; + }), + }; + }); + }, [focusIndex, setAppState]); + useEffect(() => { if (searchMatches.items.length > 0 && focusIndex !== null) { const match = searchMatches.items[focusIndex]; if (match) { const matchAsElement = newTextElement({ - text: match.keyword, + text: match.searchQuery, x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0), y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0), width: match.matchedLines[0]?.width, height: match.matchedLines[0]?.height, }); + const isTextTiny = + match.textElement.fontSize * app.state.zoom.value < 12; + if ( !isElementCompletelyInViewport( [matchAsElement], @@ -155,45 +179,36 @@ export const SearchMenu = () => { }, app.scene.getNonDeletedElementsMap(), app.getEditorUIOffsets(), - ) + ) || + isTextTiny ) { + let zoomOptions: Parameters[1]; + + if (isTextTiny && app.state.zoom.value >= 1) { + zoomOptions = { fitToViewport: true }; + } else if (isTextTiny || app.state.zoom.value > 1) { + zoomOptions = { fitToContent: true }; + } + app.scrollToContent(matchAsElement, { - fitToContent: true, animate: true, duration: 300, + ...zoomOptions, }); } - - const nextMatches = searchMatches.items.map((match, index) => { - if (index === focusIndex) { - return { - id: match.textElement.id, - focus: true, - matchedLines: match.matchedLines, - }; - } - return { - id: match.textElement.id, - focus: false, - matchedLines: match.matchedLines, - }; - }); - - setAppState({ - searchMatches: nextMatches, - }); } } - }, [app, focusIndex, searchMatches, setAppState]); + }, [focusIndex, searchMatches, app]); useEffect(() => { return () => { setFocusIndex(null); - searchedKeywordRef.current = null; + searchedQueryRef.current = null; lastSceneNonceRef.current = undefined; setAppState({ searchMatches: [], }); + setIsSearching(false); }; }, [setAppState, setFocusIndex]); @@ -276,12 +291,32 @@ export const SearchMenu = () => {
{ - setKeyword(value); + setInputValue(value); + setIsSearching(true); + const searchQuery = value.trim() as SearchQuery; + handleSearch(searchQuery, app, (matchItems, index) => { + setSearchMatches({ + nonce: randomInteger(), + items: matchItems, + }); + setFocusIndex(index); + searchedQueryRef.current = searchQuery; + lastSceneNonceRef.current = app.scene.getSceneNonce(); + setAppState({ + searchMatches: matchItems.map((searchMatch) => ({ + id: searchMatch.textElement.id, + focus: false, + matchedLines: searchMatch.matchedLines, + })), + }); + + setIsSearching(false); + }); }} selectOnRender /> @@ -319,8 +354,8 @@ export const SearchMenu = () => { )} {searchMatches.items.length === 0 && - keyword && - searchedKeywordRef.current && ( + searchQuery && + searchedQueryRef.current && (
{t("search.noMatch")}
)}
@@ -329,7 +364,7 @@ export const SearchMenu = () => { matches={searchMatches} onItemClick={setFocusIndex} focusIndex={focusIndex} - trimmedKeyword={keyword.trim()} + searchQuery={searchQuery} /> ); @@ -337,19 +372,19 @@ export const SearchMenu = () => { const ListItem = (props: { preview: SearchMatchItem["preview"]; - trimmedKeyword: string; + searchQuery: SearchQuery; highlighted: boolean; onClick?: () => void; }) => { const preview = [ props.preview.moreBefore ? "..." : "", - props.preview.previewText.slice(0, props.preview.indexInKeyword), + props.preview.previewText.slice(0, props.preview.indexInSearchQuery), props.preview.previewText.slice( - props.preview.indexInKeyword, - props.preview.indexInKeyword + props.trimmedKeyword.length, + props.preview.indexInSearchQuery, + props.preview.indexInSearchQuery + props.searchQuery.length, ), props.preview.previewText.slice( - props.preview.indexInKeyword + props.trimmedKeyword.length, + props.preview.indexInSearchQuery + props.searchQuery.length, ), props.preview.moreAfter ? "..." : "", ]; @@ -380,7 +415,7 @@ interface MatchListProps { matches: SearchMatches; onItemClick: (index: number) => void; focusIndex: number | null; - trimmedKeyword: string; + searchQuery: SearchQuery; } const MatchListBase = (props: MatchListProps) => { @@ -389,7 +424,7 @@ const MatchListBase = (props: MatchListProps) => { {props.matches.items.map((searchMatch, index) => ( props.onItemClick(index)} @@ -408,24 +443,27 @@ const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => { const MatchList = memo(MatchListBase, areEqual); -const getMatchPreview = (text: string, index: number, keyword: string) => { +const getMatchPreview = ( + text: string, + index: number, + searchQuery: SearchQuery, +) => { const WORDS_BEFORE = 2; const WORDS_AFTER = 5; - const substrBeforeKeyword = text.slice(0, index); - const wordsBeforeKeyword = substrBeforeKeyword.split(/\s+/); - // text = "small", keyword = "mall", not complete before - // text = "small", keyword = "smal", complete before - const isKeywordCompleteBefore = substrBeforeKeyword.endsWith(" "); + 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 = - wordsBeforeKeyword.length - + wordsBeforeQuery.length - WORDS_BEFORE - 1 - - (isKeywordCompleteBefore ? 0 : 1); + (isQueryCompleteBefore ? 0 : 1); let wordsBeforeAsString = - wordsBeforeKeyword - .slice(startWordIndex <= 0 ? 0 : startWordIndex) - .join(" ") + (isKeywordCompleteBefore ? " " : ""); + wordsBeforeQuery.slice(startWordIndex <= 0 ? 0 : startWordIndex).join(" ") + + (isQueryCompleteBefore ? " " : ""); const MAX_ALLOWED_CHARS = 20; @@ -434,21 +472,21 @@ const getMatchPreview = (text: string, index: number, keyword: string) => { ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS) : wordsBeforeAsString; - const substrAfterKeyword = text.slice(index + keyword.length); - const wordsAfter = substrAfterKeyword.split(/\s+/); - // text = "small", keyword = "mall", complete after - // text = "small", keyword = "smal", not complete after - const isKeywordCompleteAfter = !substrAfterKeyword.startsWith(" "); - const numberOfWordsToTake = isKeywordCompleteAfter + 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 = - (isKeywordCompleteAfter ? "" : " ") + + (isQueryCompleteAfter ? "" : " ") + wordsAfter.slice(0, numberOfWordsToTake).join(" "); return { - indexInKeyword: wordsBeforeAsString.length, - previewText: wordsBeforeAsString + keyword + wordsAfterAsString, + indexInSearchQuery: wordsBeforeAsString.length, + previewText: wordsBeforeAsString + searchQuery + wordsAfterAsString, moreBefore: startWordIndex > 0, moreAfter: wordsAfter.length > numberOfWordsToTake, }; @@ -491,7 +529,7 @@ const normalizeWrappedText = ( const getMatchedLines = ( textElement: ExcalidrawTextElement, - keyword: string, + searchQuery: SearchQuery, index: number, ) => { const normalizedText = normalizeWrappedText( @@ -522,9 +560,9 @@ const getMatchedLines = ( } let startIndex = index; - let remainingKeyword = textElement.originalText.slice( + let remainingQuery = textElement.originalText.slice( index, - index + keyword.length, + index + searchQuery.length, ); const matchedLines: { offsetX: number; @@ -534,7 +572,7 @@ const getMatchedLines = ( }[] = []; for (const lineIndexRange of lineIndexRanges) { - if (remainingKeyword === "") { + if (remainingQuery === "") { break; } @@ -548,8 +586,8 @@ const getMatchedLines = ( startIndex - lineIndexRange.startIndex, ); - const matchedWord = remainingKeyword.slice(0, matchCapacity); - remainingKeyword = remainingKeyword.slice(matchCapacity); + const matchedWord = remainingQuery.slice(0, matchCapacity); + remainingQuery = remainingQuery.slice(matchCapacity); const offset = measureText( textToStart, @@ -608,11 +646,11 @@ const escapeSpecialCharacters = (string: string) => { const handleSearch = debounce( ( - keyword: string, + searchQuery: SearchQuery, app: AppClassProperties, cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void, ) => { - if (!keyword || keyword === "") { + if (!searchQuery || searchQuery === "") { cb([], null); return; } @@ -626,20 +664,20 @@ const handleSearch = debounce( const matchItems: SearchMatchItem[] = []; - const regex = new RegExp(escapeSpecialCharacters(keyword), "gi"); + 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, keyword); - const matchedLines = getMatchedLines(textEl, keyword, match.index); + const preview = getMatchPreview(text, match.index, searchQuery); + const matchedLines = getMatchedLines(textEl, searchQuery, match.index); if (matchedLines.length > 0) { matchItems.push({ textElement: textEl, - keyword, + searchQuery, preview, index: match.index, matchedLines, From b46ca0192b4df0c12bd672205529ba6c6b16954b Mon Sep 17 00:00:00 2001 From: zsviczian Date: Wed, 11 Sep 2024 07:57:41 +0200 Subject: [PATCH 13/13] fix: addFiles clears the whole image cache when each file is added - regression from #8471 (#8490) Update App.tsx --- packages/excalidraw/components/App.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index e067bba7d..8276b88f4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2367,9 +2367,10 @@ class App extends React.Component { return false; }; - private clearImageShapeCache() { + private clearImageShapeCache(filesMap?: BinaryFiles) { + const files = filesMap ?? this.files; this.scene.getNonDeletedElements().forEach((element) => { - if (isInitializedImageElement(element) && this.files[element.fileId]) { + if (isInitializedImageElement(element) && files[element.fileId]) { this.imageCache.delete(element.fileId); ShapeCache.delete(element); } @@ -3690,7 +3691,7 @@ class App extends React.Component { this.files = { ...this.files, ...Object.fromEntries(filesMap) }; - this.clearImageShapeCache(); + this.clearImageShapeCache(Object.fromEntries(filesMap)); this.scene.triggerUpdate(); this.addNewImagesToImageCache();