From 88a2b286c7dbc2866621d27ec5b683253c42fe03 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 13 Dec 2023 21:51:27 +0530 Subject: [PATCH 001/112] feat: move utils to utils package and make @excalidraw/utils a workspace (#7432) * feat: move utils to utils package and make @excalidraw/utils a workspace * remove esm and update types path * remove esm script * fix package.json and yarn.lock * update path * fix * fix lint and test --- package.json | 3 +- packages/excalidraw/components/App.tsx | 3 +- .../components/ImageExportDialog.tsx | 2 +- .../excalidraw/components/PublishLibrary.tsx | 2 +- packages/excalidraw/data/index.ts | 2 +- packages/excalidraw/frame.ts | 2 +- .../excalidraw/hooks/useLibraryItemSvg.ts | 2 +- packages/excalidraw/index.tsx | 4 +- packages/excalidraw/package.json | 8 +- packages/excalidraw/scene/export.ts | 2 +- .../utils/__snapshots__/export.test.ts.snap | 100 +++++++++++++ packages/{ => utils}/bbox.ts | 4 +- .../utils/{utils.test.ts => export.test.ts} | 2 +- packages/{utils.ts => utils/export.ts} | 26 ++-- packages/utils/index.js | 6 +- packages/{ => utils}/withinBounds.test.ts | 4 +- packages/{ => utils}/withinBounds.ts | 10 +- yarn.lock | 135 ++++++++++++++++-- 18 files changed, 261 insertions(+), 56 deletions(-) create mode 100644 packages/utils/__snapshots__/export.test.ts.snap rename packages/{ => utils}/bbox.ts (94%) rename packages/utils/{utils.test.ts => export.test.ts} (99%) rename packages/{utils.ts => utils/export.ts} (88%) rename packages/{ => utils}/withinBounds.test.ts (98%) rename packages/{ => utils}/withinBounds.ts (95%) diff --git a/package.json b/package.json index 4b3ebb3e2..24c5a545d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "name": "excalidraw-monorepo", "workspaces": [ "excalidraw-app", - "packages/excalidraw" + "packages/excalidraw", + "packages/utils" ], "dependencies": { "@excalidraw/random-username": "1.0.0", diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 69a60249c..513b8c8e2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5,7 +5,6 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; import clsx from "clsx"; import { nanoid } from "nanoid"; - import { actionAddToLibrary, actionBringForward, @@ -392,7 +391,7 @@ import { import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; import { MagicCacheData, diagramToHTML } from "../data/magic"; -import { elementsOverlappingBBox, exportToBlob } from "../../utils"; +import { elementsOverlappingBBox, exportToBlob } from "../../utils/export"; import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index 804733eba..d0df35193 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -23,7 +23,7 @@ import { nativeFileSystemSupported } from "../data/filesystem"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; import { isSomeElementSelected } from "../scene"; -import { exportToCanvas } from "../../utils"; +import { exportToCanvas } from "../../utils/export"; import { copyIcon, downloadIcon, helpIcon } from "./icons"; import { Dialog } from "./Dialog"; diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index c14d42d50..51e14febc 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -6,7 +6,7 @@ import { t } from "../i18n"; import Trans from "./Trans"; import { LibraryItems, LibraryItem, UIAppState } from "../types"; -import { exportToCanvas, exportToSvg } from "../../utils"; +import { exportToCanvas, exportToSvg } from "../../utils/export"; import { EDITOR_LS_KEYS, EXPORT_DATA_TYPES, diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index cb7bed208..fdb834764 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -11,7 +11,7 @@ import { NonDeletedExcalidrawElement, } from "../element/types"; import { t } from "../i18n"; -import { elementsOverlappingBBox } from "../../withinBounds"; +import { elementsOverlappingBBox } from "../../utils/export"; import { isSomeElementSelected, getSelectedElements } from "../scene"; import { exportToCanvas, exportToSvg } from "../scene/export"; import { ExportType } from "../scene/types"; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 5b2186533..3818e6684 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -21,7 +21,7 @@ import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; -import { doLineSegmentsIntersect } from "../utils"; +import { doLineSegmentsIntersect } from "../utils/export"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; // --------------------------- Frame State ------------------------------------ diff --git a/packages/excalidraw/hooks/useLibraryItemSvg.ts b/packages/excalidraw/hooks/useLibraryItemSvg.ts index 972b7f284..ac40140b4 100644 --- a/packages/excalidraw/hooks/useLibraryItemSvg.ts +++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts @@ -2,7 +2,7 @@ import { atom, useAtom } from "jotai"; import { useEffect, useState } from "react"; import { COLOR_PALETTE } from "../colors"; import { jotaiScope } from "../jotai"; -import { exportToSvg } from "../../utils"; +import { exportToSvg } from "../../utils/export"; import { LibraryItem } from "../types"; export type SvgCache = Map; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index a4a82a1e0..e8c083415 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -216,7 +216,7 @@ export { getFreeDrawSvgPath, exportToClipboard, mergeLibraryItems, -} from "../utils"; +} from "../utils/export"; export { isLinearElement } from "./element/typeChecks"; export { FONT_FAMILY, THEME, MIME_TYPES } from "./constants"; @@ -254,4 +254,4 @@ export { elementsOverlappingBBox, isElementInsideBBox, elementPartiallyOverlapsWithOrContainsBBox, -} from "../withinBounds"; +} from "../utils/export"; diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 476630347..18af79be4 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -2,7 +2,7 @@ "name": "@excalidraw/excalidraw", "version": "0.17.1", "main": "main.js", - "types": "types/packages/excalidraw/index.d.ts", + "types": "types/excalidraw/index.d.ts", "files": [ "dist/*", "types/*" @@ -77,9 +77,6 @@ "tunnel-rat": "0.1.2" }, "devDependencies": { - "@types/pako": "1.0.3", - "@types/pica": "5.1.3", - "@types/resize-observer-browser": "0.1.7", "@babel/core": "7.18.9", "@babel/plugin-transform-arrow-functions": "7.18.6", "@babel/plugin-transform-async-to-generator": "7.18.6", @@ -89,6 +86,9 @@ "@babel/preset-react": "7.18.6", "@babel/preset-typescript": "7.18.6", "@size-limit/preset-big-lib": "9.0.0", + "@types/pako": "1.0.3", + "@types/pica": "5.1.3", + "@types/resize-observer-browser": "0.1.7", "autoprefixer": "10.4.7", "babel-loader": "8.2.5", "babel-plugin-transform-class-properties": "6.24.1", diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index b4702af12..bb194e1cb 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -26,7 +26,7 @@ import { getInitializedImageElements, updateImageCache, } from "../element/image"; -import { elementsOverlappingBBox } from "../../withinBounds"; +import { elementsOverlappingBBox } from "../../utils/export"; import { getFrameLikeElements, getFrameLikeTitle, diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap new file mode 100644 index 000000000..254d4163b --- /dev/null +++ b/packages/utils/__snapshots__/export.test.ts.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`exportToSvg > with default arguments 1`] = ` +{ + "activeEmbeddable": null, + "activeTool": { + "customType": null, + "lastActiveTool": null, + "locked": false, + "type": "selection", + }, + "collaborators": Map {}, + "contextMenu": null, + "currentChartType": "bar", + "currentItemBackgroundColor": "transparent", + "currentItemEndArrowhead": "arrow", + "currentItemFillStyle": "solid", + "currentItemFontFamily": 1, + "currentItemFontSize": 20, + "currentItemOpacity": 100, + "currentItemRoughness": 1, + "currentItemRoundness": "round", + "currentItemStartArrowhead": null, + "currentItemStrokeColor": "#1e1e1e", + "currentItemStrokeStyle": "solid", + "currentItemStrokeWidth": 2, + "currentItemTextAlign": "left", + "cursorButton": "up", + "defaultSidebarDockedPreference": false, + "draggingElement": null, + "editingElement": null, + "editingFrame": null, + "editingGroupId": null, + "editingLinearElement": null, + "elementsToHighlight": null, + "errorMessage": null, + "exportBackground": true, + "exportEmbedScene": false, + "exportPadding": undefined, + "exportScale": 1, + "exportWithDarkMode": false, + "fileHandle": null, + "frameRendering": { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, + "frameToHighlight": null, + "gridSize": null, + "isBindingEnabled": true, + "isLoading": false, + "isResizing": false, + "isRotating": false, + "lastPointerDownWith": "mouse", + "multiElement": null, + "name": "name", + "objectsSnapModeEnabled": false, + "openDialog": null, + "openMenu": null, + "openPopup": null, + "openSidebar": null, + "originSnapOffset": { + "x": 0, + "y": 0, + }, + "pasteDialog": { + "data": null, + "shown": false, + }, + "penDetected": false, + "penMode": false, + "pendingImageElementId": null, + "previousSelectedElementIds": {}, + "resizingElement": null, + "scrollX": 0, + "scrollY": 0, + "scrolledOutside": false, + "selectedElementIds": {}, + "selectedElementsAreBeingDragged": false, + "selectedGroupIds": {}, + "selectedLinearElement": null, + "selectionElement": null, + "shouldCacheIgnoreZoom": false, + "showHyperlinkPopup": false, + "showStats": false, + "showWelcomeScreen": false, + "snapLines": [], + "startBoundElement": null, + "suggestedBindings": [], + "theme": "light", + "toast": null, + "viewBackgroundColor": "#ffffff", + "viewModeEnabled": false, + "zenModeEnabled": false, + "zoom": { + "value": 1, + }, +} +`; diff --git a/packages/bbox.ts b/packages/utils/bbox.ts similarity index 94% rename from packages/bbox.ts rename to packages/utils/bbox.ts index 05ee3e7ea..5fc6192df 100644 --- a/packages/bbox.ts +++ b/packages/utils/bbox.ts @@ -1,5 +1,5 @@ -import { Bounds } from "./excalidraw/element/bounds"; -import { Point } from "./excalidraw/types"; +import { Bounds } from "../excalidraw/element/bounds"; +import { Point } from "../excalidraw/types"; export type LineSegment = [Point, Point]; diff --git a/packages/utils/utils.test.ts b/packages/utils/export.test.ts similarity index 99% rename from packages/utils/utils.test.ts rename to packages/utils/export.test.ts index a4715ea8b..aa1049cc1 100644 --- a/packages/utils/utils.test.ts +++ b/packages/utils/export.test.ts @@ -1,4 +1,4 @@ -import * as utils from "../utils"; +import * as utils from "."; import { diagramFactory } from "../excalidraw/tests/fixtures/diagramFixture"; import { vi } from "vitest"; import * as mockedSceneExportUtils from "../excalidraw/scene/export"; diff --git a/packages/utils.ts b/packages/utils/export.ts similarity index 88% rename from packages/utils.ts rename to packages/utils/export.ts index 3460bf561..ceb733881 100644 --- a/packages/utils.ts +++ b/packages/utils/export.ts @@ -1,23 +1,23 @@ import { exportToCanvas as _exportToCanvas, exportToSvg as _exportToSvg, -} from "./excalidraw/scene/export"; -import { getDefaultAppState } from "./excalidraw/appState"; -import { AppState, BinaryFiles } from "./excalidraw/types"; +} from "../excalidraw/scene/export"; +import { getDefaultAppState } from "../excalidraw/appState"; +import { AppState, BinaryFiles } from "../excalidraw/types"; import { ExcalidrawElement, ExcalidrawFrameLikeElement, NonDeleted, -} from "./excalidraw/element/types"; -import { restore } from "./excalidraw/data/restore"; -import { MIME_TYPES } from "./excalidraw/constants"; -import { encodePngMetadata } from "./excalidraw/data/image"; -import { serializeAsJSON } from "./excalidraw/data/json"; +} from "../excalidraw/element/types"; +import { restore } from "../excalidraw/data/restore"; +import { MIME_TYPES } from "../excalidraw/constants"; +import { encodePngMetadata } from "../excalidraw/data/image"; +import { serializeAsJSON } from "../excalidraw/data/json"; import { copyBlobToClipboardAsPng, copyTextToSystemClipboard, copyToClipboard, -} from "./excalidraw/clipboard"; +} from "../excalidraw/clipboard"; export { MIME_TYPES }; @@ -215,11 +215,11 @@ export { export { serializeAsJSON, serializeLibraryAsJSON, -} from "./excalidraw/data/json"; +} from "../excalidraw/data/json"; export { loadFromBlob, loadSceneOrLibraryFromBlob, loadLibraryFromBlob, -} from "./excalidraw/data/blob"; -export { getFreeDrawSvgPath } from "./excalidraw/renderer/renderElement"; -export { mergeLibraryItems } from "./excalidraw/data/library"; +} from "../excalidraw/data/blob"; +export { getFreeDrawSvgPath } from "../excalidraw/renderer/renderElement"; +export { mergeLibraryItems } from "../excalidraw/data/library"; diff --git a/packages/utils/index.js b/packages/utils/index.js index 9b59678c5..ffea9c3cf 100644 --- a/packages/utils/index.js +++ b/packages/utils/index.js @@ -1,5 +1 @@ -export { - exportToBlob, - exportToSvg, - exportToCanvas, -} from "../excalidraw/packages/utils.ts"; +export * from "./export"; diff --git a/packages/withinBounds.test.ts b/packages/utils/withinBounds.test.ts similarity index 98% rename from packages/withinBounds.test.ts rename to packages/utils/withinBounds.test.ts index f9d07e9a7..43bf5d6e8 100644 --- a/packages/withinBounds.test.ts +++ b/packages/utils/withinBounds.test.ts @@ -1,5 +1,5 @@ -import { Bounds } from "./excalidraw/element/bounds"; -import { API } from "./excalidraw/tests/helpers/api"; +import { Bounds } from "../excalidraw/element/bounds"; +import { API } from "../excalidraw/tests/helpers/api"; import { elementPartiallyOverlapsWithOrContainsBBox, elementsOverlappingBBox, diff --git a/packages/withinBounds.ts b/packages/utils/withinBounds.ts similarity index 95% rename from packages/withinBounds.ts rename to packages/utils/withinBounds.ts index 4f295342b..637cab3e1 100644 --- a/packages/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -3,17 +3,17 @@ import type { ExcalidrawFreeDrawElement, ExcalidrawLinearElement, NonDeletedExcalidrawElement, -} from "./excalidraw/element/types"; +} from "../excalidraw/element/types"; import { isArrowElement, isExcalidrawElement, isFreeDrawElement, isLinearElement, isTextElement, -} from "./excalidraw/element/typeChecks"; -import { isValueInRange, rotatePoint } from "./excalidraw/math"; -import type { Point } from "./excalidraw/types"; -import { Bounds, getElementBounds } from "./excalidraw/element/bounds"; +} from "../excalidraw/element/typeChecks"; +import { isValueInRange, rotatePoint } from "../excalidraw/math"; +import type { Point } from "../excalidraw/types"; +import { Bounds, getElementBounds } from "../excalidraw/element/bounds"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; diff --git a/yarn.lock b/yarn.lock index f97a7d856..3432c0b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,7 +61,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.4.tgz#457ffe647c480dff59c2be092fc3acf71195c87f" integrity sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g== -"@babel/compat-data@^7.18.6", "@babel/compat-data@^7.22.9": +"@babel/compat-data@^7.18.6", "@babel/compat-data@^7.18.8", "@babel/compat-data@^7.22.9": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98" integrity sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw== @@ -689,7 +689,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.6": +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.6", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz#f6652bb16b94f8f9c20c50941e16e9756898dc5d" integrity sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ== @@ -769,7 +769,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-json-strings" "^7.8.3" -"@babel/plugin-proposal-logical-assignment-operators@^7.18.6", "@babel/plugin-proposal-logical-assignment-operators@^7.20.7": +"@babel/plugin-proposal-logical-assignment-operators@^7.18.6", "@babel/plugin-proposal-logical-assignment-operators@^7.18.9", "@babel/plugin-proposal-logical-assignment-operators@^7.20.7": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz#dfbcaa8f7b4d37b51e8bfb46d94a5aea2bb89d83" integrity sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug== @@ -793,7 +793,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.18.6", "@babel/plugin-proposal-object-rest-spread@^7.20.7": +"@babel/plugin-proposal-object-rest-spread@^7.18.6", "@babel/plugin-proposal-object-rest-spread@^7.18.9", "@babel/plugin-proposal-object-rest-spread@^7.20.7": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz#aa662940ef425779c75534a5c41e9d936edc390a" integrity sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg== @@ -812,7 +812,7 @@ "@babel/helper-plugin-utils" "^7.18.6" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" -"@babel/plugin-proposal-optional-chaining@^7.16.0", "@babel/plugin-proposal-optional-chaining@^7.18.6", "@babel/plugin-proposal-optional-chaining@^7.20.7", "@babel/plugin-proposal-optional-chaining@^7.21.0": +"@babel/plugin-proposal-optional-chaining@^7.16.0", "@babel/plugin-proposal-optional-chaining@^7.18.6", "@babel/plugin-proposal-optional-chaining@^7.18.9", "@babel/plugin-proposal-optional-chaining@^7.20.7", "@babel/plugin-proposal-optional-chaining@^7.21.0": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz#886f5c8978deb7d30f678b2e24346b287234d3ea" integrity sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA== @@ -1059,7 +1059,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-block-scoping@^7.18.6": +"@babel/plugin-transform-block-scoping@^7.18.6", "@babel/plugin-transform-block-scoping@^7.18.9": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz#b2d38589531c6c80fbe25e6b58e763622d2d3cf5" integrity sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw== @@ -1073,7 +1073,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.20.2" -"@babel/plugin-transform-classes@^7.18.6": +"@babel/plugin-transform-classes@^7.18.6", "@babel/plugin-transform-classes@^7.18.9": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz#e7a75f815e0c534cc4c9a39c56636c84fc0d64f2" integrity sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg== @@ -1103,7 +1103,7 @@ "@babel/helper-split-export-declaration" "^7.18.6" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.18.6": +"@babel/plugin-transform-computed-properties@^7.18.6", "@babel/plugin-transform-computed-properties@^7.18.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz#652e69561fcc9d2b50ba4f7ac7f60dcf65e86474" integrity sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw== @@ -1119,7 +1119,7 @@ "@babel/helper-plugin-utils" "^7.20.2" "@babel/template" "^7.20.7" -"@babel/plugin-transform-destructuring@^7.18.6": +"@babel/plugin-transform-destructuring@^7.18.6", "@babel/plugin-transform-destructuring@^7.18.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz#8c9ee68228b12ae3dff986e56ed1ba4f3c446311" integrity sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw== @@ -1178,6 +1178,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" +"@babel/plugin-transform-for-of@^7.18.8": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz#81c37e24171b37b370ba6aaffa7ac86bcb46f94e" + integrity sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw== + dependencies: + "@babel/helper-plugin-utils" "^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" + "@babel/plugin-transform-for-of@^7.21.0": version "7.21.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.0.tgz#964108c9988de1a60b4be2354a7d7e245f36e86e" @@ -1258,7 +1266,7 @@ "@babel/helper-plugin-utils" "^7.20.2" "@babel/helper-simple-access" "^7.20.2" -"@babel/plugin-transform-modules-systemjs@^7.18.6": +"@babel/plugin-transform-modules-systemjs@^7.18.6", "@babel/plugin-transform-modules-systemjs@^7.18.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz#fa7e62248931cb15b9404f8052581c302dd9de81" integrity sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ== @@ -1326,7 +1334,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5" "@babel/plugin-syntax-optional-chaining" "^7.8.3" -"@babel/plugin-transform-parameters@^7.18.6": +"@babel/plugin-transform-parameters@^7.18.6", "@babel/plugin-transform-parameters@^7.18.8": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz#83ef5d1baf4b1072fa6e54b2b0999a7b2527e2af" integrity sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw== @@ -1417,6 +1425,18 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" +"@babel/plugin-transform-runtime@7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.6.tgz#77b14416015ea93367ca06979710f5000ff34ccb" + integrity sha512-8uRHk9ZmRSnWqUgyae249EJZ94b0yAGLBIqzZzl+0iEdbno55Pmlt/32JZsHwXD9k/uZj18Aqqk35wBX4CBTXA== + dependencies: + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" + babel-plugin-polyfill-corejs2 "^0.3.1" + babel-plugin-polyfill-corejs3 "^0.5.2" + babel-plugin-polyfill-regenerator "^0.3.1" + semver "^6.3.0" + "@babel/plugin-transform-runtime@7.18.9": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.18.9.tgz#d9e4b1b25719307bfafbf43065ed7fb3a83adb8f" @@ -1448,7 +1468,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-transform-spread@^7.18.6": +"@babel/plugin-transform-spread@^7.18.6", "@babel/plugin-transform-spread@^7.18.9": version "7.23.3" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz#41d17aacb12bde55168403c6f2d6bdca563d362c" integrity sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg== @@ -1631,6 +1651,87 @@ core-js-compat "^3.22.1" semver "^6.3.0" +"@babel/preset-env@7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.18.9.tgz#9b3425140d724fbe590322017466580844c7eaff" + integrity sha512-75pt/q95cMIHWssYtyfjVlvI+QEZQThQbKvR9xH+F/Agtw/s4Wfc2V9Bwd/P39VtixB7oWxGdH4GteTTwYJWMg== + dependencies: + "@babel/compat-data" "^7.18.8" + "@babel/helper-compilation-targets" "^7.18.9" + "@babel/helper-plugin-utils" "^7.18.9" + "@babel/helper-validator-option" "^7.18.6" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" + "@babel/plugin-proposal-async-generator-functions" "^7.18.6" + "@babel/plugin-proposal-class-properties" "^7.18.6" + "@babel/plugin-proposal-class-static-block" "^7.18.6" + "@babel/plugin-proposal-dynamic-import" "^7.18.6" + "@babel/plugin-proposal-export-namespace-from" "^7.18.9" + "@babel/plugin-proposal-json-strings" "^7.18.6" + "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" + "@babel/plugin-proposal-numeric-separator" "^7.18.6" + "@babel/plugin-proposal-object-rest-spread" "^7.18.9" + "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" + "@babel/plugin-proposal-optional-chaining" "^7.18.9" + "@babel/plugin-proposal-private-methods" "^7.18.6" + "@babel/plugin-proposal-private-property-in-object" "^7.18.6" + "@babel/plugin-proposal-unicode-property-regex" "^7.18.6" + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/plugin-syntax-import-assertions" "^7.18.6" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + "@babel/plugin-transform-arrow-functions" "^7.18.6" + "@babel/plugin-transform-async-to-generator" "^7.18.6" + "@babel/plugin-transform-block-scoped-functions" "^7.18.6" + "@babel/plugin-transform-block-scoping" "^7.18.9" + "@babel/plugin-transform-classes" "^7.18.9" + "@babel/plugin-transform-computed-properties" "^7.18.9" + "@babel/plugin-transform-destructuring" "^7.18.9" + "@babel/plugin-transform-dotall-regex" "^7.18.6" + "@babel/plugin-transform-duplicate-keys" "^7.18.9" + "@babel/plugin-transform-exponentiation-operator" "^7.18.6" + "@babel/plugin-transform-for-of" "^7.18.8" + "@babel/plugin-transform-function-name" "^7.18.9" + "@babel/plugin-transform-literals" "^7.18.9" + "@babel/plugin-transform-member-expression-literals" "^7.18.6" + "@babel/plugin-transform-modules-amd" "^7.18.6" + "@babel/plugin-transform-modules-commonjs" "^7.18.6" + "@babel/plugin-transform-modules-systemjs" "^7.18.9" + "@babel/plugin-transform-modules-umd" "^7.18.6" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.18.6" + "@babel/plugin-transform-new-target" "^7.18.6" + "@babel/plugin-transform-object-super" "^7.18.6" + "@babel/plugin-transform-parameters" "^7.18.8" + "@babel/plugin-transform-property-literals" "^7.18.6" + "@babel/plugin-transform-regenerator" "^7.18.6" + "@babel/plugin-transform-reserved-words" "^7.18.6" + "@babel/plugin-transform-shorthand-properties" "^7.18.6" + "@babel/plugin-transform-spread" "^7.18.9" + "@babel/plugin-transform-sticky-regex" "^7.18.6" + "@babel/plugin-transform-template-literals" "^7.18.9" + "@babel/plugin-transform-typeof-symbol" "^7.18.9" + "@babel/plugin-transform-unicode-escapes" "^7.18.6" + "@babel/plugin-transform-unicode-regex" "^7.18.6" + "@babel/preset-modules" "^0.1.5" + "@babel/types" "^7.18.9" + babel-plugin-polyfill-corejs2 "^0.3.1" + babel-plugin-polyfill-corejs3 "^0.5.2" + babel-plugin-polyfill-regenerator "^0.3.1" + core-js-compat "^3.22.1" + semver "^6.3.0" + "@babel/preset-env@^7.11.0", "@babel/preset-env@^7.16.4": version "7.21.4" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.21.4.tgz#a952482e634a8dd8271a3fe5459a16eb10739c58" @@ -6484,6 +6585,14 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-loader@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" + integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + filelist@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -9641,7 +9750,7 @@ schema-utils@^2.6.5: ajv "^6.12.4" ajv-keywords "^3.5.2" -schema-utils@^3.1.0, schema-utils@^3.1.1, schema-utils@^3.2.0: +schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== From aad8ab012330dddf09fe8349dc744143fa5e40da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Moln=C3=A1r?= <38168628+barnabasmolnar@users.noreply.github.com> Date: Fri, 15 Dec 2023 00:07:11 +0100 Subject: [PATCH 002/112] feat: follow mode (#6848) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/app_constants.ts | 7 +- excalidraw-app/collab/Collab.tsx | 150 ++++++++-- excalidraw-app/collab/Portal.tsx | 49 +++- excalidraw-app/data/index.ts | 8 + packages/excalidraw/actions/actionCanvas.tsx | 57 +++- .../excalidraw/actions/actionNavigate.tsx | 56 +++- packages/excalidraw/appState.ts | 4 + packages/excalidraw/components/App.tsx | 61 ++++- packages/excalidraw/components/Avatar.scss | 30 +- packages/excalidraw/components/Avatar.tsx | 16 +- .../components/FollowMode/FollowMode.scss | 59 ++++ .../components/FollowMode/FollowMode.tsx | 43 +++ .../components/Sidebar/SidebarTrigger.scss | 4 + .../components/Sidebar/SidebarTrigger.tsx | 2 +- packages/excalidraw/components/UserList.scss | 100 ++++++- packages/excalidraw/components/UserList.tsx | 259 +++++++++++++++--- packages/excalidraw/components/icons.tsx | 9 + packages/excalidraw/css/theme.scss | 2 + packages/excalidraw/css/variables.module.scss | 38 +++ packages/excalidraw/index.tsx | 1 + packages/excalidraw/locales/en.json | 9 + .../__snapshots__/contextmenu.test.tsx.snap | 34 +++ .../regressionTests.test.tsx.snap | 104 +++++++ .../packages/__snapshots__/utils.test.ts.snap | 3 + packages/excalidraw/types.ts | 31 ++- packages/excalidraw/utils.ts | 37 ++- .../utils/__snapshots__/export.test.ts.snap | 2 + .../utils/__snapshots__/utils.test.ts.snap | 2 + 28 files changed, 1039 insertions(+), 138 deletions(-) create mode 100644 packages/excalidraw/components/FollowMode/FollowMode.scss create mode 100644 packages/excalidraw/components/FollowMode/FollowMode.tsx diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 179fe52e7..a20a23506 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -15,11 +15,14 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const WS_EVENTS = { SERVER_VOLATILE: "server-volatile-broadcast", SERVER: "server-broadcast", -}; + USER_FOLLOW_CHANGE: "user-follow", + USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change", +} as const; -export enum WS_SCENE_EVENT_TYPES { +export enum WS_SUBTYPES { INIT = "SCENE_INIT", UPDATE = "SCENE_UPDATE", + USER_VIEWPORT_BOUNDS = "USER_VIEWPORT_BOUNDS", } export const FIREBASE_STORAGE_PREFIXES = { diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6ecdd1575..99fc4361e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -1,6 +1,9 @@ import throttle from "lodash.throttle"; import { PureComponent } from "react"; -import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types"; +import { + ExcalidrawImperativeAPI, + SocketId, +} from "../../packages/excalidraw/types"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import { ImportedDataState } from "../../packages/excalidraw/data/types"; @@ -11,11 +14,14 @@ import { import { getSceneVersion, restoreElements, + zoomToFitBounds, } from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { preventUnload, resolvablePromise, + throttleRAF, + viewportCoordsToSceneCoords, withBatchedUpdates, } from "../../packages/excalidraw/utils"; import { @@ -24,8 +30,9 @@ import { FIREBASE_STORAGE_PREFIXES, INITIAL_SCENE_UPDATE_TIMEOUT, LOAD_IMAGES_TIMEOUT, - WS_SCENE_EVENT_TYPES, + WS_SUBTYPES, SYNC_FULL_SCENE_INTERVAL_MS, + WS_EVENTS, } from "../app_constants"; import { generateCollaborationLinkData, @@ -74,6 +81,7 @@ import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom, useAtom } from "jotai"; import { appJotaiStore } from "../app-jotai"; +import { Mutable } from "../../packages/excalidraw/utility-types"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); @@ -154,12 +162,28 @@ class Collab extends PureComponent { this.idleTimeoutId = null; } + private onUmmount: (() => void) | null = null; + componentDidMount() { window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener("online", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener(EVENT.UNLOAD, this.onUnload); + const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => { + this.portal.socket && this.portal.broadcastUserFollowed(payload); + }); + const throttledRelayUserViewportBounds = throttleRAF( + this.relayUserViewportBounds, + ); + const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => + throttledRelayUserViewportBounds(), + ); + this.onUmmount = () => { + unsubOnUserFollow(); + unsubOnScrollChange(); + }; + this.onOfflineStatusToggle(); const collabAPI: CollabAPI = { @@ -207,6 +231,7 @@ class Collab extends PureComponent { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } + this.onUmmount?.(); } isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; @@ -489,7 +514,7 @@ class Collab extends PureComponent { switch (decryptedData.type) { case "INVALID_RESPONSE": return; - case WS_SCENE_EVENT_TYPES.INIT: { + case WS_SUBTYPES.INIT: { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); const remoteElements = decryptedData.payload.elements; @@ -505,7 +530,7 @@ class Collab extends PureComponent { } break; } - case WS_SCENE_EVENT_TYPES.UPDATE: + case WS_SUBTYPES.UPDATE: this.handleRemoteSceneUpdate( this.reconcileElements(decryptedData.payload.elements), ); @@ -513,31 +538,61 @@ class Collab extends PureComponent { case "MOUSE_LOCATION": { const { pointer, button, username, selectedElementIds } = decryptedData.payload; + const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = decryptedData.payload.socketId || // @ts-ignore legacy, see #2094 (#2097) decryptedData.payload.socketID; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.pointer = pointer; - user.button = button; - user.selectedElementIds = selectedElementIds; - user.username = username; - collaborators.set(socketId, user); - this.excalidrawAPI.updateScene({ - collaborators, + this.updateCollaborator(socketId, { + pointer, + button, + selectedElementIds, + username, }); + break; } + + case WS_SUBTYPES.USER_VIEWPORT_BOUNDS: { + const { bounds, socketId } = decryptedData.payload; + + const appState = this.excalidrawAPI.getAppState(); + + // we're not following the user + // (shouldn't happen, but could be late message or bug upstream) + if (appState.userToFollow?.socketId !== socketId) { + console.warn( + `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`, + ); + return; + } + + // cross-follow case, ignore updates in this case + if ( + appState.userToFollow && + appState.followedBy.has(appState.userToFollow.socketId) + ) { + return; + } + + this.excalidrawAPI.updateScene({ + appState: zoomToFitBounds({ + appState, + bounds, + fitToViewport: true, + viewportZoomFactor: 1, + }).appState, + }); + + break; + } + case "IDLE_STATUS": { const { userState, socketId, username } = decryptedData.payload; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.userState = userState; - user.username = username; - this.excalidrawAPI.updateScene({ - collaborators, + this.updateCollaborator(socketId, { + userState, + username, }); break; } @@ -556,6 +611,17 @@ class Collab extends PureComponent { scenePromise.resolve(sceneData); }); + this.portal.socket.on( + WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, + (followedBy: string[]) => { + this.excalidrawAPI.updateScene({ + appState: { followedBy: new Set(followedBy) }, + }); + + this.relayUserViewportBounds({ shouldPerform: true }); + }, + ); + this.initializeIdleDetector(); this.setState({ @@ -738,6 +804,24 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ collaborators }); } + private updateCollaborator = ( + socketId: SocketId, + updates: Partial, + ) => { + const collaborators = new Map(this.collaborators); + const user: Mutable = Object.assign( + {}, + collaborators.get(socketId), + updates, + ); + collaborators.set(socketId, user); + this.collaborators = collaborators; + + this.excalidrawAPI.updateScene({ + collaborators, + }); + }; + public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { this.lastBroadcastedOrReceivedSceneVersion = version; }; @@ -763,6 +847,30 @@ class Collab extends PureComponent { CURSOR_SYNC_TIMEOUT, ); + relayUserViewportBounds = (props?: { shouldPerform: boolean }) => { + const appState = this.excalidrawAPI.getAppState(); + + if ( + this.portal.socket && + (appState.followedBy.size > 0 || props?.shouldPerform) + ) { + const { x: x1, y: y1 } = viewportCoordsToSceneCoords( + { clientX: 0, clientY: 0 }, + appState, + ); + + const { x: x2, y: y2 } = viewportCoordsToSceneCoords( + { clientX: appState.width, clientY: appState.height }, + appState, + ); + + this.portal.broadcastUserViewportBounds( + { bounds: [x1, y1, x2, y2] }, + `follow_${this.portal.socket.id}`, + ); + } + }; + onIdleStateChange = (userState: UserIdleState) => { this.portal.broadcastIdleChange(userState); }; @@ -772,7 +880,7 @@ class Collab extends PureComponent { getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { - this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false); + this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } @@ -785,7 +893,7 @@ class Collab extends PureComponent { queueBroadcastAllElements = throttle(() => { this.portal.broadcastScene( - WS_SCENE_EVENT_TYPES.UPDATE, + WS_SUBTYPES.UPDATE, this.excalidrawAPI.getSceneElementsIncludingDeleted(), true, ); diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 4e5054329..7486486ce 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -7,12 +7,11 @@ import { import { TCollabClass } from "./Collab"; import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; +import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; import { - WS_EVENTS, - FILE_UPLOAD_TIMEOUT, - WS_SCENE_EVENT_TYPES, -} from "../app_constants"; -import { UserIdleState } from "../../packages/excalidraw/types"; + OnUserFollowedPayload, + UserIdleState, +} from "../../packages/excalidraw/types"; import { trackEvent } from "../../packages/excalidraw/analytics"; import throttle from "lodash.throttle"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; @@ -46,7 +45,7 @@ class Portal { }); this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene( - WS_SCENE_EVENT_TYPES.INIT, + WS_SUBTYPES.INIT, this.collab.getSceneElementsIncludingDeleted(), /* syncAll */ true, ); @@ -83,6 +82,7 @@ class Portal { async _broadcastSocketData( data: SocketUpdateData, volatile: boolean = false, + roomId?: string, ) { if (this.isOpen()) { const json = JSON.stringify(data); @@ -91,7 +91,7 @@ class Portal { this.socket?.emit( volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, - this.roomId, + roomId ?? this.roomId, encryptedBuffer, iv, ); @@ -130,11 +130,11 @@ class Portal { }, FILE_UPLOAD_TIMEOUT); broadcastScene = async ( - updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE, + updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, allElements: readonly ExcalidrawElement[], syncAll: boolean, ) => { - if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) { + if (updateType === WS_SUBTYPES.INIT && !syncAll) { throw new Error("syncAll must be true when sending SCENE.INIT"); } @@ -213,12 +213,43 @@ class Portal { username: this.collab.state.username, }, }; + return this._broadcastSocketData( data as SocketUpdateData, true, // volatile ); } }; + + broadcastUserViewportBounds = ( + payload: { + bounds: [number, number, number, number]; + }, + roomId: string, + ) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["USER_VIEWPORT_BOUNDS"] = { + type: WS_SUBTYPES.USER_VIEWPORT_BOUNDS, + payload: { + socketId: this.socket.id, + username: this.collab.state.username, + bounds: payload.bounds, + }, + }; + + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + roomId, + ); + } + }; + + broadcastUserFollowed = (payload: OnUserFollowedPayload) => { + if (this.socket?.id) { + this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload); + } + }; } export default Portal; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 6bab98332..b162da9a4 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -119,6 +119,14 @@ export type SocketUpdateDataSource = { username: string; }; }; + USER_VIEWPORT_BOUNDS: { + type: "USER_VIEWPORT_BOUNDS"; + payload: { + socketId: string; + username: string; + bounds: [number, number, number, number]; + }; + }; IDLE_STATUS: { type: "IDLE_STATUS"; payload: { diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f61f57dbd..7d57c64a7 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -109,6 +109,7 @@ export const actionZoomIn = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -146,6 +147,7 @@ export const actionZoomOut = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -183,6 +185,7 @@ export const actionResetZoom = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -226,22 +229,20 @@ const zoomValueToFitBoundsOnViewport = ( return clampedZoomValueToFitElements as NormalizedZoomValue; }; -export const zoomToFit = ({ - targetElements, +export const zoomToFitBounds = ({ + bounds, appState, fitToViewport = false, viewportZoomFactor = 0.7, }: { - targetElements: readonly ExcalidrawElement[]; + bounds: readonly [number, number, number, number]; appState: Readonly; /** whether to fit content to viewport (beyond >100%) */ fitToViewport: boolean; /** zoom content to cover X of the viewport, when fitToViewport=true */ viewportZoomFactor?: number; }) => { - const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); - - const [x1, y1, x2, y2] = commonBounds; + const [x1, y1, x2, y2] = bounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; @@ -282,7 +283,7 @@ export const zoomToFit = ({ scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; } else { - newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, { + newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { width: appState.width, height: appState.height, }); @@ -311,6 +312,29 @@ export const zoomToFit = ({ }; }; +export const zoomToFit = ({ + targetElements, + appState, + fitToViewport, + viewportZoomFactor, +}: { + targetElements: readonly ExcalidrawElement[]; + appState: Readonly; + /** whether to fit content to viewport (beyond >100%) */ + fitToViewport: boolean; + /** zoom content to cover X of the viewport, when fitToViewport=true */ + viewportZoomFactor?: number; +}) => { + const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); + + return zoomToFitBounds({ + bounds: commonBounds, + appState, + fitToViewport, + viewportZoomFactor, + }); +}; + // Note, this action differs from actionZoomToFitSelection in that it doesn't // zoom beyond 100%. In other words, if the content is smaller than viewport // size, it won't be zoomed in. @@ -321,7 +345,10 @@ export const actionZoomToFitSelectionInViewport = register({ const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: false, }); }, @@ -341,7 +368,10 @@ export const actionZoomToFitSelection = register({ const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: true, }); }, @@ -358,7 +388,14 @@ export const actionZoomToFit = register({ viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => - zoomToFit({ targetElements: elements, appState, fitToViewport: false }), + zoomToFit({ + targetElements: elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: false, + }), keyTest: (event) => event.code === CODES.ONE && event.shiftKey && diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 126e547ae..11dc22128 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,6 +1,5 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; -import { centerScrollOn } from "../scene/scroll"; import { Collaborator } from "../types"; import { register } from "./register"; @@ -9,39 +8,68 @@ export const actionGoToCollaborator = register({ viewMode: true, trackEvent: { category: "collab" }, perform: (_elements, appState, value) => { - const point = value as Collaborator["pointer"]; + const _value = value as Collaborator; + const point = _value.pointer; + if (!point) { return { appState, commitToHistory: false }; } + if (appState.userToFollow?.socketId === _value.socketId) { + return { + appState: { + ...appState, + userToFollow: null, + }, + commitToHistory: false, + }; + } + return { appState: { ...appState, - ...centerScrollOn({ - scenePoint: point, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: appState.zoom, - }), + userToFollow: { + socketId: _value.socketId!, + username: _value.username || "", + }, // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, commitToHistory: false, }; }, - PanelComponent: ({ updateData, data }) => { - const [clientId, collaborator] = data as [string, Collaborator]; + PanelComponent: ({ updateData, data, appState }) => { + const [clientId, collaborator, withName] = data as [ + string, + Collaborator, + boolean, + ]; const background = getClientColor(clientId); - return ( + return withName ? ( +
updateData({ ...collaborator, clientId })} + > + {}} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + isBeingFollowed={appState.userToFollow?.socketId === clientId} + /> + {collaborator.username} +
+ ) : ( updateData(collaborator.pointer)} + onClick={() => { + updateData({ ...collaborator, clientId }); + }} name={collaborator.username || ""} src={collaborator.avatarUrl} + isBeingFollowed={appState.userToFollow?.socketId === clientId} /> ); }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 0089f57e9..4dec9a790 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -105,6 +105,8 @@ export const getDefaultAppState = (): Omit< y: 0, }, objectsSnapModeEnabled: false, + userToFollow: null, + followedBy: new Set(), }; }; @@ -215,6 +217,8 @@ const APP_STATE_STORAGE_CONF = (< snapLines: { browser: false, export: false, server: false }, originSnapOffset: { browser: false, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false }, + userToFollow: { browser: false, export: false, server: false }, + followedBy: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 513b8c8e2..ff80ed5b0 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -244,6 +244,7 @@ import { KeyboardModifiersObject, CollaboratorPointer, ToolType, + OnUserFollowedPayload, } from "../types"; import { debounce, @@ -396,6 +397,7 @@ import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; +import FollowMode from "./FollowMode/FollowMode"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -551,6 +553,10 @@ class App extends React.Component { event: PointerEvent, ] >(); + onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>(); + onScrollChangeEmitter = new Emitter< + [scrollX: number, scrollY: number, zoom: AppState["zoom"]] + >(); constructor(props: AppProps) { super(props); @@ -620,6 +626,8 @@ class App extends React.Component { onChange: (cb) => this.onChangeEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), + onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), + onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), } as const; if (typeof excalidrawAPI === "function") { excalidrawAPI(api); @@ -1582,6 +1590,14 @@ class App extends React.Component { onPointerDown={this.handleCanvasPointerDown} onDoubleClick={this.handleCanvasDoubleClick} /> + {this.state.userToFollow && ( + + )} {this.renderFrameNames()} {this.renderEmbeddables()} @@ -2531,11 +2547,45 @@ class App extends React.Component { this.refreshEditorBreakpoints(); } + const hasFollowedPersonLeft = + prevState.userToFollow && + !this.state.collaborators.has(prevState.userToFollow.socketId); + + if (hasFollowedPersonLeft) { + this.maybeUnfollowRemoteUser(); + } + if ( + prevState.zoom.value !== this.state.zoom.value || prevState.scrollX !== this.state.scrollX || prevState.scrollY !== this.state.scrollY ) { - this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY); + this.props?.onScrollChange?.( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + this.onScrollChangeEmitter.trigger( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + } + + if (prevState.userToFollow !== this.state.userToFollow) { + if (prevState.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: prevState.userToFollow, + action: "UNFOLLOW", + }); + } + + if (this.state.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: this.state.userToFollow, + action: "FOLLOW", + }); + } } if ( @@ -3421,11 +3471,18 @@ class App extends React.Component { } }; + private maybeUnfollowRemoteUser = () => { + if (this.state.userToFollow) { + this.setState({ userToFollow: null }); + } + }; + /** use when changing scrollX/scrollY/zoom based on user interaction */ private translateCanvas: React.Component["setState"] = ( state, ) => { this.cancelInProgresAnimation?.(); + this.maybeUnfollowRemoteUser(); this.setState(state); }; @@ -5154,6 +5211,8 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + this.maybeUnfollowRemoteUser(); + // 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 diff --git a/packages/excalidraw/components/Avatar.scss b/packages/excalidraw/components/Avatar.scss index c0c66f0a2..29eece220 100644 --- a/packages/excalidraw/components/Avatar.scss +++ b/packages/excalidraw/components/Avatar.scss @@ -2,34 +2,6 @@ .excalidraw { .Avatar { - width: 1.25rem; - height: 1.25rem; - position: relative; - border-radius: 100%; - outline-offset: 2px; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - font-size: 0.75rem; - font-weight: 800; - line-height: 1; - - &-img { - width: 100%; - height: 100%; - border-radius: 100%; - } - - &::before { - content: ""; - position: absolute; - top: -3px; - right: -3px; - bottom: -3px; - left: -3px; - border: 1px solid var(--avatar-border-color); - border-radius: 100%; - } + @include avatarStyles; } } diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index 8b4624b7f..82ec88c37 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -2,21 +2,33 @@ import "./Avatar.scss"; import React, { useState } from "react"; import { getNameInitial } from "../clients"; +import clsx from "clsx"; type AvatarProps = { onClick: (e: React.MouseEvent) => void; color: string; name: string; src?: string; + isBeingFollowed?: boolean; }; -export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { +export const Avatar = ({ + color, + onClick, + name, + src, + isBeingFollowed, +}: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( void; +} + +const FollowMode = ({ + height, + width, + userToFollow, + onDisconnect, +}: FollowModeProps) => { + return ( +
+
+
+
+ Following{" "} + + {userToFollow.username} + +
+ +
+
+
+ ); +}; + +export default FollowMode; diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 834df6563..fd8bf814a 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss @@ -21,6 +21,10 @@ width: var(--lg-icon-size); height: var(--lg-icon-size); } + + &__label-element { + align-self: flex-start; + } } .default-sidebar-trigger .sidebar-trigger__label { diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 711432818..889156eba 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -19,7 +19,7 @@ export const SidebarTrigger = ({ const appState = useUIAppState(); return ( -
@@ -63,6 +67,7 @@ export const actionGoToCollaborator = register({ name={collaborator.username || ""} src={collaborator.avatarUrl} isBeingFollowed={appState.userToFollow?.socketId === clientId} + isCurrentUser={collaborator.isCurrentUser === true} /> ); }, diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index 82ec88c37..b7b1bf962 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -10,6 +10,7 @@ type AvatarProps = { name: string; src?: string; isBeingFollowed?: boolean; + isCurrentUser: boolean; }; export const Avatar = ({ @@ -18,6 +19,7 @@ export const Avatar = ({ name, src, isBeingFollowed, + isCurrentUser, }: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); @@ -25,7 +27,10 @@ export const Avatar = ({ const style = loadImg ? undefined : { background: color }; return (
diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 71ed4f71c..c7866e7a8 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -338,7 +338,9 @@ const LayerUI = ({ }, )} > - + {appState.collaborators.size > 0 && ( + + )} {renderTopRightUI?.(device.editor.isMobile, appState)} {!appState.viewModeEnabled && // hide button when sidebar docked diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index 6e227652a..1fd3ed996 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -100,7 +100,7 @@ export const UserList = React.memo( // const uniqueCollaboratorsMap = sampleCollaborators; const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter( - ([_, collaborator]) => Object.keys(collaborator).length !== 1, + ([_, collaborator]) => collaborator.username?.trim(), ); const [searchTerm, setSearchTerm] = React.useState(""); diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 3499ac4af..e434cff0c 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -150,6 +150,9 @@ &--is-followed::before { border-color: var(--color-primary-hover); } + &--is-current-user { + cursor: auto; + } } @mixin filledButtonOnCanvas { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 7fe7f1363..a4a0b0113 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -59,6 +59,7 @@ export type Collaborator = Readonly<{ // user id. If supplied, we'll filter out duplicates when rendering user avatars. id?: string; socketId?: SocketId; + isCurrentUser?: boolean; }>; export type CollaboratorPointer = { diff --git a/yarn.lock b/yarn.lock index 3432c0b71..2279e1bf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3095,6 +3095,11 @@ nanoid "^3.3.6" webpack "^5.88.2" +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053" @@ -3577,10 +3582,12 @@ "@types/mime" "*" "@types/node" "*" -"@types/socket.io-client@1.4.36": - version "1.4.36" - resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.36.tgz#e4f1ca065f84c20939e9850e70222202bd76ff3f" - integrity sha512-ZJWjtFBeBy1kRSYpVbeGYTElf6BqPQUkXDlHHD4k/42byCN5Rh027f4yARHCink9sKAkbtGZXEAmR0ZCnc2/Ag== +"@types/socket.io-client@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-3.0.0.tgz#d0b8ea22121b7c1df68b6a923002f9c8e3cefb42" + integrity sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg== + dependencies: + socket.io-client "*" "@types/sockjs@^0.3.33": version "0.3.36" @@ -4138,11 +4145,6 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.9.0.tgz#78a16e3b2bcc198c10822786fa6679e245db5b59" integrity sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ== -after@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" - integrity sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA== - agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4359,11 +4361,6 @@ array.prototype.tosorted@^1.1.1: es-shim-unscopables "^1.0.0" get-intrinsic "^1.1.3" -arraybuffer.slice@~0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" - integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog== - assertion-error@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" @@ -4379,11 +4376,6 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== -async-limiter@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" - integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== - async@^2.6.4: version "2.6.4" resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221" @@ -4624,11 +4616,6 @@ babylon@^6.18.0: resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ== -backo2@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" - integrity sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA== - balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -4639,11 +4626,6 @@ base64-arraybuffer-es6@^0.7.0: resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== -base64-arraybuffer@0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812" - integrity sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg== - base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4680,11 +4662,6 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" -blob@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683" - integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig== - body-parser@1.20.1: version "1.20.1" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" @@ -5067,21 +5044,6 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== -component-bind@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" - integrity sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw== - -component-emitter@~1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" - integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== - -component-inherit@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" - integrity sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA== - compressible@~2.0.16: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -5591,7 +5553,7 @@ debug@2.6.9, debug@^2.6.8: dependencies: ms "2.0.0" -debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -5605,13 +5567,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== - dependencies: - ms "2.0.0" - decimal.js@^10.4.3: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" @@ -5894,33 +5849,21 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -engine.io-client@~3.4.0: - version "3.4.4" - resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.4.tgz#77d8003f502b0782dd792b073a4d2cf7ca5ab967" - integrity sha512-iU4CRr38Fecj8HoZEnFtm2EiKGbYZcPn3cHxqNGl/tmdWRf60KhK+9vE0JeSjgnlS/0oynEfLgKbT9ALpim0sQ== +engine.io-client@~6.5.2: + version "6.5.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5" + integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q== dependencies: - component-emitter "~1.3.0" - component-inherit "0.0.3" - debug "~3.1.0" - engine.io-parser "~2.2.0" - has-cors "1.1.0" - indexof "0.0.1" - parseqs "0.0.6" - parseuri "0.0.6" - ws "~6.1.0" - xmlhttprequest-ssl "~1.5.4" - yeast "0.1.2" + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + xmlhttprequest-ssl "~2.0.0" -engine.io-parser@~2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7" - integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg== - dependencies: - after "0.8.2" - arraybuffer.slice "~0.0.7" - base64-arraybuffer "0.1.4" - blob "0.0.5" - has-binary2 "~1.0.2" +engine.io-parser@~5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.1.tgz#9f213c77512ff1a6cc0c7a86108a7ffceb16fcfb" + integrity sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ== enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0, enhanced-resolve@^5.15.0: version "5.15.0" @@ -6960,18 +6903,6 @@ has-bigints@^1.0.1, has-bigints@^1.0.2: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa" integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== -has-binary2@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d" - integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw== - dependencies: - isarray "2.0.1" - -has-cors@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" - integrity sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA== - has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -7265,11 +7196,6 @@ indent-string@^4.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== -indexof@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" - integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg== - inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -7564,11 +7490,6 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" -isarray@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" - integrity sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ== - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -8878,16 +8799,6 @@ parse5@^7.1.2: dependencies: entities "^4.4.0" -parseqs@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5" - integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== - -parseuri@0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a" - integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== - parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -9986,31 +9897,23 @@ sliced@^1.0.1: resolved "https://registry.yarnpkg.com/sliced/-/sliced-1.0.1.tgz#0b3a662b5d04c3177b1926bea82b03f837a2ef41" integrity sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA== -socket.io-client@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.1.tgz#91a4038ef4d03c19967bb3c646fec6e0eaa78cff" - integrity sha512-YXmXn3pA8abPOY//JtYxou95Ihvzmg8U6kQyolArkIyLd0pgVhrfor/iMsox8cn07WCOOvvuJ6XKegzIucPutQ== +socket.io-client@*, socket.io-client@4.7.2: + version "4.7.2" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08" + integrity sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w== dependencies: - backo2 "1.0.2" - component-bind "1.0.0" - component-emitter "~1.3.0" - debug "~3.1.0" - engine.io-client "~3.4.0" - has-binary2 "~1.0.2" - indexof "0.0.1" - parseqs "0.0.6" - parseuri "0.0.6" - socket.io-parser "~3.3.0" - to-array "0.1.4" + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.5.2" + socket.io-parser "~4.2.4" -socket.io-parser@~3.3.0: - version "3.3.3" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.3.tgz#3a8b84823eba87f3f7624e64a8aaab6d6318a72f" - integrity sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg== +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== dependencies: - component-emitter "~1.3.0" - debug "~3.1.0" - isarray "2.0.1" + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" sockjs@^0.3.24: version "0.3.24" @@ -10472,11 +10375,6 @@ tinyspy@^2.2.0: resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.0.tgz#9dc04b072746520b432f77ea2c2d17933de5d6ce" integrity sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg== -to-array@0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890" - integrity sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A== - to-fast-properties@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" @@ -11555,12 +11453,10 @@ ws@^8.4.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== -ws@~6.1.0: - version "6.1.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9" - integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA== - dependencies: - async-limiter "~1.0.0" +ws@~8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== xml-name-validator@^4.0.0: version "4.0.0" @@ -11572,10 +11468,10 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xmlhttprequest-ssl@~1.5.4: - version "1.5.5" - resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" - integrity sha512-/bFPLUgJrfGUL10AIv4Y7/CUt6so9CLtB/oFxQSHseSDNNCdC6vwwKEqwLN6wNPBg9YWXAiMu8jkf6RPRS/75Q== +xmlhttprequest-ssl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" + integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== xmlhttprequest@1.8.0: version "1.8.0" @@ -11628,11 +11524,6 @@ yauzl@^2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" -yeast@0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" - integrity sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg== - yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" From 561e919a2e086b6837519e7592828e40ea49f92a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 16 Dec 2023 11:15:04 +0100 Subject: [PATCH 005/112] fix: import `Socket` as type (#7446) --- excalidraw-app/collab/Portal.tsx | 2 +- excalidraw-app/data/firebase.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 67542b0f3..74ef27a05 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -18,7 +18,7 @@ import { newElementWith } from "../../packages/excalidraw/element/mutateElement" import { BroadcastedExcalidrawElement } from "./reconciliation"; import { encryptData } from "../../packages/excalidraw/data/encryption"; import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants"; -import { Socket } from "socket.io-client"; +import type { Socket } from "socket.io-client"; class Portal { collab: TCollabClass; diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts index 420f457f5..f37fbbd81 100644 --- a/excalidraw-app/data/firebase.ts +++ b/excalidraw-app/data/firebase.ts @@ -21,7 +21,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants"; import { reconcileElements } from "../collab/reconciliation"; import { getSyncableElements, SyncableExcalidrawElement } from "."; import { ResolutionType } from "../../packages/excalidraw/utility-types"; -import { Socket } from "socket.io-client"; +import type { Socket } from "socket.io-client"; // private // ----------------------------------------------------------------------------- From 6dfa89e846df4b51319ae8d91b7382c003b3c293 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 16 Dec 2023 17:32:54 +0100 Subject: [PATCH 006/112] fix: emitted visible scene bounds not accounting for offsets (#7450) --- excalidraw-app/app_constants.ts | 5 +- excalidraw-app/collab/Collab.tsx | 52 +++++++++----------- excalidraw-app/collab/Portal.tsx | 14 +++--- excalidraw-app/data/index.ts | 24 ++++----- packages/excalidraw/CHANGELOG.md | 2 + packages/excalidraw/actions/actionCanvas.tsx | 6 +-- packages/excalidraw/element/bounds.ts | 31 +++++++++++- packages/excalidraw/index.tsx | 2 +- 8 files changed, 82 insertions(+), 54 deletions(-) diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index a20a23506..3402bf106 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -20,9 +20,12 @@ export const WS_EVENTS = { } as const; export enum WS_SUBTYPES { + INVALID_RESPONSE = "INVALID_RESPONSE", INIT = "SCENE_INIT", UPDATE = "SCENE_UPDATE", - USER_VIEWPORT_BOUNDS = "USER_VIEWPORT_BOUNDS", + MOUSE_LOCATION = "MOUSE_LOCATION", + IDLE_STATUS = "IDLE_STATUS", + USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS", } export const FIREBASE_STORAGE_PREFIXES = { diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index d55ec5f3d..a0cdc2773 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -18,10 +18,10 @@ import { } from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { + assertNever, preventUnload, resolvablePromise, throttleRAF, - viewportCoordsToSceneCoords, withBatchedUpdates, } from "../../packages/excalidraw/utils"; import { @@ -81,7 +81,8 @@ import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom, useAtom } from "jotai"; import { appJotaiStore } from "../app-jotai"; -import { Mutable } from "../../packages/excalidraw/utility-types"; +import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; +import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); @@ -174,7 +175,7 @@ class Collab extends PureComponent { this.portal.socket && this.portal.broadcastUserFollowed(payload); }); const throttledRelayUserViewportBounds = throttleRAF( - this.relayUserViewportBounds, + this.relayVisibleSceneBounds, ); const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => throttledRelayUserViewportBounds(), @@ -384,7 +385,7 @@ class Collab extends PureComponent { iv: Uint8Array, encryptedData: ArrayBuffer, decryptionKey: string, - ) => { + ): Promise> => { try { const decrypted = await decryptData(iv, encryptedData, decryptionKey); @@ -396,7 +397,7 @@ class Collab extends PureComponent { window.alert(t("alerts.decryptFailed")); console.error(error); return { - type: "INVALID_RESPONSE", + type: WS_SUBTYPES.INVALID_RESPONSE, }; } }; @@ -512,7 +513,7 @@ class Collab extends PureComponent { ); switch (decryptedData.type) { - case "INVALID_RESPONSE": + case WS_SUBTYPES.INVALID_RESPONSE: return; case WS_SUBTYPES.INIT: { if (!this.portal.socketInitialized) { @@ -535,7 +536,7 @@ class Collab extends PureComponent { this.reconcileElements(decryptedData.payload.elements), ); break; - case "MOUSE_LOCATION": { + case WS_SUBTYPES.MOUSE_LOCATION: { const { pointer, button, username, selectedElementIds } = decryptedData.payload; @@ -554,8 +555,8 @@ class Collab extends PureComponent { break; } - case WS_SUBTYPES.USER_VIEWPORT_BOUNDS: { - const { bounds, socketId } = decryptedData.payload; + case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: { + const { sceneBounds, socketId } = decryptedData.payload; const appState = this.excalidrawAPI.getAppState(); @@ -579,7 +580,7 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ appState: zoomToFitBounds({ appState, - bounds, + bounds: sceneBounds, fitToViewport: true, viewportZoomFactor: 1, }).appState, @@ -588,7 +589,7 @@ class Collab extends PureComponent { break; } - case "IDLE_STATUS": { + case WS_SUBTYPES.IDLE_STATUS: { const { userState, socketId, username } = decryptedData.payload; this.updateCollaborator(socketId, { userState, @@ -596,6 +597,10 @@ class Collab extends PureComponent { }); break; } + + default: { + assertNever(decryptedData, null); + } } }, ); @@ -618,7 +623,7 @@ class Collab extends PureComponent { appState: { followedBy: new Set(followedBy) }, }); - this.relayUserViewportBounds({ shouldPerform: true }); + this.relayVisibleSceneBounds({ force: true }); }, ); @@ -848,25 +853,14 @@ class Collab extends PureComponent { CURSOR_SYNC_TIMEOUT, ); - relayUserViewportBounds = (props?: { shouldPerform: boolean }) => { + relayVisibleSceneBounds = (props?: { force: boolean }) => { const appState = this.excalidrawAPI.getAppState(); - if ( - this.portal.socket && - (appState.followedBy.size > 0 || props?.shouldPerform) - ) { - const { x: x1, y: y1 } = viewportCoordsToSceneCoords( - { clientX: 0, clientY: 0 }, - appState, - ); - - const { x: x2, y: y2 } = viewportCoordsToSceneCoords( - { clientX: appState.width, clientY: appState.height }, - appState, - ); - - this.portal.broadcastUserViewportBounds( - { bounds: [x1, y1, x2, y2] }, + if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) { + this.portal.broadcastVisibleSceneBounds( + { + sceneBounds: getVisibleSceneBounds(appState), + }, `follow@${this.portal.socket.id}`, ); } diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 74ef27a05..66dd8a56b 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -184,7 +184,7 @@ class Portal { broadcastIdleChange = (userState: UserIdleState) => { if (this.socket?.id) { const data: SocketUpdateDataSource["IDLE_STATUS"] = { - type: "IDLE_STATUS", + type: WS_SUBTYPES.IDLE_STATUS, payload: { socketId: this.socket.id, userState, @@ -204,7 +204,7 @@ class Portal { }) => { if (this.socket?.id) { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { - type: "MOUSE_LOCATION", + type: WS_SUBTYPES.MOUSE_LOCATION, payload: { socketId: this.socket.id, pointer: payload.pointer, @@ -222,19 +222,19 @@ class Portal { } }; - broadcastUserViewportBounds = ( + broadcastVisibleSceneBounds = ( payload: { - bounds: [number, number, number, number]; + sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"]; }, roomId: string, ) => { if (this.socket?.id) { - const data: SocketUpdateDataSource["USER_VIEWPORT_BOUNDS"] = { - type: WS_SUBTYPES.USER_VIEWPORT_BOUNDS, + const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS, payload: { socketId: this.socket.id, username: this.collab.state.username, - bounds: payload.bounds, + sceneBounds: payload.sceneBounds, }, }; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index b162da9a4..fe44dc138 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -10,6 +10,7 @@ import { import { serializeAsJSON } from "../../packages/excalidraw/data/json"; import { restore } from "../../packages/excalidraw/data/restore"; import { ImportedDataState } from "../../packages/excalidraw/data/types"; +import { SceneBounds } from "../../packages/excalidraw/element/bounds"; import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import { @@ -28,6 +29,7 @@ import { DELETED_ELEMENT_TIMEOUT, FILE_UPLOAD_MAX_BYTES, ROOM_ID_BYTES, + WS_SUBTYPES, } from "../app_constants"; import { encodeFilesForUpload } from "./FileManager"; import { saveFilesToFirebase } from "./firebase"; @@ -97,20 +99,23 @@ export type EncryptedData = { }; export type SocketUpdateDataSource = { + INVALID_RESPONSE: { + type: WS_SUBTYPES.INVALID_RESPONSE; + }; SCENE_INIT: { - type: "SCENE_INIT"; + type: WS_SUBTYPES.INIT; payload: { elements: readonly ExcalidrawElement[]; }; }; SCENE_UPDATE: { - type: "SCENE_UPDATE"; + type: WS_SUBTYPES.UPDATE; payload: { elements: readonly ExcalidrawElement[]; }; }; MOUSE_LOCATION: { - type: "MOUSE_LOCATION"; + type: WS_SUBTYPES.MOUSE_LOCATION; payload: { socketId: string; pointer: { x: number; y: number; tool: "pointer" | "laser" }; @@ -119,16 +124,16 @@ export type SocketUpdateDataSource = { username: string; }; }; - USER_VIEWPORT_BOUNDS: { - type: "USER_VIEWPORT_BOUNDS"; + USER_VISIBLE_SCENE_BOUNDS: { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS; payload: { socketId: string; username: string; - bounds: [number, number, number, number]; + sceneBounds: SceneBounds; }; }; IDLE_STATUS: { - type: "IDLE_STATUS"; + type: WS_SUBTYPES.IDLE_STATUS; payload: { socketId: string; userState: UserIdleState; @@ -138,10 +143,7 @@ export type SocketUpdateDataSource = { }; export type SocketUpdateDataIncoming = - | SocketUpdateDataSource[keyof SocketUpdateDataSource] - | { - type: "INVALID_RESPONSE"; - }; + SocketUpdateDataSource[keyof SocketUpdateDataSource]; export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & { diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index de8ce7227..652c77848 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -13,6 +13,8 @@ Please add the latest change on the top under the correct section. ## Unreleased +- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) + ### Breaking Changes - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 7d57c64a7..ab5f8cfd7 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -20,7 +20,7 @@ import { isHandToolActive, } from "../appState"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; -import { Bounds } from "../element/bounds"; +import { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; export const actionChangeViewBackgroundColor = register({ @@ -211,7 +211,7 @@ export const actionResetZoom = register({ }); const zoomValueToFitBoundsOnViewport = ( - bounds: Bounds, + bounds: SceneBounds, viewportDimensions: { width: number; height: number }, ) => { const [x1, y1, x2, y2] = bounds; @@ -235,7 +235,7 @@ export const zoomToFitBounds = ({ fitToViewport = false, viewportZoomFactor = 0.7, }: { - bounds: readonly [number, number, number, number]; + bounds: SceneBounds; appState: Readonly; /** whether to fit content to viewport (beyond >100%) */ fitToViewport: boolean; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index f8d8223f7..292fc995d 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -9,7 +9,7 @@ import { import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; import { Drawable, Op } from "roughjs/bin/core"; -import { Point } from "../types"; +import { AppState, Point } from "../types"; import { generateRoughOptions } from "../scene/Shape"; import { isArrowElement, @@ -35,7 +35,9 @@ export type RectangleBox = { type MaybeQuadraticSolution = [number | null, number | null] | false; -// x and y position of top left corner, x and y position of bottom right corner +/** + * x and y position of top left corner, x and y position of bottom right corner + */ export type Bounds = readonly [ minX: number, minY: number, @@ -43,6 +45,13 @@ export type Bounds = readonly [ maxY: number, ]; +export type SceneBounds = readonly [ + sceneX: number, + sceneY: number, + sceneX2: number, + sceneY2: number, +]; + export class ElementBounds { private static boundsCache = new WeakMap< ExcalidrawElement, @@ -879,3 +888,21 @@ export const getCommonBoundingBox = ( midY: (minY + maxY) / 2, }; }; + +/** + * returns scene coords of user's editor viewport (visible canvas area) bounds + */ +export const getVisibleSceneBounds = ({ + scrollX, + scrollY, + width, + height, + zoom, +}: AppState): SceneBounds => { + return [ + -scrollX, + -scrollY, + -scrollX + width / zoom.value, + -scrollY + height / zoom.value, + ]; +}; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 74aa6d8d5..915836cb8 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -249,7 +249,7 @@ export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { normalizeLink } from "./data/url"; export { zoomToFitBounds } from "./actions/actionCanvas"; export { convertToExcalidrawElements } from "./data/transform"; -export { getCommonBounds } from "./element/bounds"; +export { getCommonBounds, getVisibleSceneBounds } from "./element/bounds"; export { elementsOverlappingBBox, From 537f6e7f6840b8e7cf95382fcd37becec968b5df Mon Sep 17 00:00:00 2001 From: Jason Praful Date: Sat, 16 Dec 2023 18:18:35 +0000 Subject: [PATCH 007/112] docs: add steps for local development (#7449) * docs: add steps for local development #7434 * docs: minor tweaks --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a8a3f908..e8cd3b06f 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ We'll be adding these features as drop-in plugins for the npm package in the fut ## Quick start -Install the [Excalidraw npm package](https://www.npmjs.com/package/@excalidraw/excalidraw): +**Note:** following instructions are for installing the Excalidraw [npm package](https://www.npmjs.com/package/@excalidraw/excalidraw) when integrating Excalidraw into your own app. To run the repository locally for development, please refer to our [Development Guide](https://docs.excalidraw.com/docs/introduction/development). ``` npm install react react-dom @excalidraw/excalidraw @@ -97,7 +97,7 @@ or via yarn yarn add react react-dom @excalidraw/excalidraw ``` -Don't forget to check out our [Documentation](https://docs.excalidraw.com)! +Check out our [documentation](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/installation) for more details! ## Contributing From 7bd6496854d95ea3b25576308a4b47cdac20b3fa Mon Sep 17 00:00:00 2001 From: Adithyan <123532141+golok727@users.noreply.github.com> Date: Sat, 16 Dec 2023 23:53:11 +0530 Subject: [PATCH 008/112] refactor: Fix Typo (#7445) --- packages/excalidraw/components/App.tsx | 26 ++++++++++++++------------ packages/excalidraw/types.ts | 6 +++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index ff80ed5b0..cdecbf342 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -439,7 +439,7 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; const ExcalidrawSetAppStateContext = React.createContext< React.Component["setState"] >(() => { - console.warn("unitialized ExcalidrawSetAppStateContext context!"); + console.warn("Uninitialized ExcalidrawSetAppStateContext context!"); }); ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext"; @@ -2867,7 +2867,6 @@ class App extends React.Component { // event else some browsers (FF...) will clear the clipboardData // (something something security) let file = event?.clipboardData?.files[0]; - const data = await parseClipboard(event, isPlainPaste); if (!file && !isPlainPaste) { if (data.mixedContent) { @@ -3370,7 +3369,7 @@ class App extends React.Component { }); }; - private cancelInProgresAnimation: (() => void) | null = null; + private cancelInProgressAnimation: (() => void) | null = null; scrollToContent = ( target: @@ -3395,7 +3394,7 @@ class App extends React.Component { duration?: number; }, ) => { - this.cancelInProgresAnimation?.(); + this.cancelInProgressAnimation?.(); // convert provided target into ExcalidrawElement[] if necessary const targetElements = Array.isArray(target) ? target : [target]; @@ -3462,9 +3461,9 @@ class App extends React.Component { duration: opts?.duration ?? 500, }); - this.cancelInProgresAnimation = () => { + this.cancelInProgressAnimation = () => { cancel(); - this.cancelInProgresAnimation = null; + this.cancelInProgressAnimation = null; }; } else { this.setState({ scrollX, scrollY, zoom }); @@ -3481,7 +3480,7 @@ class App extends React.Component { private translateCanvas: React.Component["setState"] = ( state, ) => { - this.cancelInProgresAnimation?.(); + this.cancelInProgressAnimation?.(); this.maybeUnfollowRemoteUser(); this.setState(state); }; @@ -7779,7 +7778,7 @@ class App extends React.Component { ), }; }); - // if not gragging a linear element point (outside editor) + // if not dragging a linear element point (outside editor) } else if (!this.state.selectedLinearElement?.isDragging) { // remove element from selection while // keeping prev elements selected @@ -8104,7 +8103,10 @@ class App extends React.Component { maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, }); } catch (error: any) { - console.error("error trying to resing image file on insertion", error); + console.error( + "Error trying to resizing image file on insertion", + error, + ); } if (imageFile.size > MAX_ALLOWED_FILE_BYTES) { @@ -8647,7 +8649,7 @@ class App extends React.Component { } if (file) { - // atetmpt to parse an excalidraw/excalidrawlib file + // Attempt to parse an excalidraw/excalidrawlib file await this.loadFileToCanvas(file, fileHandle); } @@ -8746,13 +8748,13 @@ class App extends React.Component { }); const selectedElements = this.scene.getSelectedElements(this.state); - const isHittignCommonBoundBox = + const isHittingCommonBoundBox = this.isHittingCommonBoundingBoxOfSelectedElements( { x, y }, selectedElements, ); - const type = element || isHittignCommonBoundBox ? "element" : "canvas"; + const type = element || isHittingCommonBoundBox ? "element" : "canvas"; const container = this.excalidrawContainerRef.current!; const { top: offsetTop, left: offsetLeft } = diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index a4a0b0113..249ead30d 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -53,7 +53,7 @@ export type Collaborator = Readonly<{ background: string; stroke: string; }; - // The url of the collaborator's avatar, defaults to username intials + // The url of the collaborator's avatar, defaults to username initials // if not present avatarUrl?: string; // user id. If supplied, we'll filter out duplicates when rendering user avatars. @@ -500,10 +500,10 @@ export type ExportOpts = { ) => JSX.Element; }; -// NOTE at the moment, if action name coressponds to canvasAction prop, its +// NOTE at the moment, if action name corresponds to canvasAction prop, its // truthiness value will determine whether the action is rendered or not // (see manager renderAction). We also override canvasAction values in -// excalidraw package index.tsx. +// Excalidraw package index.tsx. export type CanvasActions = Partial<{ changeViewBackgroundColor: boolean; clearCanvas: boolean; From 2a0fe2584e781b532006c0477aea6d491753a8ba Mon Sep 17 00:00:00 2001 From: Lynda Lin Date: Mon, 18 Dec 2023 20:42:17 +0800 Subject: [PATCH 009/112] fix: empty snapLines arrays would cause re-render (#7454) Co-authored-by: Lynda Lin Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/App.tsx | 35 ++++++++--- .../tests/__snapshots__/move.test.tsx.snap | 12 ++-- .../multiPointCreate.test.tsx.snap | 4 +- .../tests/linearElementEditor.test.tsx | 62 ++++++++++++------- packages/excalidraw/tests/move.test.tsx | 30 +++++---- .../tests/multiPointCreate.test.tsx | 20 +++--- packages/excalidraw/utils.ts | 40 ++++++++++-- 7 files changed, 139 insertions(+), 64 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cdecbf342..71d6ea821 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -268,6 +268,7 @@ import { muteFSAbortError, isTestEnv, easeOut, + updateStable, } from "../utils"; import { createSrcDoc, @@ -4736,13 +4737,31 @@ class App extends React.Component { event, ); - this.setState({ - snapLines, - originSnapOffset: originOffset, + this.setState((prevState) => { + const nextSnapLines = updateStable(prevState.snapLines, snapLines); + const nextOriginOffset = prevState.originSnapOffset + ? updateStable(prevState.originSnapOffset, originOffset) + : originOffset; + + if ( + prevState.snapLines === nextSnapLines && + prevState.originSnapOffset === nextOriginOffset + ) { + return null; + } + return { + snapLines: nextSnapLines, + originSnapOffset: nextOriginOffset, + }; }); } else if (!this.state.draggingElement) { - this.setState({ - snapLines: [], + this.setState((prevState) => { + if (prevState.snapLines.length) { + return { + snapLines: [], + }; + } + return null; }); } @@ -7227,7 +7246,7 @@ class App extends React.Component { isRotating, } = this.state; - this.setState({ + this.setState((prevState) => ({ isResizing: false, isRotating: false, resizingElement: null, @@ -7241,10 +7260,10 @@ class App extends React.Component { multiElement || isTextElement(this.state.editingElement) ? this.state.editingElement : null, - snapLines: [], + snapLines: updateStable(prevState.snapLines, []), originSnapOffset: null, - }); + })); SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 015ff7c4c..f348d0501 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`duplicate element on move when ALT is clicked > rectangle 1`] = ` +exports[`duplicate element on move when ALT is clicked > rectangle 5`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -32,7 +32,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 1`] = ` } `; -exports[`duplicate element on move when ALT is clicked > rectangle 2`] = ` +exports[`duplicate element on move when ALT is clicked > rectangle 6`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -64,7 +64,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 2`] = ` } `; -exports[`move element > rectangle 1`] = ` +exports[`move element > rectangle 5`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -96,7 +96,7 @@ exports[`move element > rectangle 1`] = ` } `; -exports[`move element > rectangles with binding arrow 1`] = ` +exports[`move element > rectangles with binding arrow 5`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -133,7 +133,7 @@ exports[`move element > rectangles with binding arrow 1`] = ` } `; -exports[`move element > rectangles with binding arrow 2`] = ` +exports[`move element > rectangles with binding arrow 6`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -170,7 +170,7 @@ exports[`move element > rectangles with binding arrow 2`] = ` } `; -exports[`move element > rectangles with binding arrow 3`] = ` +exports[`move element > rectangles with binding arrow 7`] = ` { "angle": 0, "backgroundColor": "transparent", diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index 48ae1f640..12e7e61ed 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`multi point mode in linear elements > arrow 1`] = ` +exports[`multi point mode in linear elements > arrow 3`] = ` { "angle": 0, "backgroundColor": "transparent", @@ -54,7 +54,7 @@ exports[`multi point mode in linear elements > arrow 1`] = ` } `; -exports[`multi point mode in linear elements > line 1`] = ` +exports[`multi point mode in linear elements > line 3`] = ` { "angle": 0, "backgroundColor": "transparent", diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 6c44c6cd8..f4ddeafd2 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -173,14 +173,14 @@ describe("Test Linear Elements", () => { createTwoPointerLinearElement("line"); const line = h.elements[0] as ExcalidrawLinearElement; - expect(renderInteractiveScene).toHaveBeenCalledTimes(5); - expect(renderStaticScene).toHaveBeenCalledTimes(5); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2); // drag line from midpoint drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(9); - expect(renderStaticScene).toHaveBeenCalledTimes(7); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` [ @@ -273,8 +273,10 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(14); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `12`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` @@ -311,8 +313,10 @@ describe("Test Linear Elements", () => { // update roundness fireEvent.click(screen.getByTitle("Round")); - expect(renderInteractiveScene).toHaveBeenCalledTimes(10); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `10`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, @@ -357,8 +361,10 @@ describe("Test Linear Elements", () => { // Move the element drag(startPoint, endPoint); - expect(renderInteractiveScene).toHaveBeenCalledTimes(14); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `13`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect([line.x, line.y]).toEqual([ points[0][0] + deltaX, @@ -416,8 +422,10 @@ describe("Test Linear Elements", () => { lastSegmentMidpoint[1] + delta, ]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(21); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `17`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -457,8 +465,10 @@ describe("Test Linear Elements", () => { // Drag from first point drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(14); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `13`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ @@ -484,8 +494,10 @@ describe("Test Linear Elements", () => { // Drag from first point drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(14); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `13`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ @@ -519,8 +531,10 @@ describe("Test Linear Elements", () => { // delete 3rd point deletePoint(points[2]); expect(line.points.length).toEqual(3); - expect(renderInteractiveScene).toHaveBeenCalledTimes(21); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `19`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); const newMidPoints = LinearElementEditor.getEditorMidPoints( line, @@ -566,8 +580,10 @@ describe("Test Linear Elements", () => { lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta, ]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(21); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `17`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); expect((h.elements[0] as ExcalidrawLinearElement).points) @@ -642,8 +658,10 @@ describe("Test Linear Elements", () => { // Drag from first point drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]); - expect(renderInteractiveScene).toHaveBeenCalledTimes(14); - expect(renderStaticScene).toHaveBeenCalledTimes(8); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `13`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 248f43d72..22d828ee9 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -42,8 +42,10 @@ describe("move element", () => { fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(6); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `6`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -57,8 +59,8 @@ describe("move element", () => { fireEvent.pointerMove(canvas, { clientX: 20, clientY: 40 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(3); - expect(renderStaticScene).toHaveBeenCalledTimes(2); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`3`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`2`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect([h.elements[0].x, h.elements[0].y]).toEqual([0, 40]); @@ -84,8 +86,10 @@ describe("move element", () => { // select the second rectangles new Pointer("mouse").clickOn(rectB); - expect(renderInteractiveScene).toHaveBeenCalledTimes(24); - expect(renderStaticScene).toHaveBeenCalledTimes(19); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `21`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`19`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(3); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); @@ -103,8 +107,8 @@ describe("move element", () => { Keyboard.keyDown(KEYS.ARROW_DOWN); // Check that the arrow size has been changed according to moving the rectangle - expect(renderInteractiveScene).toHaveBeenCalledTimes(3); - expect(renderStaticScene).toHaveBeenCalledTimes(3); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`3`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`3`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(3); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); @@ -130,8 +134,10 @@ describe("duplicate element on move when ALT is clicked", () => { fireEvent.pointerMove(canvas, { clientX: 60, clientY: 70 }); fireEvent.pointerUp(canvas); - expect(renderInteractiveScene).toHaveBeenCalledTimes(6); - expect(renderStaticScene).toHaveBeenCalledTimes(6); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( + `6`, + ); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(1); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); @@ -152,8 +158,8 @@ describe("duplicate element on move when ALT is clicked", () => { // TODO: This used to be 4, but binding made it go up to 5. Do we need // that additional render? - expect(renderInteractiveScene).toHaveBeenCalledTimes(5); - expect(renderStaticScene).toHaveBeenCalledTimes(3); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`4`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`3`); expect(h.state.selectionElement).toBeNull(); expect(h.elements.length).toEqual(2); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index 93256d07c..f462cfacf 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -47,8 +47,8 @@ describe("remove shape in non linear elements", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(5); - expect(renderStaticScene).toHaveBeenCalledTimes(5); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(h.elements.length).toEqual(0); }); @@ -62,8 +62,8 @@ describe("remove shape in non linear elements", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(5); - expect(renderStaticScene).toHaveBeenCalledTimes(5); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(h.elements.length).toEqual(0); }); @@ -77,8 +77,8 @@ describe("remove shape in non linear elements", () => { fireEvent.pointerDown(canvas, { clientX: 30, clientY: 20 }); fireEvent.pointerUp(canvas, { clientX: 30, clientY: 30 }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(5); - expect(renderStaticScene).toHaveBeenCalledTimes(5); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`5`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`); expect(h.elements.length).toEqual(0); }); }); @@ -110,8 +110,8 @@ describe("multi point mode in linear elements", () => { key: KEYS.ENTER, }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(11); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; @@ -153,8 +153,8 @@ describe("multi point mode in linear elements", () => { fireEvent.keyDown(document, { key: KEYS.ENTER, }); - expect(renderInteractiveScene).toHaveBeenCalledTimes(11); - expect(renderStaticScene).toHaveBeenCalledTimes(9); + expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(h.elements.length).toEqual(1); const element = h.elements[0] as ExcalidrawLinearElement; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 69b3426e4..4278f36f6 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -769,6 +769,24 @@ export const queryFocusableElements = (container: HTMLElement | null) => { : []; }; +/** use as a fallback after identity check (for perf reasons) */ +const _defaultIsShallowComparatorFallback = (a: any, b: any): boolean => { + // consider two empty arrays equal + if ( + Array.isArray(a) && + Array.isArray(b) && + a.length === 0 && + b.length === 0 + ) { + return true; + } + return a === b; +}; + +/** + * Returns whether object/array is shallow equal. + * Considers empty object/arrays as equal (whether top-level or second-level). + */ export const isShallowEqual = < T extends Record, K extends readonly unknown[], @@ -796,10 +814,12 @@ export const isShallowEqual = < if (comparators && Array.isArray(comparators)) { for (const key of comparators) { - const ret = objA[key] === objB[key]; + const ret = + objA[key] === objB[key] || + _defaultIsShallowComparatorFallback(objA[key], objB[key]); if (!ret) { if (debug) { - console.info( + console.warn( `%cisShallowEqual: ${key} not equal ->`, "color: #8B4000", objA[key], @@ -818,9 +838,11 @@ export const isShallowEqual = < )?.[key as keyof T]; const ret = comparator ? comparator(objA[key], objB[key]) - : objA[key] === objB[key]; + : objA[key] === objB[key] || + _defaultIsShallowComparatorFallback(objA[key], objB[key]); + if (!ret && debug) { - console.info( + console.warn( `%cisShallowEqual: ${key} not equal ->`, "color: #8B4000", objA[key], @@ -960,3 +982,13 @@ 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, +) => { + if (isShallowEqual(prevValue, nextValue)) { + return prevValue; + } + return nextValue; +}; From 0808532b494cda95815d95a8a9a754a5106a4fc2 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Dec 2023 16:14:25 +0100 Subject: [PATCH 010/112] fix: follow mode collaborator status indicator (#7459) --- .../excalidraw/actions/actionNavigate.tsx | 30 ++++++---- packages/excalidraw/components/App.tsx | 1 + packages/excalidraw/components/LayerUI.tsx | 5 +- packages/excalidraw/components/Tooltip.tsx | 5 ++ packages/excalidraw/components/UserList.scss | 7 +++ packages/excalidraw/components/UserList.tsx | 57 ++++++++++++------- .../components/main-menu/MainMenu.tsx | 1 + packages/excalidraw/locales/en.json | 3 +- 8 files changed, 75 insertions(+), 34 deletions(-) diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 46492c733..1c55f1046 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,5 +1,8 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; +import { GoToCollaboratorComponentProps } from "../components/UserList"; +import { eyeIcon } from "../components/icons"; +import { t } from "../i18n"; import { Collaborator } from "../types"; import { register } from "./register"; @@ -35,38 +38,43 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ updateData, data, appState }) => { - const [clientId, collaborator, withName] = data as [ - string, - Collaborator, - boolean, - ]; + const [socketId, collaborator, withName, isBeingFollowed] = + data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId); return withName ? (
updateData({ ...collaborator, clientId })} + className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator" + onClick={() => updateData({ ...collaborator, socketId })} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={appState.userToFollow?.socketId === clientId} + isBeingFollowed={isBeingFollowed} isCurrentUser={collaborator.isCurrentUser === true} /> {collaborator.username} +
+ {eyeIcon} +
) : ( { - updateData({ ...collaborator, clientId }); + updateData({ ...collaborator, socketId }); }} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={appState.userToFollow?.socketId === clientId} + isBeingFollowed={isBeingFollowed} isCurrentUser={collaborator.isCurrentUser === true} /> ); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 71d6ea821..5f3f8be28 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3472,6 +3472,7 @@ class App extends React.Component { }; private maybeUnfollowRemoteUser = () => { + console.warn("maybeUnfollowRemoteUser"); if (this.state.userToFollow) { this.setState({ userToFollow: null }); } diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index c7866e7a8..7dd362063 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -339,7 +339,10 @@ const LayerUI = ({ )} > {appState.collaborators.size > 0 && ( - + )} {renderTopRightUI?.(device.editor.isMobile, appState)} {!appState.viewModeEnabled && diff --git a/packages/excalidraw/components/Tooltip.tsx b/packages/excalidraw/components/Tooltip.tsx index 220de2831..38c04ef23 100644 --- a/packages/excalidraw/components/Tooltip.tsx +++ b/packages/excalidraw/components/Tooltip.tsx @@ -80,6 +80,7 @@ type TooltipProps = { label: string; long?: boolean; style?: React.CSSProperties; + disabled?: boolean; }; export const Tooltip = ({ @@ -87,11 +88,15 @@ export const Tooltip = ({ label, long = false, style, + disabled, }: TooltipProps) => { useEffect(() => { return () => getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); }, []); + if (disabled) { + return null; + } return (
shouldWrap ? ( - + {children} ) : ( - {children} + {children} ); const renderCollaborator = ({ actionManager, collaborator, - clientId, + socketId, withName = false, shouldWrapWithTooltip = false, + isBeingFollowed, }: { actionManager: ActionManager; collaborator: Collaborator; - clientId: string; + socketId: string; withName?: boolean; shouldWrapWithTooltip?: boolean; + isBeingFollowed: boolean; }) => { - const avatarJSX = actionManager.renderAction("goToCollaborator", [ - clientId, + const data: GoToCollaboratorComponentProps = [ + socketId, collaborator, withName, - ]); + isBeingFollowed, + ]; + const avatarJSX = actionManager.renderAction("goToCollaborator", data); return ( @@ -75,6 +86,7 @@ type UserListProps = { className?: string; mobile?: boolean; collaborators: Map; + userToFollow: SocketId | null; }; const collaboratorComparatorKeys = [ @@ -85,7 +97,7 @@ const collaboratorComparatorKeys = [ ] as const; export const UserList = React.memo( - ({ className, mobile, collaborators }: UserListProps) => { + ({ className, mobile, collaborators, userToFollow }: UserListProps) => { const actionManager = useExcalidrawActionManager(); const uniqueCollaboratorsMap = new Map(); @@ -98,7 +110,6 @@ export const UserList = React.memo( ); }); - // const uniqueCollaboratorsMap = sampleCollaborators; const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter( ([_, collaborator]) => collaborator.username?.trim(), ); @@ -123,23 +134,25 @@ export const UserList = React.memo( ); const firstNAvatarsJSX = firstNCollaborators.map( - ([clientId, collaborator]) => + ([socketId, collaborator]) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId, shouldWrapWithTooltip: true, + isBeingFollowed: socketId === userToFollow, }), ); return mobile ? (
- {uniqueCollaboratorsArray.map(([clientId, collaborator]) => + {uniqueCollaboratorsArray.map(([socketId, collaborator]) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId, shouldWrapWithTooltip: true, + isBeingFollowed: socketId === userToFollow, }), )}
@@ -161,7 +174,7 @@ export const UserList = React.memo( {t("userList.hint.text")}
- {filteredCollaborators.map(([clientId, collaborator]) => + {filteredCollaborators.map(([socketId, collaborator]) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId, withName: true, + isBeingFollowed: socketId === userToFollow, }), )}
@@ -212,7 +226,8 @@ export const UserList = React.memo( if ( prev.collaborators.size !== next.collaborators.size || prev.mobile !== next.mobile || - prev.className !== next.className + prev.className !== next.className || + prev.userToFollow !== next.userToFollow ) { return false; } diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index 37450ca5e..07afd3c1d 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -60,6 +60,7 @@ const MainMenu = Object.assign( )} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index dc5e86f4a..a57b823d4 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -528,7 +528,8 @@ "empty": "No users found" }, "hint": { - "text": "Click on user to follow" + "text": "Click on user to follow", + "followStatus": "You're currently following this user" } } } From 57ea4e61d163318edf0f71bec566439821ad7f1b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Dec 2023 18:21:57 +0100 Subject: [PATCH 011/112] fix: mixing clientId & socketId in UserList (#7461) --- excalidraw-app/collab/Collab.tsx | 6 +-- excalidraw-app/collab/Portal.tsx | 9 ++-- excalidraw-app/data/index.ts | 7 +-- .../excalidraw/actions/actionNavigate.tsx | 3 +- packages/excalidraw/components/App.tsx | 1 - .../components/LaserTool/LaserPathManager.ts | 3 +- packages/excalidraw/components/UserList.tsx | 46 ++++++++++--------- packages/excalidraw/types.ts | 10 ++-- 8 files changed, 46 insertions(+), 39 deletions(-) diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index a0cdc2773..9b26af054 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -123,7 +123,7 @@ class Collab extends PureComponent { private socketInitializationTimer?: number; private lastBroadcastedOrReceivedSceneVersion: number = -1; - private collaborators = new Map(); + private collaborators = new Map(); constructor(props: Props) { super(props); @@ -618,7 +618,7 @@ class Collab extends PureComponent { this.portal.socket.on( WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, - (followedBy: string[]) => { + (followedBy: SocketId[]) => { this.excalidrawAPI.updateScene({ appState: { followedBy: new Set(followedBy) }, }); @@ -795,7 +795,7 @@ class Collab extends PureComponent { document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); }; - setCollaborators(sockets: string[]) { + setCollaborators(sockets: SocketId[]) { const collaborators: InstanceType["collaborators"] = new Map(); for (const socketId of sockets) { diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 66dd8a56b..bf8ffa5de 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -10,6 +10,7 @@ import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; import { OnUserFollowedPayload, + SocketId, UserIdleState, } from "../../packages/excalidraw/types"; import { trackEvent } from "../../packages/excalidraw/analytics"; @@ -51,7 +52,7 @@ class Portal { /* syncAll */ true, ); }); - this.socket.on("room-user-change", (clients: string[]) => { + this.socket.on("room-user-change", (clients: SocketId[]) => { this.collab.setCollaborators(clients); }); @@ -186,7 +187,7 @@ class Portal { const data: SocketUpdateDataSource["IDLE_STATUS"] = { type: WS_SUBTYPES.IDLE_STATUS, payload: { - socketId: this.socket.id, + socketId: this.socket.id as SocketId, userState, username: this.collab.state.username, }, @@ -206,7 +207,7 @@ class Portal { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { type: WS_SUBTYPES.MOUSE_LOCATION, payload: { - socketId: this.socket.id, + socketId: this.socket.id as SocketId, pointer: payload.pointer, button: payload.button || "up", selectedElementIds: @@ -232,7 +233,7 @@ class Portal { const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = { type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS, payload: { - socketId: this.socket.id, + socketId: this.socket.id as SocketId, username: this.collab.state.username, sceneBounds: payload.sceneBounds, }, diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index fe44dc138..0f54ee880 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -22,6 +22,7 @@ import { AppState, BinaryFileData, BinaryFiles, + SocketId, UserIdleState, } from "../../packages/excalidraw/types"; import { bytesToHexString } from "../../packages/excalidraw/utils"; @@ -117,7 +118,7 @@ export type SocketUpdateDataSource = { MOUSE_LOCATION: { type: WS_SUBTYPES.MOUSE_LOCATION; payload: { - socketId: string; + socketId: SocketId; pointer: { x: number; y: number; tool: "pointer" | "laser" }; button: "down" | "up"; selectedElementIds: AppState["selectedElementIds"]; @@ -127,7 +128,7 @@ export type SocketUpdateDataSource = { USER_VISIBLE_SCENE_BOUNDS: { type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS; payload: { - socketId: string; + socketId: SocketId; username: string; sceneBounds: SceneBounds; }; @@ -135,7 +136,7 @@ export type SocketUpdateDataSource = { IDLE_STATUS: { type: WS_SUBTYPES.IDLE_STATUS; payload: { - socketId: string; + socketId: SocketId; userState: UserIdleState; username: string; }; diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 1c55f1046..8e8d30231 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -12,6 +12,7 @@ export const actionGoToCollaborator = register({ trackEvent: { category: "collab" }, perform: (_elements, appState, collaborator: Collaborator) => { if ( + !collaborator.socketId || appState.userToFollow?.socketId === collaborator.socketId || collaborator.isCurrentUser ) { @@ -28,7 +29,7 @@ export const actionGoToCollaborator = register({ appState: { ...appState, userToFollow: { - socketId: collaborator.socketId!, + socketId: collaborator.socketId, username: collaborator.username || "", }, // Close mobile menu diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5f3f8be28..71d6ea821 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3472,7 +3472,6 @@ class App extends React.Component { }; private maybeUnfollowRemoteUser = () => { - console.warn("maybeUnfollowRemoteUser"); if (this.state.userToFollow) { this.setState({ userToFollow: null }); } diff --git a/packages/excalidraw/components/LaserTool/LaserPathManager.ts b/packages/excalidraw/components/LaserTool/LaserPathManager.ts index 2f0c63955..b6e462aa3 100644 --- a/packages/excalidraw/components/LaserTool/LaserPathManager.ts +++ b/packages/excalidraw/components/LaserTool/LaserPathManager.ts @@ -3,6 +3,7 @@ import { LaserPointer } from "@excalidraw/laser-pointer"; import { sceneCoordsToViewportCoords } from "../../utils"; import App from "../App"; import { getClientColor } from "../../clients"; +import { SocketId } from "../../types"; // decay time in milliseconds const DECAY_TIME = 1000; @@ -88,7 +89,7 @@ type CollabolatorState = { export class LaserPathManager { private ownState: CollabolatorState; - private collaboratorsState: Map = new Map(); + private collaboratorsState: Map = new Map(); private rafId: number | undefined; private isDrawing = false; diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index 4b6aa8e44..2791d9ef4 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -14,51 +14,54 @@ import { t } from "../i18n"; import { isShallowEqual } from "../utils"; export type GoToCollaboratorComponentProps = [ - SocketId, + ClientId, Collaborator, boolean, boolean, ]; +/** collaborator user id or socket id (fallback) */ +type ClientId = string & { _brand: "UserId" }; + const FIRST_N_AVATARS = 3; const SHOW_COLLABORATORS_FILTER_AT = 8; const ConditionalTooltipWrapper = ({ shouldWrap, children, - socketId, + clientId, username, }: { shouldWrap: boolean; children: React.ReactNode; username?: string | null; - socketId: string; + clientId: ClientId; }) => shouldWrap ? ( - + {children} ) : ( - {children} + {children} ); const renderCollaborator = ({ actionManager, collaborator, - socketId, + clientId, withName = false, shouldWrapWithTooltip = false, isBeingFollowed, }: { actionManager: ActionManager; collaborator: Collaborator; - socketId: string; + clientId: ClientId; withName?: boolean; shouldWrapWithTooltip?: boolean; isBeingFollowed: boolean; }) => { const data: GoToCollaboratorComponentProps = [ - socketId, + clientId, collaborator, withName, isBeingFollowed, @@ -67,8 +70,8 @@ const renderCollaborator = ({ return ( @@ -100,12 +103,13 @@ export const UserList = React.memo( ({ className, mobile, collaborators, userToFollow }: UserListProps) => { const actionManager = useExcalidrawActionManager(); - const uniqueCollaboratorsMap = new Map(); + const uniqueCollaboratorsMap = new Map(); collaborators.forEach((collaborator, socketId) => { + const userId = (collaborator.id || socketId) as ClientId; uniqueCollaboratorsMap.set( // filter on user id, else fall back on unique socketId - collaborator.id || socketId, + userId, { ...collaborator, socketId }, ); }); @@ -134,25 +138,25 @@ export const UserList = React.memo( ); const firstNAvatarsJSX = firstNCollaborators.map( - ([socketId, collaborator]) => + ([clientId, collaborator]) => renderCollaborator({ actionManager, collaborator, - socketId, + clientId, shouldWrapWithTooltip: true, - isBeingFollowed: socketId === userToFollow, + isBeingFollowed: collaborator.socketId === userToFollow, }), ); return mobile ? (
- {uniqueCollaboratorsArray.map(([socketId, collaborator]) => + {uniqueCollaboratorsArray.map(([clientId, collaborator]) => renderCollaborator({ actionManager, collaborator, - socketId, + clientId, shouldWrapWithTooltip: true, - isBeingFollowed: socketId === userToFollow, + isBeingFollowed: collaborator.socketId === userToFollow, }), )}
@@ -205,13 +209,13 @@ export const UserList = React.memo(
{t("userList.hint.text")}
- {filteredCollaborators.map(([socketId, collaborator]) => + {filteredCollaborators.map(([clientId, collaborator]) => renderCollaborator({ actionManager, collaborator, - socketId, + clientId, withName: true, - isBeingFollowed: socketId === userToFollow, + isBeingFollowed: collaborator.socketId === userToFollow, }), )}
diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 249ead30d..854a27519 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -41,7 +41,7 @@ import { Merge, ValueOf } from "./utility-types"; export type Point = Readonly; -export type SocketId = string; +export type SocketId = string & { _brand: "SocketId" }; export type Collaborator = Readonly<{ pointer?: CollaboratorPointer; @@ -128,7 +128,7 @@ export type SidebarName = string; export type SidebarTabName = string; export type UserToFollow = { - socketId: string; + socketId: SocketId; username: string; }; @@ -296,7 +296,7 @@ export interface AppState { offsetLeft: number; fileHandle: FileSystemHandle | null; - collaborators: Map; + collaborators: Map; showStats: boolean; currentChartType: ChartType; pasteDialog: @@ -321,7 +321,7 @@ export interface AppState { /** the user's clientId & username who is being followed on the canvas */ userToFollow: UserToFollow | null; /** the clientIds of the users following the current user */ - followedBy: Set; + followedBy: Set; } export type UIAppState = Omit< @@ -474,7 +474,7 @@ export interface ExcalidrawProps { export type SceneData = { elements?: ImportedDataState["elements"]; appState?: ImportedDataState["appState"]; - collaborators?: Map; + collaborators?: Map; commitToHistory?: boolean; }; From d91c98b82ecd8211f071fdb2a356f8796ecc1306 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Dec 2023 21:14:30 +0100 Subject: [PATCH 012/112] fix: incorrect types in `ActionNavigate` (#7462) --- packages/excalidraw/actions/actionNavigate.tsx | 8 ++++---- packages/excalidraw/actions/types.ts | 2 +- packages/excalidraw/components/UserList.tsx | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 8e8d30231..4ce79b96f 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -39,15 +39,15 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ updateData, data, appState }) => { - const [socketId, collaborator, withName, isBeingFollowed] = + const { clientId, collaborator, withName, isBeingFollowed } = data as GoToCollaboratorComponentProps; - const background = getClientColor(socketId); + const background = getClientColor(clientId); return withName ? (
updateData({ ...collaborator, socketId })} + onClick={() => updateData(collaborator)} > { - updateData({ ...collaborator, socketId }); + updateData(collaborator); }} name={collaborator.username || ""} src={collaborator.avatarUrl} diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index c74e19552..118a5b233 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -129,7 +129,7 @@ export type ActionName = export type PanelComponentProps = { elements: readonly ExcalidrawElement[]; appState: AppState; - updateData: (formData?: any) => void; + updateData: (formData?: T) => void; appProps: ExcalidrawProps; data?: Record; app: AppClassProperties; diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index 2791d9ef4..ba01b52dc 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -13,12 +13,12 @@ import { searchIcon } from "./icons"; import { t } from "../i18n"; import { isShallowEqual } from "../utils"; -export type GoToCollaboratorComponentProps = [ - ClientId, - Collaborator, - boolean, - boolean, -]; +export type GoToCollaboratorComponentProps = { + clientId: ClientId; + collaborator: Collaborator; + withName: boolean; + isBeingFollowed: boolean; +}; /** collaborator user id or socket id (fallback) */ type ClientId = string & { _brand: "UserId" }; @@ -60,12 +60,12 @@ const renderCollaborator = ({ shouldWrapWithTooltip?: boolean; isBeingFollowed: boolean; }) => { - const data: GoToCollaboratorComponentProps = [ + const data: GoToCollaboratorComponentProps = { clientId, collaborator, withName, isBeingFollowed, - ]; + }; const avatarJSX = actionManager.renderAction("goToCollaborator", data); return ( From 5f40a4cad4951dca01fa445e050c9bba19d16980 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Tue, 19 Dec 2023 00:02:03 +0100 Subject: [PATCH 013/112] fix: missing cross-env from build:umd in package.json (#7460) --- packages/excalidraw/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 18af79be4..d11c349f0 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -114,7 +114,7 @@ "homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw", "scripts": { "gen:types": "tsc --project tsconfig-types.json", - "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && NODE_ENV=development webpack --config webpack.preact.config.js && NODE_ENV=production webpack --config webpack.preact.config.js && yarn gen:types", + "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && cross-env NODE_ENV=development webpack --config webpack.preact.config.js && cross-env NODE_ENV=production webpack --config webpack.preact.config.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", "start": "webpack serve --config webpack.dev-server.config.js", From c72e853c852a1dc3169e16567ba4fa51bd4c2291 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 30 Dec 2023 11:12:38 +0100 Subject: [PATCH 014/112] refactor: editor events sub/unsub refactor (#7483) --- packages/excalidraw/components/App.tsx | 204 +++++++++++-------------- packages/excalidraw/emitter.ts | 15 +- packages/excalidraw/global.d.ts | 13 -- packages/excalidraw/types.ts | 2 +- packages/excalidraw/utils.ts | 81 +++++++++- 5 files changed, 186 insertions(+), 129 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 71d6ea821..3163f7cea 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -269,6 +269,7 @@ import { isTestEnv, easeOut, updateStable, + addEventListener, } from "../utils"; import { createSrcDoc, @@ -559,6 +560,8 @@ class App extends React.Component { [scrollX: number, scrollY: number, zoom: AppState["zoom"]] >(); + onRemoveEventListenersEmitter = new Emitter<[]>(); + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); @@ -2390,63 +2393,6 @@ class App extends React.Component { this.setState({}); }); - private removeEventListeners() { - document.removeEventListener(EVENT.POINTER_UP, this.removePointer); - document.removeEventListener(EVENT.COPY, this.onCopy); - document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard); - document.removeEventListener(EVENT.CUT, this.onCut); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.WHEEL, - this.onWheel, - ); - this.nearestScrollableContainer?.removeEventListener( - EVENT.SCROLL, - this.onScroll, - ); - document.removeEventListener(EVENT.KEYDOWN, this.onKeyDown, false); - document.removeEventListener( - EVENT.MOUSE_MOVE, - this.updateCurrentCursorPosition, - false, - ); - document.removeEventListener(EVENT.KEYUP, this.onKeyUp); - window.removeEventListener(EVENT.RESIZE, this.onResize, false); - window.removeEventListener(EVENT.UNLOAD, this.onUnload, false); - window.removeEventListener(EVENT.BLUR, this.onBlur, false); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.DRAG_OVER, - this.disableEvent, - false, - ); - this.excalidrawContainerRef.current?.removeEventListener( - EVENT.DROP, - this.disableEvent, - false, - ); - - document.removeEventListener( - EVENT.GESTURE_START, - this.onGestureStart as any, - false, - ); - document.removeEventListener( - EVENT.GESTURE_CHANGE, - this.onGestureChange as any, - false, - ); - document.removeEventListener( - EVENT.GESTURE_END, - this.onGestureEnd as any, - false, - ); - document.removeEventListener( - EVENT.FULLSCREENCHANGE, - this.onFullscreenChange, - ); - - window.removeEventListener(EVENT.MESSAGE, this.onWindowMessage, false); - } - /** generally invoked only if fullscreen was invoked programmatically */ private onFullscreenChange = () => { if ( @@ -2460,76 +2406,108 @@ class App extends React.Component { } }; + private removeEventListeners() { + this.onRemoveEventListenersEmitter.trigger(); + } + private addEventListeners() { + // remove first as we can add event listeners multiple times this.removeEventListeners(); - window.addEventListener(EVENT.MESSAGE, this.onWindowMessage, false); - document.addEventListener(EVENT.POINTER_UP, this.removePointer); // #3553 - document.addEventListener(EVENT.COPY, this.onCopy); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.WHEEL, - this.onWheel, - { passive: false }, - ); + + // ------------------------------------------------------------------------- + // view+edit mode listeners + // ------------------------------------------------------------------------- if (this.props.handleKeyboardGlobally) { - document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); + this.onRemoveEventListenersEmitter.once( + addEventListener(document, EVENT.KEYDOWN, this.onKeyDown, false), + ); } - document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true }); - document.addEventListener( - EVENT.MOUSE_MOVE, - this.updateCurrentCursorPosition, - ); - // rerender text elements on font load to fix #637 && #1553 - document.fonts?.addEventListener?.("loadingdone", (event) => { - const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; - this.fonts.onFontsLoaded(loadedFontFaces); - }); - // Safari-only desktop pinch zoom - document.addEventListener( - EVENT.GESTURE_START, - this.onGestureStart as any, - false, - ); - document.addEventListener( - EVENT.GESTURE_CHANGE, - this.onGestureChange as any, - false, - ); - document.addEventListener( - EVENT.GESTURE_END, - this.onGestureEnd as any, - false, + this.onRemoveEventListenersEmitter.once( + addEventListener( + this.excalidrawContainerRef.current, + EVENT.WHEEL, + this.onWheel, + { passive: false }, + ), + addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false), + addEventListener(document, EVENT.POINTER_UP, this.removePointer), // #3553 + addEventListener(document, EVENT.COPY, this.onCopy), + addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }), + addEventListener( + document, + EVENT.MOUSE_MOVE, + this.updateCurrentCursorPosition, + ), + // rerender text elements on font load to fix #637 && #1553 + addEventListener(document.fonts, "loadingdone", (event) => { + const loadedFontFaces = (event as FontFaceSetLoadEvent).fontfaces; + this.fonts.onFontsLoaded(loadedFontFaces); + }), + // Safari-only desktop pinch zoom + addEventListener( + document, + EVENT.GESTURE_START, + this.onGestureStart as any, + false, + ), + addEventListener( + document, + EVENT.GESTURE_CHANGE, + this.onGestureChange as any, + false, + ), + addEventListener( + document, + EVENT.GESTURE_END, + this.onGestureEnd as any, + false, + ), ); + if (this.state.viewModeEnabled) { return; } - document.addEventListener(EVENT.FULLSCREENCHANGE, this.onFullscreenChange); - document.addEventListener(EVENT.PASTE, this.pasteFromClipboard); - document.addEventListener(EVENT.CUT, this.onCut); + // ------------------------------------------------------------------------- + // edit-mode listeners only + // ------------------------------------------------------------------------- + + this.onRemoveEventListenersEmitter.once( + addEventListener( + document, + EVENT.FULLSCREENCHANGE, + this.onFullscreenChange, + ), + addEventListener(document, EVENT.PASTE, this.pasteFromClipboard), + addEventListener(document, EVENT.CUT, this.onCut), + addEventListener(window, EVENT.RESIZE, this.onResize, false), + addEventListener(window, EVENT.UNLOAD, this.onUnload, false), + addEventListener(window, EVENT.BLUR, this.onBlur, false), + addEventListener( + this.excalidrawContainerRef.current, + EVENT.DRAG_OVER, + this.disableEvent, + false, + ), + addEventListener( + this.excalidrawContainerRef.current, + EVENT.DROP, + this.disableEvent, + false, + ), + ); + if (this.props.detectScroll) { - this.nearestScrollableContainer = getNearestScrollableContainer( - this.excalidrawContainerRef.current!, - ); - this.nearestScrollableContainer.addEventListener( - EVENT.SCROLL, - this.onScroll, + this.onRemoveEventListenersEmitter.once( + addEventListener( + getNearestScrollableContainer(this.excalidrawContainerRef.current!), + EVENT.SCROLL, + this.onScroll, + ), ); } - window.addEventListener(EVENT.RESIZE, this.onResize, false); - window.addEventListener(EVENT.UNLOAD, this.onUnload, false); - window.addEventListener(EVENT.BLUR, this.onBlur, false); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.DRAG_OVER, - this.disableEvent, - false, - ); - this.excalidrawContainerRef.current?.addEventListener( - EVENT.DROP, - this.disableEvent, - false, - ); } componentDidUpdate(prevProps: AppProps, prevState: AppState) { diff --git a/packages/excalidraw/emitter.ts b/packages/excalidraw/emitter.ts index 5b1cdd0a7..cb86670be 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/excalidraw/emitter.ts @@ -1,3 +1,5 @@ +import { UnsubscribeCallback } from "./types"; + type Subscriber = (...payload: T) => void; export class Emitter { @@ -15,7 +17,7 @@ export class Emitter { * * @returns unsubscribe function */ - on(...handlers: Subscriber[] | Subscriber[][]) { + on(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { const _handlers = handlers .flat() .filter((item) => typeof item === "function"); @@ -25,6 +27,17 @@ export class Emitter { return () => this.off(_handlers); } + once(...handlers: Subscriber[] | Subscriber[][]): UnsubscribeCallback { + const _handlers = handlers + .flat() + .filter((item) => typeof item === "function"); + + _handlers.push(() => detach()); + + const detach = this.on(..._handlers); + return detach; + } + off(...handlers: Subscriber[] | Subscriber[][]) { const _handlers = handlers.flat(); this.subscribers = this.subscribers.filter( diff --git a/packages/excalidraw/global.d.ts b/packages/excalidraw/global.d.ts index 76730c8de..49e5eac1c 100644 --- a/packages/excalidraw/global.d.ts +++ b/packages/excalidraw/global.d.ts @@ -1,16 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -interface Document { - fonts?: { - ready?: Promise; - check?: (font: string, text?: string) => boolean; - load?: (font: string, text?: string) => Promise; - addEventListener?( - type: "loading" | "loadingdone" | "loadingerror", - listener: (this: Document, ev: Event) => any, - ): void; - }; -} - interface Window { ClipboardItem: any; __EXCALIDRAW_SHA__: string | undefined; diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 854a27519..2ba9bd68d 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -641,7 +641,7 @@ export type PointerDownState = Readonly<{ }; }>; -type UnsubscribeCallback = () => void; +export type UnsubscribeCallback = () => void; export type ExcalidrawImperativeAPI = { updateScene: InstanceType["updateScene"]; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 4278f36f6..8b39ba6bd 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -7,7 +7,13 @@ import { WINDOWS_EMOJI_FALLBACK_FONT, } from "./constants"; import { FontFamilyValues, FontString } from "./element/types"; -import { ActiveTool, AppState, ToolType, Zoom } from "./types"; +import { + ActiveTool, + AppState, + ToolType, + UnsubscribeCallback, + Zoom, +} from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { ResolutionType } from "./utility-types"; import React from "react"; @@ -992,3 +998,76 @@ export const updateStable = >( } return nextValue; }; + +// Window +export function addEventListener( + target: Window & typeof globalThis, + type: K, + listener: (this: Window, ev: WindowEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +export function addEventListener( + target: Window & typeof globalThis, + type: string, + listener: (this: Window, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// Document +export function addEventListener( + target: Document, + type: K, + listener: (this: Document, ev: DocumentEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +export function addEventListener( + target: Document, + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// FontFaceSet (document.fonts) +export function addEventListener( + target: FontFaceSet, + type: K, + listener: (this: FontFaceSet, ev: FontFaceSetEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// HTMLElement / mix +export function addEventListener( + target: + | Document + | (Window & typeof globalThis) + | HTMLElement + | undefined + | null + | false, + type: K, + listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// implem +export function addEventListener( + /** + * allows for falsy values so you don't have to type check when adding + * event listeners to optional elements + */ + target: + | Document + | (Window & typeof globalThis) + | FontFaceSet + | HTMLElement + | undefined + | null + | false, + type: keyof WindowEventMap | keyof DocumentEventMap | string, + listener: (ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback { + if (!target) { + return () => {}; + } + target?.addEventListener?.(type, listener, options); + return () => { + target?.removeEventListener?.(type, listener, options); + }; +} From d19b51d4f8eee8a661b2f9df95f3620f23fe4b93 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 30 Dec 2023 15:00:12 +0100 Subject: [PATCH 015/112] fix: drawing-tablet stylus touch events being prevented (#7494) --- packages/excalidraw/components/App.tsx | 10 +++------- packages/excalidraw/constants.ts | 4 ++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3163f7cea..9ff151675 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -67,7 +67,6 @@ import { GRID_SIZE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, - isAndroid, isBrave, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, @@ -90,6 +89,7 @@ import { POINTER_EVENTS, TOOL_TYPE, EDITOR_LS_KEYS, + isIOS, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -2756,9 +2756,8 @@ class App extends React.Component { } private onTouchStart = (event: TouchEvent) => { - // fix for Apple Pencil Scribble - // On Android, preventing the event would disable contextMenu on tap-hold - if (!isAndroid) { + // fix for Apple Pencil Scribble (do not prevent for other devices) + if (isIOS) { event.preventDefault(); } @@ -2783,9 +2782,6 @@ class App extends React.Component { didTapTwice = false; clearTimeout(tappedTwiceTimer); } - if (isAndroid) { - event.preventDefault(); - } if (event.touches.length === 2) { this.setState({ diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 5594f356e..ff1fdabb7 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -13,6 +13,10 @@ export const isFirefox = export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1; export const isSafari = !isChrome && navigator.userAgent.indexOf("Safari") !== -1; +export const isIOS = + /iPad|iPhone/.test(navigator.platform) || + // iPadOS 13+ + (navigator.userAgent.includes("Mac") && "ontouchend" in document); // keeping function so it can be mocked in test export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; From e6c3c06c2eb552aed9241aa1e1fb72052e2caf35 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 1 Jan 2024 13:27:03 +0100 Subject: [PATCH 016/112] feat: support pen erasing (#7496) --- packages/excalidraw/components/App.tsx | 114 ++++++-- packages/excalidraw/constants.ts | 1 + packages/excalidraw/emitter.ts | 19 +- .../__snapshots__/contextmenu.test.tsx.snap | 264 +++++++++--------- .../regressionTests.test.tsx.snap | 26 +- 5 files changed, 240 insertions(+), 184 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9ff151675..e1f480fa8 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -245,6 +245,7 @@ import { CollaboratorPointer, ToolType, OnUserFollowedPayload, + UnsubscribeCallback, } from "../types"; import { debounce, @@ -488,7 +489,7 @@ let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; -let lastPointerUp: ((event: any) => void) | null = null; +let lastPointerUp: (() => void) | null = null; const gesture: Gesture = { pointers: new Map(), lastCenter: null, @@ -528,6 +529,7 @@ class App extends React.Component { lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + lastPointerMoveEvent: PointerEvent | null = null; lastViewportPosition = { x: 0, y: 0 }; laserPathManager: LaserPathManager = new LaserPathManager(this); @@ -560,6 +562,9 @@ class App extends React.Component { [scrollX: number, scrollY: number, zoom: AppState["zoom"]] >(); + missingPointerEventCleanupEmitter = new Emitter< + [event: PointerEvent | null] + >(); onRemoveEventListenersEmitter = new Emitter<[]>(); constructor(props: AppProps) { @@ -2372,7 +2377,7 @@ class App extends React.Component { this.scene.destroy(); this.library.destroy(); this.laserPathManager.destroy(); - this.onChangeEmitter.destroy(); + this.onChangeEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2464,6 +2469,9 @@ class App extends React.Component { this.onGestureEnd as any, false, ), + addEventListener(window, EVENT.FOCUS, () => { + this.maybeCleanupAfterMissingPointerUp(null); + }), ); if (this.state.viewModeEnabled) { @@ -4616,6 +4624,7 @@ class App extends React.Component { event: React.PointerEvent, ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); + this.lastPointerMoveEvent = event.nativeEvent; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -5203,6 +5212,7 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeUnfollowRemoteUser(); // since contextMenu options are potentially evaluated on each render, @@ -5265,7 +5275,6 @@ class App extends React.Component { selection.removeAllRanges(); } this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event); - this.maybeCleanupAfterMissingPointerUp(event); //fires only once, if pen is detected, penMode is enabled //the user can disable this by toggling the penMode button @@ -5304,10 +5313,60 @@ class App extends React.Component { }); this.savePointer(event.clientX, event.clientY, "down"); + if ( + event.button === POINTER_BUTTON.ERASER && + this.state.activeTool.type !== TOOL_TYPE.eraser + ) { + this.setState( + { + activeTool: updateActiveTool(this.state, { + type: TOOL_TYPE.eraser, + lastActiveToolBeforeEraser: this.state.activeTool, + }), + }, + () => { + this.handleCanvasPointerDown(event); + const onPointerUp = () => { + unsubPointerUp(); + unsubCleanup?.(); + if (isEraserActive(this.state)) { + this.setState({ + activeTool: updateActiveTool(this.state, { + ...(this.state.activeTool.lastActiveTool || { + type: TOOL_TYPE.selection, + }), + lastActiveToolBeforeEraser: null, + }), + }); + } + }; + + const unsubPointerUp = addEventListener( + window, + EVENT.POINTER_UP, + onPointerUp, + { + once: true, + }, + ); + let unsubCleanup: UnsubscribeCallback | undefined; + // subscribe inside rAF lest it'd be triggered on the same pointerdown + // if we start erasing while coming from blurred document since + // we cleanup pointer events on focus + requestAnimationFrame(() => { + unsubCleanup = + this.missingPointerEventCleanupEmitter.once(onPointerUp); + }); + }, + ); + return; + } + // only handle left mouse button or touch if ( event.button !== POINTER_BUTTON.MAIN && - event.button !== POINTER_BUTTON.TOUCH + event.button !== POINTER_BUTTON.TOUCH && + event.button !== POINTER_BUTTON.ERASER ) { return; } @@ -5435,7 +5494,9 @@ class App extends React.Component { const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState); const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState); - lastPointerUp = onPointerUp; + this.missingPointerEventCleanupEmitter.once((_event) => + onPointerUp(_event || event.nativeEvent), + ); if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") { window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); @@ -5546,16 +5607,15 @@ class App extends React.Component { invalidateContextMenu = false; }; - private maybeCleanupAfterMissingPointerUp( - event: React.PointerEvent, - ): void { - if (lastPointerUp !== null) { - // Unfortunately, sometimes we don't get a pointerup after a pointerdown, - // this can happen when a contextual menu or alert is triggered. In order to avoid - // being in a weird state, we clean up on the next pointerdown - lastPointerUp(event); - } - } + /** + * pointerup may not fire in certian cases (user tabs away...), so in order + * to properly cleanup pointerdown state, we need to fire any hanging + * pointerup handlers manually + */ + private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => { + lastPointerUp?.(); + this.missingPointerEventCleanupEmitter.trigger(event).clear(); + }; // Returns whether the event is a panning private handleCanvasPanUsingWheelOrSpaceDrag = ( @@ -5758,11 +5818,10 @@ class App extends React.Component { this.handlePointerMoveOverScrollbars(event, pointerDownState); }); - const onPointerUp = withBatchedUpdates(() => { + lastPointerUp = null; isDraggingScrollBar = false; setCursorForShape(this.interactiveCanvas, this.state); - lastPointerUp = null; this.setState({ cursorButton: "up", }); @@ -7208,6 +7267,7 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + this.removePointer(childEvent); if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); } @@ -7310,7 +7370,7 @@ class App extends React.Component { } } - lastPointerUp = null; + this.missingPointerEventCleanupEmitter.clear(); window.removeEventListener( EVENT.POINTER_MOVE, @@ -7693,19 +7753,23 @@ class App extends React.Component { }); } } - if (isEraserActive(this.state)) { + + const pointerStart = this.lastPointerDownEvent; + const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; + + if (isEraserActive(this.state) && pointerStart && pointerEnd) { const draggedDistance = distance2d( - this.lastPointerDownEvent!.clientX, - this.lastPointerDownEvent!.clientY, - this.lastPointerUpEvent!.clientX, - this.lastPointerUpEvent!.clientY, + pointerStart.clientX, + pointerStart.clientY, + pointerEnd.clientX, + pointerEnd.clientY, ); if (draggedDistance === 0) { const scenePointer = viewportCoordsToSceneCoords( { - clientX: this.lastPointerUpEvent!.clientX, - clientY: this.lastPointerUpEvent!.clientY, + clientX: pointerEnd.clientX, + clientY: pointerEnd.clientY, }, this.state, ); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index ff1fdabb7..72286e698 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -43,6 +43,7 @@ export const POINTER_BUTTON = { WHEEL: 1, SECONDARY: 2, TOUCH: -1, + ERASER: 5, } as const; export const POINTER_EVENTS = { diff --git a/packages/excalidraw/emitter.ts b/packages/excalidraw/emitter.ts index cb86670be..98e97ad46 100644 --- a/packages/excalidraw/emitter.ts +++ b/packages/excalidraw/emitter.ts @@ -4,13 +4,6 @@ type Subscriber = (...payload: T) => void; export class Emitter { public subscribers: Subscriber[] = []; - public value: T | undefined; - private updateOnChangeOnly: boolean; - - constructor(opts?: { initialState?: T; updateOnChangeOnly?: boolean }) { - this.updateOnChangeOnly = opts?.updateOnChangeOnly ?? false; - this.value = opts?.initialState; - } /** * Attaches subscriber @@ -45,16 +38,14 @@ export class Emitter { ); } - trigger(...payload: T): any[] { - if (this.updateOnChangeOnly && this.value === payload) { - return []; + trigger(...payload: T) { + for (const handler of this.subscribers) { + handler(...payload); } - this.value = payload; - return this.subscribers.map((handler) => handler(...payload)); + return this; } - destroy() { + clear() { this.subscribers = []; - this.value = undefined; } } diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index d672a2542..b14000c2c 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -604,7 +604,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -663,7 +663,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -799,14 +799,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -838,7 +838,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": -10, "y": 0, @@ -897,7 +897,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -940,7 +940,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -962,14 +962,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -1005,14 +1005,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -1041,7 +1041,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": -10, "y": 0, @@ -1177,14 +1177,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -1216,7 +1216,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": -10, "y": 0, @@ -1275,7 +1275,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -1318,7 +1318,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -1340,14 +1340,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -1383,14 +1383,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -1419,7 +1419,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": -10, "y": 0, @@ -1557,7 +1557,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -1616,7 +1616,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -1764,7 +1764,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1150084233, + "versionNonce": 1116226695, "width": 20, "x": -10, "y": 0, @@ -1823,7 +1823,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -1864,7 +1864,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1150084233, + "versionNonce": 1116226695, "width": 20, "x": -10, "y": 0, @@ -2007,7 +2007,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2032,14 +2032,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": 0, "y": 10, @@ -2098,7 +2098,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2141,7 +2141,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2163,14 +2163,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": 0, "y": 10, @@ -2320,7 +2320,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1604849351, + "versionNonce": 1505387817, "width": 20, "x": -10, "y": 0, @@ -2347,14 +2347,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 20, "x": 20, "y": 30, @@ -2413,7 +2413,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2456,7 +2456,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2478,14 +2478,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -2533,7 +2533,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1604849351, + "versionNonce": 1505387817, "width": 20, "x": -10, "y": 0, @@ -2557,14 +2557,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 20, "x": 20, "y": 30, @@ -2709,7 +2709,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1898319239, + "versionNonce": 640725609, "width": 20, "x": -10, "y": 0, @@ -2734,14 +2734,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1723083209, + "seed": 760410951, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 289600103, + "versionNonce": 1315507081, "width": 20, "x": 20, "y": 30, @@ -2800,7 +2800,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2843,7 +2843,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2865,14 +2865,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -2915,7 +2915,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -2937,14 +2937,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -2987,7 +2987,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3009,14 +3009,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 20, "x": 20, "y": 30, @@ -3059,7 +3059,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3081,14 +3081,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#e03131", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 493213705, + "versionNonce": 915032327, "width": 20, "x": 20, "y": 30, @@ -3131,7 +3131,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3153,14 +3153,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 6, - "versionNonce": 81784553, + "versionNonce": 747212839, "width": 20, "x": 20, "y": 30, @@ -3203,7 +3203,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3225,14 +3225,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1723083209, + "seed": 760410951, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 7, - "versionNonce": 760410951, + "versionNonce": 1006504105, "width": 20, "x": 20, "y": 30, @@ -3275,7 +3275,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3297,14 +3297,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1723083209, + "seed": 760410951, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 289600103, + "versionNonce": 1315507081, "width": 20, "x": 20, "y": 30, @@ -3347,7 +3347,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1898319239, + "versionNonce": 640725609, "width": 20, "x": -10, "y": 0, @@ -3369,14 +3369,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "roundness": { "type": 3, }, - "seed": 1723083209, + "seed": 760410951, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 8, - "versionNonce": 289600103, + "versionNonce": 1315507081, "width": 20, "x": 20, "y": 30, @@ -3512,14 +3512,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -3551,7 +3551,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3610,7 +3610,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3653,7 +3653,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3675,14 +3675,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -3718,14 +3718,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -3754,7 +3754,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3890,14 +3890,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -3929,7 +3929,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3988,7 +3988,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -4031,7 +4031,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -4053,14 +4053,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 238820263, "width": 20, "x": 20, "y": 30, @@ -4096,14 +4096,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 1116226695, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 400692809, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -4132,7 +4132,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -4271,14 +4271,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 915032327, + "versionNonce": 81784553, "width": 20, "x": -10, "y": 0, @@ -4303,14 +4303,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 81784553, + "versionNonce": 747212839, "width": 20, "x": 20, "y": 30, @@ -4362,7 +4362,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -4405,7 +4405,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -4434,14 +4434,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 238820263, + "versionNonce": 400692809, "width": 20, "x": 20, "y": 30, @@ -4482,14 +4482,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 20, "x": -10, "y": 0, @@ -4513,14 +4513,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 23633383, + "versionNonce": 493213705, "width": 20, "x": 20, "y": 30, @@ -4557,14 +4557,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 915032327, + "versionNonce": 81784553, "width": 20, "x": -10, "y": 0, @@ -4586,14 +4586,14 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 81784553, + "versionNonce": 747212839, "width": 20, "x": 20, "y": 30, @@ -5005,14 +5005,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 453191, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 1116226695, "width": 10, "x": -10, "y": 0, @@ -5037,14 +5037,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 238820263, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 1604849351, "width": 10, "x": 10, "y": 0, @@ -5096,14 +5096,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 453191, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 1116226695, "width": 10, "x": -10, "y": 0, @@ -5139,14 +5139,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 449462985, + "seed": 453191, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 1116226695, "width": 10, "x": -10, "y": 0, @@ -5168,14 +5168,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "roundness": { "type": 3, }, - "seed": 1150084233, + "seed": 238820263, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 1014066025, + "versionNonce": 1604849351, "width": 10, "x": 10, "y": 0, @@ -5591,14 +5591,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 23633383, + "versionNonce": 493213705, "width": 10, "x": -10, "y": 0, @@ -5625,14 +5625,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 493213705, + "versionNonce": 915032327, "width": 10, "x": 10, "y": 0, @@ -5684,7 +5684,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -5727,7 +5727,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -5756,14 +5756,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 238820263, + "versionNonce": 400692809, "width": 10, "x": 10, "y": 0, @@ -5804,14 +5804,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 453191, + "seed": 449462985, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 23633383, + "versionNonce": 493213705, "width": 10, "x": -10, "y": 0, @@ -5835,14 +5835,14 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "roundness": { "type": 3, }, - "seed": 1116226695, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 493213705, + "versionNonce": 915032327, "width": 10, "x": 10, "y": 0, @@ -6892,7 +6892,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -7015,7 +7015,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] hi "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 452f9242c..65fa16899 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -1908,7 +1908,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "type": "ellipse", "updated": 1, "version": 2, - "versionNonce": 453191, + "versionNonce": 401146281, "width": 10, "x": 0, "y": 0, @@ -1951,7 +1951,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "type": "ellipse", "updated": 1, "version": 3, - "versionNonce": 1150084233, + "versionNonce": 1116226695, "width": 10, "x": 25, "y": 25, @@ -16160,7 +16160,7 @@ exports[`regression tests > switches from group of selected elements to another "roundness": { "type": 2, }, - "seed": 493213705, + "seed": 915032327, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -16246,7 +16246,7 @@ exports[`regression tests > switches from group of selected elements to another "roundness": { "type": 2, }, - "seed": 493213705, + "seed": 915032327, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -16330,7 +16330,7 @@ exports[`regression tests > switches from group of selected elements to another "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 453191, + "versionNonce": 401146281, "width": 10, "x": 0, "y": 0, @@ -16373,7 +16373,7 @@ exports[`regression tests > switches from group of selected elements to another "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 453191, + "versionNonce": 401146281, "width": 10, "x": 0, "y": 0, @@ -16395,14 +16395,14 @@ exports[`regression tests > switches from group of selected elements to another "roundness": { "type": 2, }, - "seed": 2019559783, + "seed": 1150084233, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "ellipse", "updated": 1, "version": 2, - "versionNonce": 1116226695, + "versionNonce": 1014066025, "width": 100, "x": 110, "y": 110, @@ -16445,7 +16445,7 @@ exports[`regression tests > switches from group of selected elements to another "type": "rectangle", "updated": 1, "version": 2, - "versionNonce": 453191, + "versionNonce": 401146281, "width": 10, "x": 0, "y": 0, @@ -16467,14 +16467,14 @@ exports[`regression tests > switches from group of selected elements to another "roundness": { "type": 2, }, - "seed": 2019559783, + "seed": 1150084233, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "ellipse", "updated": 1, "version": 2, - "versionNonce": 1116226695, + "versionNonce": 1014066025, "width": 100, "x": 110, "y": 110, @@ -16496,14 +16496,14 @@ exports[`regression tests > switches from group of selected elements to another "roundness": { "type": 2, }, - "seed": 238820263, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "diamond", "updated": 1, "version": 2, - "versionNonce": 1604849351, + "versionNonce": 1505387817, "width": 100, "x": 310, "y": 310, From a8064ba3eef10fa50bf5d9f402b779d54558ae76 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 1 Jan 2024 20:18:44 +0530 Subject: [PATCH 017/112] build: Welcome ESM and Bye Bye UMD (#7441) * build: Welcome ESM and Bye Bye UMD * remove package * create unbundled esm build * update script for example * fix typo * dummy commit * update autorelease script to build esm * revert dummy commit * move react, react-dom and testing library to dev dependencies * remove entry.js, publicPath and yarn install:deps script * fix * upgrade esbuild to fix glob import error for locales * remove webpack chunk names as thats not needed anymore * marking the code sideeffects free * make the library tree-shakeable and move fonts to fonts directory * allow side effects for css, scss files * remove tree-shaking * comment code for tree shaking * move to vite for example * bye bye webpack * ignore ts * separate build and output dir * use esbuild for creating bundle for example * update output dir * lint * create browser dev build with source maps and prod with minification * add dev and prod builds for bundler * lint * update script * remove await * load prod build * create minified build in dist * prod and dev builds using export field * remove import.meta * dummy * define import.meta prod and dev * fix * export types * add types field * typo * lint * Update scripts/buildPackage.js * move types inside export * newline --- .github/workflows/lint.yml | 2 +- .github/workflows/size-limit.yml | 2 +- .github/workflows/test-coverage-pr.yml | 2 +- .github/workflows/test.yml | 2 +- .gitignore | 3 - excalidraw-app/collab/RoomDialog.scss | 2 +- excalidraw-app/index.html | 2 +- excalidraw-app/package.json | 4 +- package.json | 1 - packages/excalidraw/.gitignore | 4 +- packages/excalidraw/components/App.tsx | 1 - packages/excalidraw/components/Avatar.scss | 2 +- packages/excalidraw/components/Card.scss | 2 +- .../excalidraw/components/CheckboxItem.scss | 2 +- .../components/ColorPicker/ColorPicker.scss | 2 +- .../excalidraw/components/ConfirmDialog.scss | 2 +- .../excalidraw/components/ContextMenu.scss | 2 +- packages/excalidraw/components/Dialog.scss | 2 +- .../excalidraw/components/ExportDialog.scss | 2 +- .../excalidraw/components/FilledButton.scss | 2 +- .../components/FixedSideContainer.scss | 2 +- .../excalidraw/components/HelpDialog.scss | 2 +- .../excalidraw/components/HintViewer.scss | 2 +- .../excalidraw/components/IconPicker.scss | 2 +- .../components/ImageExportDialog.scss | 2 +- packages/excalidraw/components/LayerUI.scss | 2 +- .../excalidraw/components/LibraryUnit.scss | 2 +- packages/excalidraw/components/Modal.scss | 2 +- .../OverwriteConfirm/OverwriteConfirm.scss | 2 +- .../components/PasteChartDialog.scss | 2 +- .../excalidraw/components/PublishLibrary.scss | 2 +- .../excalidraw/components/RadioGroup.scss | 2 +- .../components/ShareableLinkDialog.scss | 2 +- .../components/Sidebar/Sidebar.scss | 2 +- .../components/Sidebar/SidebarTrigger.scss | 2 +- packages/excalidraw/components/Stats.scss | 2 +- packages/excalidraw/components/Switch.scss | 2 +- .../components/TTDDialog/TTDDialog.scss | 2 +- .../components/TTDDialog/TTDDialog.tsx | 4 +- packages/excalidraw/components/TextField.scss | 2 +- packages/excalidraw/components/TextInput.scss | 2 +- packages/excalidraw/components/Toast.scss | 2 +- packages/excalidraw/components/ToolIcon.scss | 2 +- packages/excalidraw/components/Toolbar.scss | 2 +- packages/excalidraw/components/Tooltip.scss | 2 +- .../components/dropdownMenu/DropdownMenu.scss | 2 +- .../LiveCollaborationTrigger.scss | 2 +- packages/excalidraw/css/styles.scss | 2 +- packages/excalidraw/data/blob.ts | 6 +- packages/excalidraw/data/index.ts | 2 +- packages/excalidraw/element/Hyperlink.scss | 2 +- packages/excalidraw/entry.js | 7 - packages/excalidraw/{env.js => env.cjs} | 0 packages/excalidraw/example/App.tsx | 9 +- packages/excalidraw/example/CustomFooter.tsx | 5 +- packages/excalidraw/example/MobileFooter.tsx | 2 +- packages/excalidraw/example/index.tsx | 7 +- packages/excalidraw/example/initialData.tsx | 4 +- packages/excalidraw/example/public/index.html | 11 +- .../example/sidebar/ExampleSidebar.tsx | 6 +- packages/excalidraw/i18n.ts | 4 +- packages/excalidraw/index.tsx | 4 + packages/excalidraw/main.js | 12 +- packages/excalidraw/package.json | 50 +- packages/excalidraw/publicPath.js | 8 - packages/excalidraw/scene/export.ts | 2 +- packages/excalidraw/tests/flip.test.tsx | 1 - packages/excalidraw/tests/shortcuts.test.tsx | 2 +- packages/excalidraw/tsconfig.json | 15 + packages/excalidraw/vite.config.mts | 15 + .../excalidraw/webpack.dev-server.config.js | 28 - packages/excalidraw/webpack.dev.config.js | 108 -- packages/excalidraw/webpack.preact.config.js | 32 - packages/excalidraw/webpack.prod.config.js | 131 -- public/{ => fonts}/Assistant-Bold.woff2 | Bin public/{ => fonts}/Assistant-Medium.woff2 | Bin public/{ => fonts}/Assistant-Regular.woff2 | Bin public/{ => fonts}/Assistant-SemiBold.woff2 | Bin public/{ => fonts}/Cascadia.ttf | Bin public/{ => fonts}/Cascadia.woff2 | Bin public/{ => fonts}/FG_Virgil.ttf | Bin public/{ => fonts}/FG_Virgil.woff2 | Bin public/{ => fonts}/Virgil.woff2 | Bin public/{ => fonts}/fonts.css | 0 scripts/autorelease.js | 8 +- scripts/buildExample.mjs | 35 + scripts/buildPackage.js | 135 ++ yarn.lock | 1094 +++-------------- 88 files changed, 511 insertions(+), 1335 deletions(-) delete mode 100644 packages/excalidraw/entry.js rename packages/excalidraw/{env.js => env.cjs} (100%) delete mode 100644 packages/excalidraw/publicPath.js create mode 100644 packages/excalidraw/tsconfig.json create mode 100644 packages/excalidraw/vite.config.mts delete mode 100644 packages/excalidraw/webpack.dev-server.config.js delete mode 100644 packages/excalidraw/webpack.dev.config.js delete mode 100644 packages/excalidraw/webpack.preact.config.js delete mode 100644 packages/excalidraw/webpack.prod.config.js rename public/{ => fonts}/Assistant-Bold.woff2 (100%) rename public/{ => fonts}/Assistant-Medium.woff2 (100%) rename public/{ => fonts}/Assistant-Regular.woff2 (100%) rename public/{ => fonts}/Assistant-SemiBold.woff2 (100%) rename public/{ => fonts}/Cascadia.ttf (100%) rename public/{ => fonts}/Cascadia.woff2 (100%) rename public/{ => fonts}/FG_Virgil.ttf (100%) rename public/{ => fonts}/FG_Virgil.woff2 (100%) rename public/{ => fonts}/Virgil.woff2 (100%) rename public/{ => fonts}/fonts.css (100%) create mode 100644 scripts/buildExample.mjs create mode 100644 scripts/buildPackage.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f922f5e75..82f826361 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - name: Install and lint run: | - yarn install:deps + yarn install yarn test:other yarn test:code yarn test:typecheck diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 02aade54e..5bd3c0d92 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -23,6 +23,6 @@ jobs: - uses: andresz1/size-limit-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - build_script: build:umd + build_script: build:esm skip_step: install directory: packages/excalidraw diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 7d77d39f5..7ff40ad5d 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -16,7 +16,7 @@ jobs: with: node-version: "18.x" - name: "Install Deps" - run: yarn install:deps + run: yarn install - name: "Test Coverage" run: yarn test:coverage - name: "Report Coverage" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 124cae26e..2c458a810 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,5 +13,5 @@ jobs: node-version: 18.x - name: Install and test run: | - yarn install:deps + yarn install yarn test:app diff --git a/.gitignore b/.gitignore index d670c78ab..17e3e7dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,6 @@ package-lock.json yarn-debug.log* yarn-error.log* packages/excalidraw/types -packages/excalidraw/example/public/bundle.js -packages/excalidraw/example/public/excalidraw-assets-dev -packages/excalidraw/example/public/excalidraw.development.js coverage dev-dist html diff --git a/excalidraw-app/collab/RoomDialog.scss b/excalidraw-app/collab/RoomDialog.scss index 93885e647..61624664b 100644 --- a/excalidraw-app/collab/RoomDialog.scss +++ b/excalidraw-app/collab/RoomDialog.scss @@ -1,4 +1,4 @@ -@import "../../packages/excalidraw/css/variables.module"; +@import "../../packages/excalidraw/css/variables.module.scss"; .excalidraw { .RoomDialog { diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index c11d9ab68..66f3afdab 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -121,7 +121,7 @@ crossorigin="anonymous" /> - + <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %> + + @@ -19,11 +21,12 @@
- - - - + + diff --git a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx b/packages/excalidraw/example/sidebar/ExampleSidebar.tsx index 4c51ecdc2..a6e1b6475 100644 --- a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx +++ b/packages/excalidraw/example/sidebar/ExampleSidebar.tsx @@ -1,7 +1,9 @@ -import React, { useState } from "react"; import "./ExampleSidebar.scss"; + +const React = window.React; + export default function Sidebar({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false); + const [open, setOpen] = React.useState(false); return ( <> diff --git a/packages/excalidraw/i18n.ts b/packages/excalidraw/i18n.ts index 6536b2c6d..a014b33b8 100644 --- a/packages/excalidraw/i18n.ts +++ b/packages/excalidraw/i18n.ts @@ -96,9 +96,7 @@ export const setLanguage = async (lang: Language) => { currentLangData = {}; } else { try { - currentLangData = await import( - /* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json` - ); + currentLangData = await import(`./locales/${currentLang.code}.json`); } catch (error: any) { console.error(`Failed to load language ${lang.code}:`, error.message); currentLangData = fallbackLangData; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 915836cb8..6524873a2 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -5,6 +5,8 @@ import { isShallowEqual } from "./utils"; import "./css/app.scss"; import "./css/styles.scss"; +import "../../public/fonts/fonts.css"; +import polyfill from "./polyfill"; import { AppProps, ExcalidrawProps } from "./types"; import { defaultLang } from "./i18n"; @@ -16,6 +18,8 @@ import MainMenu from "./components/main-menu/MainMenu"; import WelcomeScreen from "./components/welcome-screen/WelcomeScreen"; import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger"; +polyfill(); + const ExcalidrawBase = (props: ExcalidrawProps) => { const { onChange, diff --git a/packages/excalidraw/main.js b/packages/excalidraw/main.js index 853bb70f8..56e511b25 100644 --- a/packages/excalidraw/main.js +++ b/packages/excalidraw/main.js @@ -1,11 +1,5 @@ -if (process.env.IS_PREACT === "true") { - if (process.env.NODE_ENV === "production") { - module.exports = require("./dist/excalidraw-with-preact.production.min.js"); - } else { - module.exports = require("./dist/excalidraw-with-preact.development.js"); - } -} else if (process.env.NODE_ENV === "production") { - module.exports = require("./dist/excalidraw.production.min.js"); +if (process.env.NODE_ENV !== "development") { + import("./dist/dev/index.js"); } else { - module.exports = require("./dist/excalidraw.development.js"); + import("./dist/prod/index.js"); } diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index d11c349f0..1cd837fdd 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -1,11 +1,23 @@ { "name": "@excalidraw/excalidraw", "version": "0.17.1", - "main": "main.js", - "types": "types/excalidraw/index.d.ts", + "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/excalidraw/index.d.ts" + }, + "./index.css": { + "development": "./dist/dev/index.css", + "default": "./dist/prod/index.css" + } + }, + "types": "./dist/excalidraw/index.d.ts", "files": [ - "dist/*", - "types/*" + "dist/*" ], "publishConfig": { "access": "public" @@ -50,15 +62,11 @@ "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", - "@testing-library/jest-dom": "5.16.2", - "@testing-library/react": "12.1.5", "@tldraw/vec": "1.7.1", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", "cross-env": "7.0.3", - "eslint-plugin-react": "7.32.2", - "fake-indexeddb": "3.1.7", "image-blob-reduce": "3.0.1", "jotai": "1.13.1", "lodash.throttle": "4.1.1", @@ -95,30 +103,32 @@ "cross-env": "7.0.3", "css-loader": "6.7.1", "dotenv": "16.0.1", + "esbuild": "0.19.10", + "esbuild-plugin-external-global": "1.0.1", + "esbuild-sass-plugin": "2.16.0", + "eslint-plugin-react": "7.32.2", + "fake-indexeddb": "3.1.7", "import-meta-loader": "1.1.0", "mini-css-extract-plugin": "2.6.1", "postcss-loader": "7.0.1", + "react": "18.2.0", + "react-dom": "18.2.0", "sass-loader": "13.0.2", "size-limit": "9.0.0", "style-loader": "3.3.3", - "terser-webpack-plugin": "5.3.3", + "@testing-library/jest-dom": "5.16.2", + "@testing-library/react": "12.1.5", "ts-loader": "9.3.1", - "typescript": "4.9.4", - "webpack": "5.76.0", - "webpack-bundle-analyzer": "4.5.0", - "webpack-cli": "4.10.0", - "webpack-dev-server": "4.9.3", - "webpack-merge": "5.8.0" + "typescript": "4.9.4" }, "bugs": "https://github.com/excalidraw/excalidraw/issues", "homepage": "https://github.com/excalidraw/excalidraw/tree/master/packages/excalidraw", "scripts": { - "gen:types": "tsc --project tsconfig-types.json", - "build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && cross-env NODE_ENV=development webpack --config webpack.preact.config.js && cross-env NODE_ENV=production webpack --config webpack.preact.config.js && yarn gen:types", - "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", + "gen:types": "rm -rf types && tsc", + "build:esm": "rm -rf dist && node ../../scripts/buildPackage.js && yarn gen:types", "pack": "yarn build:umd && yarn pack", - "start": "webpack serve --config webpack.dev-server.config.js", - "build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types", + "start": "node ../../scripts/buildExample.mjs && vite", + "build:example": "node ../../scripts/buildExample.mjs", "size": "yarn build:umd && size-limit" } } diff --git a/packages/excalidraw/publicPath.js b/packages/excalidraw/publicPath.js deleted file mode 100644 index 3eb6bd272..000000000 --- a/packages/excalidraw/publicPath.js +++ /dev/null @@ -1,8 +0,0 @@ -import { ENV } from "./constants"; -if (process.env.NODE_ENV !== ENV.TEST) { - /* eslint-disable */ - /* global __webpack_public_path__:writable */ - __webpack_public_path__ = - window.EXCALIDRAW_ASSET_PATH || - `https://unpkg.com/${process.env.VITE_PKG_NAME}@${process.env.VITE_PKG_VERSION}/dist/`; -} diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index bb194e1cb..9bfab7e77 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -327,7 +327,7 @@ export const exportToSvg = async ( if (exportEmbedScene) { try { metadata = await ( - await import(/* webpackChunkName: "image" */ "../data/image") + await import("../data/image") ).encodeSvgMetadata({ // when embedding scene, we want to embed the origionally supplied // elements which don't contain the temp frame labels. diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 68dce2c4a..875e87752 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -198,7 +198,6 @@ const checkElementsBoundingBox = async ( const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2); - debugger; await waitFor(() => { // Check if width and height did not change expect(x2 - x1).toBeCloseTo(x22 - x12, -1); diff --git a/packages/excalidraw/tests/shortcuts.test.tsx b/packages/excalidraw/tests/shortcuts.test.tsx index 52fa6c2bf..4da160fee 100644 --- a/packages/excalidraw/tests/shortcuts.test.tsx +++ b/packages/excalidraw/tests/shortcuts.test.tsx @@ -1,5 +1,5 @@ import { KEYS } from "../keys"; -import { Excalidraw } from "../entry"; +import { Excalidraw } from "../index"; import { API } from "./helpers/api"; import { Keyboard } from "./helpers/ui"; import { fireEvent, render, waitFor } from "./test-utils"; diff --git a/packages/excalidraw/tsconfig.json b/packages/excalidraw/tsconfig.json new file mode 100644 index 000000000..28e276c35 --- /dev/null +++ b/packages/excalidraw/tsconfig.json @@ -0,0 +1,15 @@ +{ + "exclude": ["**/*.test.*", "tests", "types", "example", "dist"], + "compilerOptions": { + "target": "ESNext", + "strict": true, + "outDir": "dist", + "skipLibCheck": true, + "declaration": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "jsx": "react-jsx" + } +} diff --git a/packages/excalidraw/vite.config.mts b/packages/excalidraw/vite.config.mts new file mode 100644 index 000000000..9639966b2 --- /dev/null +++ b/packages/excalidraw/vite.config.mts @@ -0,0 +1,15 @@ +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +// To load .env.local variables +const envVars = loadEnv("", `../../`); +// https://vitejs.dev/config/ +export default defineConfig({ + root: "example/public", + server: { + port: 3001, + // open the browser + open: true, + }, + publicDir: "public", +}); diff --git a/packages/excalidraw/webpack.dev-server.config.js b/packages/excalidraw/webpack.dev-server.config.js deleted file mode 100644 index 4e8df8992..000000000 --- a/packages/excalidraw/webpack.dev-server.config.js +++ /dev/null @@ -1,28 +0,0 @@ -const path = require("path"); -const { merge } = require("webpack-merge"); - -const devConfig = require("./webpack.dev.config"); - -const devServerConfig = { - entry: { - bundle: "./example/index.tsx", - }, - // Server Configuration options - devServer: { - port: 3001, - host: "localhost", - hot: true, - compress: true, - static: { - directory: path.join(__dirname, "./example/public"), - }, - client: { - progress: true, - logging: "info", - overlay: true, //Shows a full-screen overlay in the browser when there are compiler errors or warnings. - }, - open: ["./"], - }, -}; - -module.exports = merge(devServerConfig, devConfig); diff --git a/packages/excalidraw/webpack.dev.config.js b/packages/excalidraw/webpack.dev.config.js deleted file mode 100644 index 2b06e9a11..000000000 --- a/packages/excalidraw/webpack.dev.config.js +++ /dev/null @@ -1,108 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const autoprefixer = require("autoprefixer"); -const { parseEnvVariables } = require("./env"); -const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist"; - -module.exports = { - mode: "development", - devtool: false, - entry: { - "excalidraw.development": "./entry.js", - }, - output: { - path: path.resolve(__dirname, outputDir), - library: "ExcalidrawLib", - libraryTarget: "umd", - filename: "[name].js", - chunkFilename: "excalidraw-assets-dev/[name]-[contenthash].js", - assetModuleFilename: "excalidraw-assets-dev/[name][ext]", - publicPath: "", - }, - resolve: { - extensions: [".js", ".ts", ".tsx", ".css", ".scss"], - }, - module: { - rules: [ - { - test: /\.(sa|sc|c)ss$/, - exclude: /node_modules/, - use: [ - "style-loader", - { loader: "css-loader" }, - { - loader: "postcss-loader", - options: { - postcssOptions: { - plugins: [autoprefixer()], - }, - }, - }, - "sass-loader", - ], - }, - // So that type module works with webpack - // https://github.com/webpack/webpack/issues/11467#issuecomment-691873586 - { - test: /\.m?js/, - resolve: { - fullySpecified: false, - }, - }, - { - test: /\.(ts|tsx|js|jsx|mjs)$/, - exclude: - /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/, - use: [ - { - loader: "import-meta-loader", - }, - { - loader: "ts-loader", - options: { - transpileOnly: true, - configFile: path.resolve(__dirname, "../tsconfig.dev.json"), - }, - }, - ], - }, - { - test: /\.(woff|woff2|eot|ttf|otf)$/, - type: "asset/resource", - }, - ], - }, - optimization: { - splitChunks: { - chunks: "async", - cacheGroups: { - vendors: { - test: /[\\/]node_modules[\\/]/, - name: "vendor", - }, - }, - }, - }, - plugins: [ - new webpack.EvalSourceMapDevToolPlugin({ exclude: /vendor/ }), - new webpack.DefinePlugin({ - "process.env": parseEnvVariables( - path.resolve(__dirname, "../../.env.development"), - ), - }), - ], - externals: { - react: { - root: "React", - commonjs2: "react", - commonjs: "react", - amd: "react", - }, - "react-dom": { - root: "ReactDOM", - commonjs2: "react-dom", - commonjs: "react-dom", - amd: "react-dom", - }, - }, -}; diff --git a/packages/excalidraw/webpack.preact.config.js b/packages/excalidraw/webpack.preact.config.js deleted file mode 100644 index 0ae969aa6..000000000 --- a/packages/excalidraw/webpack.preact.config.js +++ /dev/null @@ -1,32 +0,0 @@ -const prodConfig = require("./webpack.prod.config"); -const devConfig = require("./webpack.dev.config"); - -const isProd = process.env.NODE_ENV === "production"; - -const config = isProd ? prodConfig : devConfig; -const outputFile = isProd - ? "excalidraw-with-preact.production.min" - : "excalidraw-with-preact.development"; - -const preactWebpackConfig = { - ...config, - entry: { - [outputFile]: "./entry.js", - }, - externals: { - ...config.externals, - "react-dom/client": { - root: "ReactDOMClient", - commonjs2: "react-dom/client", - commonjs: "react-dom/client", - amd: "react-dom/client", - }, - "react/jsx-runtime": { - root: "ReactJSXRuntime", - commonjs2: "react/jsx-runtime", - commonjs: "react/jsx-runtime", - amd: "react/jsx-runtime", - }, - }, -}; -module.exports = preactWebpackConfig; diff --git a/packages/excalidraw/webpack.prod.config.js b/packages/excalidraw/webpack.prod.config.js deleted file mode 100644 index e1d38509b..000000000 --- a/packages/excalidraw/webpack.prod.config.js +++ /dev/null @@ -1,131 +0,0 @@ -const path = require("path"); -const webpack = require("webpack"); -const autoprefixer = require("autoprefixer"); -const { parseEnvVariables } = require("./env"); -const TerserPlugin = require("terser-webpack-plugin"); -const BundleAnalyzerPlugin = - require("webpack-bundle-analyzer").BundleAnalyzerPlugin; - -module.exports = { - mode: "production", - entry: { - "excalidraw.production.min": "./entry.js", - }, - output: { - path: path.resolve(__dirname, "dist"), - library: "ExcalidrawLib", - libraryTarget: "umd", - filename: "[name].js", - chunkFilename: "excalidraw-assets/[name]-[contenthash].js", - assetModuleFilename: "excalidraw-assets/[name][ext]", - publicPath: "", - }, - resolve: { - extensions: [".js", ".ts", ".tsx", ".css", ".scss"], - }, - module: { - rules: [ - { - test: /\.(sa|sc|c)ss$/, - exclude: /node_modules/, - use: [ - "style-loader", - { - loader: "css-loader", - }, - { - loader: "postcss-loader", - options: { - postcssOptions: { - plugins: [autoprefixer()], - }, - }, - }, - "sass-loader", - ], - }, - // So that type module works with webpack - // https://github.com/webpack/webpack/issues/11467#issuecomment-691873586 - { - test: /\.m?js/, - resolve: { - fullySpecified: false, - }, - }, - { - test: /\.(ts|tsx|js|jsx|mjs)$/, - exclude: - /node_modules[\\/](?!(browser-fs-access|canvas-roundrect-polyfill))/, - use: [ - { - loader: "import-meta-loader", - }, - { - 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: [ - "transform-class-properties", - "@babel/plugin-transform-runtime", - ], - }, - }, - ], - }, - { - test: /\.(woff|woff2|eot|ttf|otf)$/, - type: "asset/resource", - }, - ], - }, - optimization: { - minimize: true, - minimizer: [ - new TerserPlugin({ - test: /\.js($|\?)/i, - }), - ], - splitChunks: { - chunks: "async", - cacheGroups: { - vendors: { - test: /[\\/]node_modules[\\/]/, - name: "vendor", - }, - }, - }, - }, - plugins: [ - ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []), - new webpack.DefinePlugin({ - "process.env": parseEnvVariables( - path.resolve(__dirname, "../../.env.production"), - ), - }), - ], - externals: { - react: { - root: "React", - commonjs2: "react", - commonjs: "react", - amd: "react", - }, - "react-dom": { - root: "ReactDOM", - commonjs2: "react-dom", - commonjs: "react-dom", - amd: "react-dom", - }, - }, -}; diff --git a/public/Assistant-Bold.woff2 b/public/fonts/Assistant-Bold.woff2 similarity index 100% rename from public/Assistant-Bold.woff2 rename to public/fonts/Assistant-Bold.woff2 diff --git a/public/Assistant-Medium.woff2 b/public/fonts/Assistant-Medium.woff2 similarity index 100% rename from public/Assistant-Medium.woff2 rename to public/fonts/Assistant-Medium.woff2 diff --git a/public/Assistant-Regular.woff2 b/public/fonts/Assistant-Regular.woff2 similarity index 100% rename from public/Assistant-Regular.woff2 rename to public/fonts/Assistant-Regular.woff2 diff --git a/public/Assistant-SemiBold.woff2 b/public/fonts/Assistant-SemiBold.woff2 similarity index 100% rename from public/Assistant-SemiBold.woff2 rename to public/fonts/Assistant-SemiBold.woff2 diff --git a/public/Cascadia.ttf b/public/fonts/Cascadia.ttf similarity index 100% rename from public/Cascadia.ttf rename to public/fonts/Cascadia.ttf diff --git a/public/Cascadia.woff2 b/public/fonts/Cascadia.woff2 similarity index 100% rename from public/Cascadia.woff2 rename to public/fonts/Cascadia.woff2 diff --git a/public/FG_Virgil.ttf b/public/fonts/FG_Virgil.ttf similarity index 100% rename from public/FG_Virgil.ttf rename to public/fonts/FG_Virgil.ttf diff --git a/public/FG_Virgil.woff2 b/public/fonts/FG_Virgil.woff2 similarity index 100% rename from public/FG_Virgil.woff2 rename to public/fonts/FG_Virgil.woff2 diff --git a/public/Virgil.woff2 b/public/fonts/Virgil.woff2 similarity index 100% rename from public/Virgil.woff2 rename to public/fonts/Virgil.woff2 diff --git a/public/fonts.css b/public/fonts/fonts.css similarity index 100% rename from public/fonts.css rename to public/fonts/fonts.css diff --git a/scripts/autorelease.js b/scripts/autorelease.js index ab5d26e27..f506cf13c 100644 --- a/scripts/autorelease.js +++ b/scripts/autorelease.js @@ -16,8 +16,7 @@ const publish = () => { try { execSync(`yarn --frozen-lockfile`); - execSync(`yarn --frozen-lockfile`, { cwd: excalidrawDir }); - execSync(`yarn run build:umd`, { cwd: excalidrawDir }); + execSync(`yarn run build:esm`, { cwd: excalidrawDir }); execSync(`yarn --cwd ${excalidrawDir} publish --tag ${tag}`); console.info(`Published ${pkg.name}@${tag}🎉`); core.setOutput( @@ -41,7 +40,10 @@ exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => { const changedFiles = stdout.trim().split("\n"); const excalidrawPackageFiles = changedFiles.filter((file) => { - return file.indexOf("packages/excalidraw") >= 0; + return ( + file.indexOf("packages/excalidraw") >= 0 || + file.indexOf("buildPackage.js") > 0 + ); }); if (!excalidrawPackageFiles.length) { console.info("Skipping release as no valid diff found"); diff --git a/scripts/buildExample.mjs b/scripts/buildExample.mjs new file mode 100644 index 000000000..cfcbe8420 --- /dev/null +++ b/scripts/buildExample.mjs @@ -0,0 +1,35 @@ +import * as esbuild from "esbuild"; +import { sassPlugin } from "esbuild-sass-plugin"; +import { execSync } from "child_process"; + +const createDevBuild = async () => { + return await esbuild.build({ + entryPoints: ["example/index.tsx"], + outfile: "example/public/bundle.js", + define: { + "import.meta.env": "{}", + }, + bundle: true, + format: "esm", + plugins: [sassPlugin()], + loader: { + ".woff2": "dataurl", + ".html": "copy", + }, + }); +}; + +const startServer = async (ctx) => { + await ctx.serve({ + servedir: "example/public", + port: 5001, + }); +}; +execSync( + `rm -rf example/public/dist && yarn build:esm && cp -r dist example/public`, +); + +const ctx = await createDevBuild(); + +// await startServer(ctx); +// console.info("Hosted at port http://localhost:5001!!"); diff --git a/scripts/buildPackage.js b/scripts/buildPackage.js new file mode 100644 index 000000000..f564466d5 --- /dev/null +++ b/scripts/buildPackage.js @@ -0,0 +1,135 @@ +const { build } = require("esbuild"); +const { sassPlugin } = require("esbuild-sass-plugin"); +const { externalGlobalPlugin } = require("esbuild-plugin-external-global"); +// Will be used later for treeshaking +//const fs = require("fs"); +// const path = require("path"); + +// 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") +// ) { +// 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 browserConfig = { + entryPoints: ["index.tsx"], + bundle: true, + format: "esm", + plugins: [ + sassPlugin(), + externalGlobalPlugin({ + react: "React", + "react-dom": "ReactDOM", + }), + ], + splitting: true, + loader: { + ".woff2": "copy", + ".ttf": "copy", + }, +}; +const createESMBrowserBuild = async () => { + // Development unminified build with source maps + await build({ + ...browserConfig, + outdir: "dist/browser/dev", + sourcemap: true, + chunkNames: "excalidraw-assets-dev/[name]-[hash]", + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + + // production minified build without sourcemaps + await build({ + ...browserConfig, + outdir: "dist/browser/prod", + minify: true, + chunkNames: "excalidraw-assets/[name]-[hash]", + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); +}; + +// const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`; +// const filesinExcalidrawPackage = [ +// ...getFiles(`${BASE_PATH}/packages/excalidraw`), +// `${BASE_PATH}/packages/utils/export.ts`, +// `${BASE_PATH}/packages/utils/bbox.ts`, +// ...getFiles(`${BASE_PATH}/public/fonts`), +// ]; + +// const filesToTransform = filesinExcalidrawPackage.filter((file) => { +// return !( +// file.includes("/__tests__/") || +// file.includes(".test.") || +// file.includes("/tests/") || +// file.includes("example") +// ); +// }); + +const rawConfig = { + entryPoints: ["index.tsx"], + bundle: true, + format: "esm", + plugins: [sassPlugin()], + + loader: { + ".woff2": "copy", + ".ttf": "copy", + ".json": "copy", + }, + packages: "external", +}; + +const createESMRawBuild = async () => { + // Development unminified build with source maps + await build({ + ...rawConfig, + sourcemap: true, + outdir: "dist/dev", + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + + // production minified build without sourcemaps + await build({ + ...rawConfig, + minify: true, + outdir: "dist/prod", + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); +}; + +createESMRawBuild(); +createESMBrowserBuild(); diff --git a/yarn.lock b/yarn.lock index 2279e1bf3..87493423e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1990,111 +1990,226 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@esbuild/aix-ppc64@0.19.10": + version "0.19.10" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.10.tgz#fb3922a0183d27446de00cf60d4f7baaadf98d84" + integrity sha512-Q+mk96KJ+FZ30h9fsJl+67IjNJm3x2eX+GBWGmocAKgzp27cowCOOqSdscX80s0SpdFXZnIv/+1xD1EctFx96Q== + +"@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" + integrity sha512-1X4CClKhDgC3by7k8aOWZeBXQX8dHT5QAMCAQDArCLaYfkppoARvh0fit3X2Qs+MXDngKcHv6XXyQCpY0hkK1Q== + "@esbuild/android-arm64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz#fb7130103835b6d43ea499c3f30cfb2b2ed58456" integrity sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA== +"@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" + integrity sha512-7W0bK7qfkw1fc2viBfrtAEkDKHatYfHzr/jKAHNr9BvkYDXPcC6bodtm8AyLJNNuqClLNaeTLuwURt4PRT9d7w== + "@esbuild/android-arm@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.8.tgz#b46e4d9e984e6d6db6c4224d72c86b7757e35bcb" integrity sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA== +"@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" + integrity sha512-O/nO/g+/7NlitUxETkUv/IvADKuZXyH4BHf/g/7laqKC4i/7whLpB0gvpPc2zpF0q9Q6FXS3TS75QHac9MvVWw== + "@esbuild/android-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.8.tgz#a13db9441b5a4f4e4fec4a6f8ffacfea07888db7" integrity sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A== +"@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" + integrity sha512-YSRRs2zOpwypck+6GL3wGXx2gNP7DXzetmo5pHXLrY/VIMsS59yKfjPizQ4lLt5vEI80M41gjm2BxrGZ5U+VMA== + "@esbuild/darwin-arm64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz#49f5718d36541f40dd62bfdf84da9c65168a0fc2" integrity sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw== +"@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" + integrity sha512-alfGtT+IEICKtNE54hbvPg13xGBe4GkVxyGWtzr+yHO7HIiRJppPDhOKq3zstTcVf8msXb/t4eavW3jCDpMSmA== + "@esbuild/darwin-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz#75c5c88371eea4bfc1f9ecfd0e75104c74a481ac" integrity sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q== +"@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" + integrity sha512-dMtk1wc7FSH8CCkE854GyGuNKCewlh+7heYP/sclpOG6Cectzk14qdUIY5CrKDbkA/OczXq9WesqnPl09mj5dg== + "@esbuild/freebsd-arm64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz#9d7259fea4fd2b5f7437b52b542816e89d7c8575" integrity sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw== +"@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" + integrity sha512-G5UPPspryHu1T3uX8WiOEUa6q6OlQh6gNl4CO4Iw5PS+Kg5bVggVFehzXBJY6X6RSOMS8iXDv2330VzaObm4Ag== + "@esbuild/freebsd-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz#abac03e1c4c7c75ee8add6d76ec592f46dbb39e3" integrity sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg== +"@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" + integrity sha512-QxaouHWZ+2KWEj7cGJmvTIHVALfhpGxo3WLmlYfJ+dA5fJB6lDEIg+oe/0//FuyVHuS3l79/wyBxbHr0NgtxJQ== + "@esbuild/linux-arm64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz#c577932cf4feeaa43cb9cec27b89cbe0df7d9098" integrity sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ== +"@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" + integrity sha512-j6gUW5aAaPgD416Hk9FHxn27On28H4eVI9rJ4az7oCGTFW48+LcgNDBN+9f8rKZz7EEowo889CPKyeaD0iw9Kg== + "@esbuild/linux-arm@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz#d6014d8b98b5cbc96b95dad3d14d75bb364fdc0f" integrity sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ== +"@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" + integrity sha512-4ub1YwXxYjj9h1UIZs2hYbnTZBtenPw5NfXCRgEkGb0b6OJ2gpkMvDqRDYIDRjRdWSe/TBiZltm3Y3Q8SN1xNg== + "@esbuild/linux-ia32@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz#2379a0554307d19ac4a6cdc15b08f0ea28e7a40d" integrity sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ== +"@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" + integrity sha512-lo3I9k+mbEKoxtoIbM0yC/MZ1i2wM0cIeOejlVdZ3D86LAcFXFRdeuZmh91QJvUTW51bOK5W2BznGNIl4+mDaA== + "@esbuild/linux-loong64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz#e2a5bbffe15748b49356a6cd7b2d5bf60c5a7123" integrity sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ== +"@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" + integrity sha512-J4gH3zhHNbdZN0Bcr1QUGVNkHTdpijgx5VMxeetSk6ntdt+vR1DqGmHxQYHRmNb77tP6GVvD+K0NyO4xjd7y4A== + "@esbuild/linux-mips64el@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz#1359331e6f6214f26f4b08db9b9df661c57cfa24" integrity sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q== +"@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" + integrity sha512-tgT/7u+QhV6ge8wFMzaklOY7KqiyitgT1AUHMApau32ZlvTB/+efeCtMk4eXS+uEymYK249JsoiklZN64xt6oQ== + "@esbuild/linux-ppc64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz#9ba436addc1646dc89dae48c62d3e951ffe70951" integrity sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg== +"@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" + integrity sha512-0f/spw0PfBMZBNqtKe5FLzBDGo0SKZKvMl5PHYQr3+eiSscfJ96XEknCe+JoOayybWUFQbcJTrk946i3j9uYZA== + "@esbuild/linux-riscv64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz#fbcf0c3a0b20f40b5fc31c3b7695f0769f9de66b" integrity sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg== +"@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" + integrity sha512-pZFe0OeskMHzHa9U38g+z8Yx5FNCLFtUnJtQMpwhS+r4S566aK2ci3t4NCP4tjt6d5j5uo4h7tExZMjeKoehAA== + "@esbuild/linux-s390x@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz#989e8a05f7792d139d5564ffa7ff898ac6f20a4a" integrity sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg== +"@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" + integrity sha512-SpYNEqg/6pZYoc+1zLCjVOYvxfZVZj6w0KROZ3Fje/QrM3nfvT2llI+wmKSrWuX6wmZeTapbarvuNNK/qepSgA== + "@esbuild/linux-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz#b187295393a59323397fe5ff51e769ec4e72212b" integrity sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg== +"@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" + integrity sha512-ACbZ0vXy9zksNArWlk2c38NdKg25+L9pr/mVaj9SUq6lHZu/35nx2xnQVRGLrC1KKQqJKRIB0q8GspiHI3J80Q== + "@esbuild/netbsd-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz#c1ec0e24ea82313cb1c7bae176bd5acd5bde7137" integrity sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw== +"@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" + integrity sha512-PxcgvjdSjtgPMiPQrM3pwSaG4kGphP+bLSb+cihuP0LYdZv1epbAIecHVl5sD3npkfYBZ0ZnOjR878I7MdJDFg== + "@esbuild/openbsd-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz#0c5b696ac66c6d70cf9ee17073a581a28af9e18d" integrity sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ== +"@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" + integrity sha512-ZkIOtrRL8SEJjr+VHjmW0znkPs+oJXhlJbNwfI37rvgeMtk3sxOQevXPXjmAPZPigVTncvFqLMd+uV0IBSEzqA== + "@esbuild/sunos-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz#2a697e1f77926ff09fcc457d8f29916d6cd48fb1" integrity sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w== +"@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" + integrity sha512-+Sa4oTDbpBfGpl3Hn3XiUe4f8TU2JF7aX8cOfqFYMMjXp6ma6NJDztl5FDG8Ezx0OjwGikIHw+iA54YLDNNVfw== + "@esbuild/win32-arm64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz#ec029e62a2fca8c071842ecb1bc5c2dd20b066f1" integrity sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg== +"@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" + integrity sha512-EOGVLK1oWMBXgfttJdPHDTiivYSjX6jDNaATeNOaCOFEVcfMjtbx7WVQwPSE1eIfCp/CaSF2nSrDtzc4I9f8TQ== + "@esbuild/win32-ia32@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz#cbb9a3146bde64dc15543e48afe418c7a3214851" integrity sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw== +"@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" + integrity sha512-whqLG6Sc70AbU73fFYvuYzaE4MNMBIlR1Y/IrUeOXFrWHxBEjjbZaQ3IXIQS8wJdAzue2GwYZCjOrgrU1oUHoA== + "@esbuild/win32-x64@0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz#c8285183dbdb17008578dbacb6e22748709b4822" @@ -2495,11 +2610,6 @@ resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" - integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== - "@jridgewell/set-array@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" @@ -2526,7 +2636,7 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@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.15": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== @@ -2539,19 +2649,6 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@jridgewell/trace-mapping@^0.3.7": - version "0.3.20" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f" - integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@leichtgewicht/ip-codec@^2.0.1": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" - integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== - "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -3246,41 +3343,11 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/body-parser@*": - version "1.19.5" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4" - integrity sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg== - dependencies: - "@types/connect" "*" - "@types/node" "*" - -"@types/bonjour@^3.5.9": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@types/bonjour/-/bonjour-3.5.13.tgz#adf90ce1a105e81dd1f9c61fdc5afda1bfb92956" - integrity sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ== - dependencies: - "@types/node" "*" - "@types/chai@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== -"@types/connect-history-api-fallback@^1.3.5": - version "1.5.4" - resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz#7de71645a103056b48ac3ce07b3520b819c1d5b3" - integrity sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw== - dependencies: - "@types/express-serve-static-core" "*" - "@types/node" "*" - -"@types/connect@*": - version "3.4.38" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858" - integrity sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug== - dependencies: - "@types/node" "*" - "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -3324,38 +3391,6 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== -"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": - version "4.17.41" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz#5077defa630c2e8d28aa9ffc2c01c157c305bef6" - integrity sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - "@types/send" "*" - -"@types/express@*", "@types/express@^4.17.13": - version "4.17.21" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" - integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.33" - "@types/qs" "*" - "@types/serve-static" "*" - -"@types/http-errors@*": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" - integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== - -"@types/http-proxy@^1.17.8": - version "1.17.14" - resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.14.tgz#57f8ccaa1c1c3780644f8a94f9c6b5000b5e2eec" - integrity sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w== - dependencies: - "@types/node" "*" - "@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" @@ -3435,28 +3470,11 @@ dependencies: "@types/unist" "^2" -"@types/mime@*": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45" - integrity sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw== - -"@types/mime@^1": - version "1.3.5" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690" - integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== - "@types/ms@*": version "0.7.34" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== -"@types/node-forge@^1.3.0": - version "1.3.10" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.10.tgz#62a19d4f75a8b03290578c2b04f294b1a5a71b07" - integrity sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw== - dependencies: - "@types/node" "*" - "@types/node@*": version "18.15.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" @@ -3489,16 +3507,6 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== -"@types/qs@*": - version "6.9.10" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.10.tgz#0af26845b5067e1c9a622658a51f60a3934d51e8" - integrity sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw== - -"@types/range-parser@*": - version "1.2.7" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" - integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== - "@types/react-dom@18.0.6": version "18.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" @@ -3543,11 +3551,6 @@ dependencies: "@types/node" "*" -"@types/retry@0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" - integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== - "@types/scheduler@*": version "0.16.3" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" @@ -3558,30 +3561,6 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== -"@types/send@*": - version "0.17.4" - resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" - integrity sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA== - dependencies: - "@types/mime" "^1" - "@types/node" "*" - -"@types/serve-index@^1.9.1": - version "1.9.4" - resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.4.tgz#e6ae13d5053cb06ed36392110b4f9a49ac4ec898" - integrity sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug== - dependencies: - "@types/express" "*" - -"@types/serve-static@*", "@types/serve-static@^1.13.10": - version "1.15.5" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" - integrity sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ== - dependencies: - "@types/http-errors" "*" - "@types/mime" "*" - "@types/node" "*" - "@types/socket.io-client@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-3.0.0.tgz#d0b8ea22121b7c1df68b6a923002f9c8e3cefb42" @@ -3589,13 +3568,6 @@ dependencies: socket.io-client "*" -"@types/sockjs@^0.3.33": - version "0.3.36" - resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.36.tgz#ce322cf07bcc119d4cbf7f88954f3a3bd0f67535" - integrity sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q== - dependencies: - "@types/node" "*" - "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -3618,13 +3590,6 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" integrity sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA== -"@types/ws@^8.5.1": - version "8.5.10" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" - integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== - dependencies: - "@types/node" "*" - "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -4102,14 +4067,6 @@ abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== -accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - acorn-import-assertions@^1.7.6, acorn-import-assertions@^1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" @@ -4211,11 +4168,6 @@ ansi-escapes@^4.3.0: dependencies: type-fest "^0.21.3" -ansi-html-community@^0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" - integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== - ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" @@ -4304,16 +4256,6 @@ array-buffer-byte-length@^1.0.0: call-bind "^1.0.2" is-array-buffer "^3.0.1" -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - -array-flatten@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" - integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== - array-includes@^3.1.5, array-includes@^3.1.6: version "3.1.6" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.6.tgz#9e9e720e194f198266ba9e18c29e6a9b0e4b225f" @@ -4638,11 +4580,6 @@ basic-auth@^2.0.1: dependencies: safe-buffer "5.1.2" -batch@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" - integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== - big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -4662,34 +4599,6 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.11.0" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -bonjour-service@^1.0.11: - version "1.1.1" - resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.1.1.tgz#960948fa0e0153f5d26743ab15baf8e33752c135" - integrity sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg== - dependencies: - array-flatten "^2.1.2" - dns-equal "^1.0.0" - fast-deep-equal "^3.1.3" - multicast-dns "^7.2.5" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -4765,16 +4674,6 @@ bytes-iec@^3.1.1: resolved "https://registry.yarnpkg.com/bytes-iec/-/bytes-iec-3.1.1.tgz#94cd36bf95c2c22a82002c247df8772d1d591083" integrity sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA== -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" @@ -4997,7 +4896,7 @@ color-name@^1.1.4, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^2.0.10, colorette@^2.0.14: +colorette@^2.0.14: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== @@ -5044,26 +4943,6 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== -compressible@~2.0.16: - version "2.0.18" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" - integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== - dependencies: - mime-db ">= 1.43.0 < 2" - -compression@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" - integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== - dependencies: - accepts "~1.3.5" - bytes "3.0.0" - compressible "~2.0.16" - debug "2.6.9" - on-headers "~1.0.2" - safe-buffer "5.1.2" - vary "~1.1.2" - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -5074,38 +4953,11 @@ confusing-browser-globals@^1.0.11: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== -connect-history-api-fallback@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" - integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== - -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.9.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== - -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - core-js-compat@^3.21.0, core-js-compat@^3.22.1: version "3.34.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.34.0.tgz#61a4931a13c52f8f08d924522bba65f8c94a5f17" @@ -5135,11 +4987,6 @@ core-js@^3.4: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.34.0.tgz#5705e6ad5982678612e96987d05b27c6c7c274a5" integrity sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag== -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - corser@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87" @@ -5546,13 +5393,6 @@ dayjs@^1.11.7: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== -debug@2.6.9, debug@^2.6.8: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5560,6 +5400,13 @@ debug@4, debug@4.3.4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, de dependencies: ms "2.1.2" +debug@^2.6.8: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -5631,18 +5478,6 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== -default-gateway@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71" - integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg== - dependencies: - execa "^5.0.0" - -define-lazy-prop@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" - integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== - define-properties@^1.1.3, define-properties@^1.1.4: version "1.2.0" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.0.tgz#52988570670c9eacedd8064f4a990f2405849bd5" @@ -5663,36 +5498,16 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - detect-node-es@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -detect-node@^2.0.4: - version "2.1.0" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" - integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== - devtools-protocol@0.0.981744: version "0.0.981744" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf" @@ -5725,18 +5540,6 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -dns-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" - integrity sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg== - -dns-packet@^5.2.2: - version "5.6.1" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.6.1.tgz#ae888ad425a9d1478a0674256ab866de1012cf2f" - integrity sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw== - dependencies: - "@leichtgewicht/ip-codec" "^2.0.1" - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -5795,11 +5598,6 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - ejs@^3.1.6, ejs@^3.1.9: version "3.1.9" resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361" @@ -5837,11 +5635,6 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -5987,6 +5780,48 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +esbuild-plugin-external-global@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz#e3bba0e3a561f61b395bec0984a90ed0de06c4ce" + integrity sha512-NDzYHRoShpvLqNcrgV8ZQh61sMIFAry5KLTQV83BPG5iTXCCu7h72SCfJ97bW0GqtuqDD/1aqLbKinI/rNgUsg== + +esbuild-sass-plugin@2.16.0: + version "2.16.0" + resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-2.16.0.tgz#2908ab5e104cfc980118c46d0b409cbab8aa32dd" + integrity sha512-mGCe9MxNYvZ+j77Q/QFO+rwUGA36mojDXkOhtVmoyz1zwYbMaNrtVrmXwwYDleS/UMKTNU3kXuiTtPiAD3K+Pw== + dependencies: + resolve "^1.22.6" + sass "^1.7.3" + +esbuild@0.19.10: + version "0.19.10" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.10.tgz#55e83e4a6b702e3498b9f872d84bfb4ebcb6d16e" + integrity sha512-S1Y27QGt/snkNYrRcswgRFqZjaTG5a5xM3EQo97uNBnH505pdzSNe/HLBq1v0RO7iK/ngdbhJB6mDAp0OK+iUA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.10" + "@esbuild/android-arm" "0.19.10" + "@esbuild/android-arm64" "0.19.10" + "@esbuild/android-x64" "0.19.10" + "@esbuild/darwin-arm64" "0.19.10" + "@esbuild/darwin-x64" "0.19.10" + "@esbuild/freebsd-arm64" "0.19.10" + "@esbuild/freebsd-x64" "0.19.10" + "@esbuild/linux-arm" "0.19.10" + "@esbuild/linux-arm64" "0.19.10" + "@esbuild/linux-ia32" "0.19.10" + "@esbuild/linux-loong64" "0.19.10" + "@esbuild/linux-mips64el" "0.19.10" + "@esbuild/linux-ppc64" "0.19.10" + "@esbuild/linux-riscv64" "0.19.10" + "@esbuild/linux-s390x" "0.19.10" + "@esbuild/linux-x64" "0.19.10" + "@esbuild/netbsd-x64" "0.19.10" + "@esbuild/openbsd-x64" "0.19.10" + "@esbuild/sunos-x64" "0.19.10" + "@esbuild/win32-arm64" "0.19.10" + "@esbuild/win32-ia32" "0.19.10" + "@esbuild/win32-x64" "0.19.10" + esbuild@^0.19.3: version "0.19.8" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.8.tgz#ad05b72281d84483fa6b5345bd246c27a207b8f1" @@ -6020,11 +5855,6 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -6319,11 +6149,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -6334,7 +6159,7 @@ events@^3.2.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0, execa@^5.1.1: +execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -6375,43 +6200,6 @@ expect@^29.0.0: jest-message-util "^29.7.0" jest-util "^29.7.0" -express@^4.17.3: - version "4.18.2" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.1" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.11.0" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - extract-zip@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" @@ -6502,13 +6290,6 @@ faye-websocket@0.11.3: dependencies: websocket-driver ">=0.5.1" -faye-websocket@^0.11.3: - version "0.11.4" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" - integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== - dependencies: - websocket-driver ">=0.5.1" - fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -6550,19 +6331,6 @@ fill-range@^7.0.1: 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" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - find-cache-dir@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" @@ -6644,21 +6412,11 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - fraction.js@^4.2.0: version "4.3.7" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -6683,11 +6441,6 @@ fs-extra@^9.0.1: jsonfile "^6.0.1" universalify "^2.0.0" -fs-monkey@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.5.tgz#fe450175f0db0d7ea758102e1d84096acb925788" - integrity sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew== - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -6864,7 +6617,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -6886,11 +6639,6 @@ hachure-fill@^0.5.2: resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg== -handle-thing@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" - integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== - has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" @@ -6961,16 +6709,6 @@ heap@^0.2.6: resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.7.tgz#1e6adf711d3f27ce35a81fe3b7bd576c2260a8fc" integrity sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg== -hpack.js@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" - integrity sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ== - dependencies: - inherits "^2.0.1" - obuf "^1.0.0" - readable-stream "^2.0.1" - wbuf "^1.1.0" - html-encoding-sniffer@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" @@ -6978,42 +6716,11 @@ html-encoding-sniffer@^3.0.0: dependencies: whatwg-encoding "^2.0.0" -html-entities@^2.3.2: - version "2.4.0" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.4.0.tgz#edd0cee70402584c8c76cc2c0556db09d1f45061" - integrity sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ== - html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -http-deceiver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" - integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-errors@~1.6.2: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -7028,17 +6735,6 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== - dependencies: - "@types/http-proxy" "^1.17.8" - http-proxy "^1.18.1" - is-glob "^4.0.1" - is-plain-obj "^3.0.0" - micromatch "^4.0.2" - http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" @@ -7097,13 +6793,6 @@ i18next-browser-languagedetector@6.1.4: dependencies: "@babel/runtime" "^7.14.6" -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - iconv-lite@0.6, iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -7204,16 +6893,11 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== - internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986" @@ -7240,16 +6924,6 @@ invariant@^2.2.2, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -ipaddr.js@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f" - integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ== - is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -7327,11 +7001,6 @@ is-date-object@^1.0.1, is-date-object@^1.0.5: dependencies: has-tostringtag "^1.0.0" -is-docker@^2.0.0, is-docker@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -7386,11 +7055,6 @@ is-obj@^1.0.1: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== -is-plain-obj@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" - integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== - is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -7483,23 +7147,11 @@ is-weakset@^2.0.1: call-bind "^1.0.2" get-intrinsic "^1.1.1" -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -8089,23 +7741,6 @@ mdast-util-to-string@^3.1.0: dependencies: "@types/mdast" "^3.0.0" -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -memfs@^3.4.3: - version "3.6.0" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6" - integrity sha512-EGowvkkgbMcIChjMTMkESFDbZeSh8xZ7kNSF0hAiAN4Jh6jgHCRS0Ga/+C8y6Au+oqpezRHCfPsmJ2+DwAgiwQ== - dependencies: - fs-monkey "^1.0.4" - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -8139,11 +7774,6 @@ mermaid@10.2.3: uuid "^9.0.0" web-worker "^1.2.0" -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - micromark-core-commonmark@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" @@ -8338,7 +7968,7 @@ micromark@^3.0.0: micromark-util-types "^1.0.1" uvu "^0.5.0" -micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4: +micromatch@^4.0.0, micromatch@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== @@ -8346,19 +7976,19 @@ micromatch@^4.0.0, micromatch@^4.0.2, micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": +mime-db@1.52.0: version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.27: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" -mime@1.6.0, mime@^1.6.0: +mime@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -8385,11 +8015,6 @@ mini-css-extract-plugin@2.6.1: dependencies: schema-utils "^4.0.0" -minimalistic-assert@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -8468,19 +8093,11 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -multicast-dns@^7.2.5: - version "7.2.5" - resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" - integrity sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg== - dependencies: - dns-packet "^5.2.2" - thunky "^1.0.2" - multimath@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302" @@ -8521,11 +8138,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -8543,11 +8155,6 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-forge@^1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" - integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== - node-releases@^2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" @@ -8660,23 +8267,6 @@ object.values@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" -obuf@^1.0.0, obuf@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" - integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== - -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -8703,15 +8293,6 @@ open-color@1.9.1: resolved "https://registry.yarnpkg.com/open-color/-/open-color-1.9.1.tgz#a6e6328f60eff7aa60e3e8fcfa50f53ff3eece35" integrity sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw== -open@^8.0.9: - version "8.4.2" - resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" - integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== - dependencies: - define-lazy-prop "^2.0.0" - is-docker "^2.1.1" - is-wsl "^2.2.0" - opener@^1.5.1, opener@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -8757,14 +8338,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-retry@^4.5.0: - version "4.6.2" - resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" - integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== - dependencies: - "@types/retry" "0.12.0" - retry "^0.13.1" - p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -8799,11 +8372,6 @@ parse5@^7.1.2: dependencies: entities "^4.4.0" -parseurl@~1.3.2, parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - path-data-parser@0.1.0, path-data-parser@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/path-data-parser/-/path-data-parser-0.1.0.tgz#8f5ba5cc70fc7becb3dcefaea08e2659aba60b8c" @@ -8834,11 +8402,6 @@ 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-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== - path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -9062,11 +8625,6 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - progress@2.0.3, progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -9123,14 +8681,6 @@ protobufjs@^7.2.4: "@types/node" ">=13.7.0" long "^5.0.0" -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - proxy-from-env@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -9177,13 +8727,6 @@ pwacompat@2.0.17: resolved "https://registry.yarnpkg.com/pwacompat/-/pwacompat-2.0.17.tgz#707959ff97f239bf1fe7b260b63aeea416a15eab" integrity sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w== -qs@6.11.0: - version "6.11.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" - integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== - dependencies: - side-channel "^1.0.4" - qs@^6.4.0: version "6.11.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.1.tgz#6c29dff97f0c0060765911ba65cbc9764186109f" @@ -9208,21 +8751,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - react-dom@18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -9286,20 +8814,7 @@ react@18.2.0: dependencies: loose-envify "^1.1.0" -readable-stream@^2.0.1: - version "2.3.8" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -9455,7 +8970,7 @@ resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.9.0: +resolve@^1.22.6, resolve@^1.9.0: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== @@ -9481,11 +8996,6 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" -retry@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" - integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -9597,12 +9107,12 @@ safari-14-idb-fix@^3.0.0: resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz#450fc049b996ec7f3fd9ca2f89d32e0761583440" integrity sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog== -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -9616,7 +9126,7 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -9638,6 +9148,15 @@ sass@1.51.0: immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" +sass@^1.7.3: + version "1.69.5" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.69.5.tgz#23e18d1c757a35f2e52cc81871060b9ad653dfde" + integrity sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ== + dependencies: + chokidar ">=3.0.0 <4.0.0" + immutable "^4.0.0" + source-map-js ">=0.6.2 <2.0.0" + saxes@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" @@ -9685,19 +9204,6 @@ secure-compare@3.0.1: resolved "https://registry.yarnpkg.com/secure-compare/-/secure-compare-3.0.1.tgz#f1a0329b308b221fae37b9974f3d578d0ca999e3" integrity sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw== -select-hose@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" - integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== - -selfsigned@^2.0.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.4.1.tgz#560d90565442a3ed35b674034cec4e95dceb4ae0" - integrity sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q== - dependencies: - "@types/node-forge" "^1.3.0" - node-forge "^1" - semver@7.5.4, semver@^7.3.4, semver@^7.3.5, semver@^7.5.0, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -9722,25 +9228,6 @@ semver@^7.2.1, semver@^7.3.7: dependencies: lru-cache "^6.0.0" -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" @@ -9748,46 +9235,13 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: +serialize-javascript@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.1.tgz#b206efb27c3da0b0ab6b52f48d170b7996458e5c" integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w== dependencies: randombytes "^2.1.0" -serve-index@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" - integrity sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw== - dependencies: - accepts "~1.3.4" - batch "0.6.1" - debug "2.6.9" - escape-html "~1.0.3" - http-errors "~1.6.2" - mime-types "~2.1.17" - parseurl "~1.3.2" - -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -setprototypeof@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" - integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -9915,15 +9369,6 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" -sockjs@^0.3.24: - version "0.3.24" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" - integrity sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ== - dependencies: - faye-websocket "^0.11.3" - uuid "^8.3.2" - websocket-driver "^0.7.4" - "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -9962,29 +9407,6 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -spdy-transport@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" - integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== - dependencies: - debug "^4.1.0" - detect-node "^2.0.4" - hpack.js "^2.1.6" - obuf "^1.1.2" - readable-stream "^3.0.6" - wbuf "^1.7.3" - -spdy@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" - integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== - dependencies: - debug "^4.1.0" - handle-thing "^2.0.0" - http-deceiver "^1.2.7" - select-hose "^2.0.0" - spdy-transport "^3.0.0" - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -10002,16 +9424,6 @@ stackback@0.0.2: resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -"statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - std-env@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.3.3.tgz#a54f06eb245fdcfef53d56f3c0251f1d5c3d01fe" @@ -10105,13 +9517,6 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - stringify-object@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -10289,17 +9694,6 @@ tempy@^0.6.0: type-fest "^0.16.0" unique-string "^2.0.0" -terser-webpack-plugin@5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.3.tgz#8033db876dd5875487213e87c627bca323e5ed90" - integrity sha512-Fx60G5HNYknNTNQnzQ1VePRuu89ZVYWfjRAeT5rITuCY/1b08s49e5kSQwHDirKZWuoKOBRFS98EUUoZ9kLEwQ== - dependencies: - "@jridgewell/trace-mapping" "^0.3.7" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.0" - terser "^5.7.2" - terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.3.7: version "5.3.9" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1" @@ -10321,7 +9715,7 @@ terser@^5.0.0: commander "^2.20.0" source-map-support "~0.5.20" -terser@^5.16.8, terser@^5.7.2: +terser@^5.16.8: version "5.26.0" resolved "https://registry.yarnpkg.com/terser/-/terser-5.26.0.tgz#ee9f05d929f4189a9c28a0feb889d96d50126fe1" integrity sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ== @@ -10350,11 +9744,6 @@ through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -thunky@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" - integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== - tiny-invariant@^1.1.0: version "1.3.1" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" @@ -10392,11 +9781,6 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - totalist@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" @@ -10524,14 +9908,6 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - typed-array-length@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb" @@ -10647,11 +10023,6 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - upath@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" @@ -10718,21 +10089,11 @@ use-sync-external-store@1.2.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== -util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - uuid@^9.0.0: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" @@ -10762,11 +10123,6 @@ v8-to-istanbul@^9.1.0: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - vite-node@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.0.1.tgz#c16c9df9b5d47b74156a6501c9db5b380d992768" @@ -10931,13 +10287,6 @@ watchpack@^2.4.0: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -wbuf@^1.1.0, wbuf@^1.7.3: - version "1.7.3" - resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" - integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== - dependencies: - minimalistic-assert "^1.0.0" - web-worker@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da" @@ -10996,60 +10345,6 @@ webpack-cli@4.10.0: rechoir "^0.7.0" webpack-merge "^5.7.3" -webpack-dev-middleware@^5.3.1: - version "5.3.3" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" - integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA== - dependencies: - colorette "^2.0.10" - memfs "^3.4.3" - mime-types "^2.1.31" - range-parser "^1.2.1" - schema-utils "^4.0.0" - -webpack-dev-server@4.9.3: - version "4.9.3" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.9.3.tgz#2360a5d6d532acb5410a668417ad549ee3b8a3c9" - integrity sha512-3qp/eoboZG5/6QgiZ3llN8TUzkSpYg1Ko9khWX1h40MIEUNS2mDoIa8aXsPfskER+GbTvs/IJZ1QTBBhhuetSw== - dependencies: - "@types/bonjour" "^3.5.9" - "@types/connect-history-api-fallback" "^1.3.5" - "@types/express" "^4.17.13" - "@types/serve-index" "^1.9.1" - "@types/serve-static" "^1.13.10" - "@types/sockjs" "^0.3.33" - "@types/ws" "^8.5.1" - ansi-html-community "^0.0.8" - bonjour-service "^1.0.11" - chokidar "^3.5.3" - colorette "^2.0.10" - compression "^1.7.4" - connect-history-api-fallback "^2.0.0" - default-gateway "^6.0.3" - express "^4.17.3" - graceful-fs "^4.2.6" - html-entities "^2.3.2" - http-proxy-middleware "^2.0.3" - ipaddr.js "^2.0.1" - open "^8.0.9" - p-retry "^4.5.0" - rimraf "^3.0.2" - schema-utils "^4.0.0" - selfsigned "^2.0.1" - serve-index "^1.9.1" - sockjs "^0.3.24" - spdy "^4.0.2" - webpack-dev-middleware "^5.3.1" - ws "^8.4.2" - -webpack-merge@5.8.0: - version "5.8.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61" - integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q== - dependencies: - clone-deep "^4.0.1" - wildcard "^2.0.0" - webpack-merge@^5.7.3: version "5.10.0" resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.10.0.tgz#a3ad5d773241e9c682803abf628d4cd62b8a4177" @@ -11124,7 +10419,7 @@ webpack@^5.88.2: watchpack "^2.4.0" webpack-sources "^3.2.3" -websocket-driver@>=0.5.1, websocket-driver@^0.7.4: +websocket-driver@>=0.5.1: version "0.7.4" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== @@ -11448,11 +10743,6 @@ ws@^8.13.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== -ws@^8.4.2: - version "8.14.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" - integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== - ws@~8.11.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" From 49f15c736b3b8f07c86b04c8654133714bd70574 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 3 Jan 2024 16:25:36 +0530 Subject: [PATCH 018/112] chore: remove unused files (#7509) chore remove unused files --- packages/excalidraw/main.js | 5 ----- packages/excalidraw/tsconfig-types.json | 20 -------------------- 2 files changed, 25 deletions(-) delete mode 100644 packages/excalidraw/main.js delete mode 100644 packages/excalidraw/tsconfig-types.json diff --git a/packages/excalidraw/main.js b/packages/excalidraw/main.js deleted file mode 100644 index 56e511b25..000000000 --- a/packages/excalidraw/main.js +++ /dev/null @@ -1,5 +0,0 @@ -if (process.env.NODE_ENV !== "development") { - import("./dist/dev/index.js"); -} else { - import("./dist/prod/index.js"); -} diff --git a/packages/excalidraw/tsconfig-types.json b/packages/excalidraw/tsconfig-types.json deleted file mode 100644 index 3f9b8c6ce..000000000 --- a/packages/excalidraw/tsconfig-types.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "include": ["./**/*"], - "exclude": ["**/*.test.*", "tests", "types"], - "compilerOptions": { - "types": ["vite/client", "vite-plugin-svgr/client"], - "allowJs": true, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "types", - "jsx": "react-jsx", - "target": "es6", - "lib": ["dom", "dom.iterable", "esnext"], - "module": "ESNext", - "moduleResolution": "node", - "resolveJsonModule": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true - } -} From 4249b7dec888711796fac1b043b3e982b35fa647 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 4 Jan 2024 13:53:19 +0530 Subject: [PATCH 019/112] chore: add version for excalidraw-app workspace (#7514) --- excalidraw-app/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json index f9a59a40c..7d602d03a 100644 --- a/excalidraw-app/package.json +++ b/excalidraw-app/package.json @@ -1,4 +1,8 @@ { + "name": "excalidraw-app", + "version": "1.0.0", + "private": true, + "homepage": ".", "browserslist": { "production": [ ">0.2%", @@ -22,17 +26,13 @@ "node": ">=18.0.0" }, "dependencies": {}, - "homepage": ".", - "name": "excalidraw-app", "prettier": "@excalidraw/prettier-config", - "private": true, "scripts": { "build-node": "node ./scripts/build-node.js", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build", "build:version": "node ../scripts/build-version.js", "build": "yarn build:app && yarn build:version", - "install:deps": "yarn install --frozen-lockfile && yarn --cwd ../", "start": "yarn && vite", "start:production": "npm run build && npx http-server build -a localhost -p 5001 -o", "build:preview": "yarn build && vite preview --port 5000" From 43ccc875fb1658383cdb29a9ac65517f4e0584e0 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 4 Jan 2024 13:27:52 +0100 Subject: [PATCH 020/112] feat: support multi-embed pasting & x.com domain (#7516) --- packages/excalidraw/components/App.tsx | 56 +++++++++++++++++----- packages/excalidraw/element/embeddable.ts | 22 +++++---- packages/excalidraw/element/textElement.ts | 8 ++-- packages/excalidraw/utils.ts | 4 ++ 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 069392a2d..b439907e9 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -182,6 +182,7 @@ import { ExcalidrawIframeLikeElement, IframeData, ExcalidrawIframeElement, + ExcalidrawEmbeddableElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -271,11 +272,12 @@ import { easeOut, updateStable, addEventListener, + normalizeEOL, } from "../utils"; import { createSrcDoc, embeddableURLValidator, - extractSrc, + maybeParseEmbedSrc, getEmbedLink, } from "../element/embeddable"; import { @@ -2924,21 +2926,49 @@ class App extends React.Component { retainSeed: isPlainPaste, }); } else if (data.text) { - const maybeUrl = extractSrc(data.text); + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter((string) => { + return ( + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video") + ); + }); if ( - !isPlainPaste && - embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(maybeUrl) || - getEmbedLink(maybeUrl)?.type === "video") + !IS_PLAIN_PASTE && + embbeddableUrls.length > 0 && + // if there were non-embeddable text (lines) mixed in with embeddable + // urls, ignore and paste as text + embbeddableUrls.length === nonEmptyLines.length ) { - const embeddable = this.insertEmbeddableElement({ - sceneX, - sceneY, - link: normalizeLink(maybeUrl), - }); - if (embeddable) { - this.setState({ selectedElementIds: { [embeddable.id]: true } }); + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); } return; } diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index c129d3927..025ed4901 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -32,9 +32,9 @@ const RE_GH_GIST_EMBED = /^ twitter embeds -const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/; +const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/; const RE_TWITTER_EMBED = - /^ { - const twitterMatch = htmlString.match(RE_TWITTER_EMBED); +export const maybeParseEmbedSrc = (str: string): string => { + const twitterMatch = str.match(RE_TWITTER_EMBED); if (twitterMatch && twitterMatch.length === 2) { return twitterMatch[1]; } - const gistMatch = htmlString.match(RE_GH_GIST_EMBED); + const gistMatch = str.match(RE_GH_GIST_EMBED); if (gistMatch && gistMatch.length === 2) { return gistMatch[1]; } - if (RE_GIPHY.test(htmlString)) { - return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`; + if (RE_GIPHY.test(str)) { + return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`; } - const match = htmlString.match(RE_GENERIC_EMBED); + const match = str.match(RE_GENERIC_EMBED); if (match && match.length === 2) { return match[1]; } - return htmlString; + return str; }; export const embeddableURLValidator = ( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index f812b8577..e084dfba3 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,4 +1,4 @@ -import { getFontString, arrayToMap, isTestEnv } from "../utils"; +import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { ExcalidrawElement, ExcalidrawElementType, @@ -39,15 +39,13 @@ import { ExtractSetType } from "../utility-types"; export const normalizeText = (text: string) => { return ( - text + normalizeEOL(text) // replace tabs with spaces so they render and measure correctly .replace(/\t/g, " ") - // normalize newlines - .replace(/\r?\n|\r/g, "\n") ); }; -export const splitIntoLines = (text: string) => { +const splitIntoLines = (text: string) => { return normalizeText(text).split("\n"); }; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 8b39ba6bd..1f0e31760 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1071,3 +1071,7 @@ export function addEventListener( target?.removeEventListener?.(type, listener, options); }; } + +export const normalizeEOL = (str: string) => { + return str.replace(/\r?\n|\r/g, "\n"); +}; From 1cb350b2aa90c37cd86c8e9e40738babbae4e0bc Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:57:31 +0100 Subject: [PATCH 021/112] feat: update X brand logo & tweak labels (#7518) --- packages/excalidraw/components/icons.tsx | 7 +- .../components/main-menu/DefaultItems.tsx | 56 ++++++------ packages/excalidraw/locales/en.json | 4 +- .../__snapshots__/excalidraw.test.tsx.snap | 91 ++++++++++--------- 4 files changed, 84 insertions(+), 74 deletions(-) diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 62fc39574..fcf8df4a6 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -486,10 +486,11 @@ export const DiscordIcon = createIcon( modifiedTablerIconProps, ); -export const TwitterIcon = createIcon( +export const XBrandIcon = createIcon( - - + + + , tablerIconProps, ); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 9191bbe83..cc74e4c74 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -17,7 +17,7 @@ import { TrashIcon, usersIcon, } from "../icons"; -import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons"; +import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; import { @@ -241,31 +241,35 @@ export const Export = () => { }; Export.displayName = "Export"; -export const Socials = () => ( - <> - - GitHub - - - Discord - - - Twitter - - -); +export const Socials = () => { + const { t } = useI18n(); + + return ( + <> + + GitHub + + + {t("labels.followUs")} + + + {t("labels.discordChat")} + + + ); +}; Socials.displayName = "Socials"; export const LiveCollaborationTrigger = ({ diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index a57b823d4..52305535a 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -138,7 +138,9 @@ "removeAllElementsFromFrame": "Remove all elements from frame", "eyeDropper": "Pick color from canvas", "textToDiagram": "Text to diagram", - "prompt": "Prompt" + "prompt": "Prompt", + "followUs": "Follow us", + "discordChat": "Discord chat" }, "library": { "noItems": "No items added yet...", diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 39aed3745..c06fff7e4 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -386,6 +386,52 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende GitHub
+ + + + > Test UIOptions prop > Test canvasActions > should rende - - - - From 8b993d409ee444cc531a744e1d89ae0dac67e88a Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 4 Jan 2024 19:03:04 +0100 Subject: [PATCH 022/112] feat: render embeds lazily (#7519) --- packages/excalidraw/components/App.tsx | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b439907e9..5b0a3b593 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -526,6 +526,7 @@ class App extends React.Component { public files: BinaryFiles = {}; public imageCache: AppClassProperties["imageCache"] = new Map(); private iFrameRefs = new Map(); + private initializedEmbeds = new Set(); hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; @@ -897,6 +898,23 @@ class App extends React.Component { this.state, ); + const isVisible = isElementInViewport( + el, + normalizedWidth, + normalizedHeight, + this.state, + ); + const hasBeenInitialized = this.initializedEmbeds.has(el.id); + + if (isVisible && !hasBeenInitialized) { + this.initializedEmbeds.add(el.id); + } + const shouldRender = isVisible || hasBeenInitialized; + + if (!shouldRender) { + return null; + } + let src: IframeData | null; if (isIframeElement(el)) { @@ -1038,14 +1056,6 @@ class App extends React.Component { src = getEmbedLink(toValidURL(el.link || "")); } - // console.log({ src }); - - const isVisible = isElementInViewport( - el, - normalizedWidth, - normalizedHeight, - this.state, - ); const isActive = this.state.activeEmbeddable?.element === el && this.state.activeEmbeddable?.state === "active"; From 65047cc2cb59843a4305f9088d886edd9b933585 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 8 Jan 2024 21:01:47 +0530 Subject: [PATCH 023/112] fix: decouple react and react-dom imports from utils and make it treeshakeable (#7527) fix: decouple react and react-dom imports from utils and make it tree-shakeable --- excalidraw-app/collab/Collab.tsx | 2 +- packages/excalidraw/components/App.tsx | 3 +- .../components/canvases/InteractiveCanvas.tsx | 7 +-- .../components/canvases/StaticCanvas.tsx | 3 +- packages/excalidraw/example/App.tsx | 8 +-- packages/excalidraw/reactUtils.ts | 61 +++++++++++++++++++ packages/excalidraw/utils.ts | 59 ------------------ 7 files changed, 69 insertions(+), 74 deletions(-) create mode 100644 packages/excalidraw/reactUtils.ts diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 9b26af054..92d94dbc9 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -22,7 +22,6 @@ import { preventUnload, resolvablePromise, throttleRAF, - withBatchedUpdates, } from "../../packages/excalidraw/utils"; import { CURSOR_SYNC_TIMEOUT, @@ -83,6 +82,7 @@ import { atom, useAtom } from "jotai"; import { appJotaiStore } from "../app-jotai"; import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; +import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 5b0a3b593..792db29f7 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -259,9 +259,7 @@ import { sceneCoordsToViewportCoords, tupleToCoors, viewportCoordsToSceneCoords, - withBatchedUpdates, wrapEvent, - withBatchedUpdatesThrottled, updateObject, updateActiveTool, getShortcutKey, @@ -403,6 +401,7 @@ import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; +import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 5a524921a..0aaa52c7c 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -1,10 +1,6 @@ import React, { useEffect, useRef } from "react"; import { renderInteractiveScene } from "../../renderer/renderScene"; -import { - isRenderThrottlingEnabled, - isShallowEqual, - sceneCoordsToViewportCoords, -} from "../../utils"; +import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils"; import { CURSOR_TYPE } from "../../constants"; import { t } from "../../i18n"; import type { DOMAttributes } from "react"; @@ -14,6 +10,7 @@ import type { RenderInteractiveSceneCallback, } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; +import { isRenderThrottlingEnabled } from "../../reactUtils"; type InteractiveCanvasProps = { containerRef: React.RefObject; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 38b9baade..c8174566b 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; import { renderStaticScene } from "../../renderer/renderScene"; -import { isRenderThrottlingEnabled, isShallowEqual } from "../../utils"; +import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; import type { StaticCanvasRenderConfig } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; +import { isRenderThrottlingEnabled } from "../../reactUtils"; type StaticCanvasProps = { canvas: HTMLCanvasElement; diff --git a/packages/excalidraw/example/App.tsx b/packages/excalidraw/example/App.tsx index 4a5160788..50dc5b9a3 100644 --- a/packages/excalidraw/example/App.tsx +++ b/packages/excalidraw/example/App.tsx @@ -5,12 +5,7 @@ import type * as TExcalidraw from "../index"; import "./App.scss"; import initialData from "./initialData"; import { nanoid } from "nanoid"; -import { - resolvablePromise, - ResolvablePromise, - withBatchedUpdates, - withBatchedUpdatesThrottled, -} from "../utils"; +import { resolvablePromise, ResolvablePromise } from "../utils"; import { EVENT, ROUNDNESS } from "../constants"; import { distance2d } from "../math"; import { fileOpen } from "../data/filesystem"; @@ -29,6 +24,7 @@ import { ImportedLibraryData } from "../data/types"; import CustomFooter from "./CustomFooter"; import MobileFooter from "./MobileFooter"; import { KEYS } from "../keys"; +import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; declare global { interface Window { diff --git a/packages/excalidraw/reactUtils.ts b/packages/excalidraw/reactUtils.ts new file mode 100644 index 000000000..535302d42 --- /dev/null +++ b/packages/excalidraw/reactUtils.ts @@ -0,0 +1,61 @@ +/** + * @param func handler taking at most single parameter (event). + */ + +import { unstable_batchedUpdates } from "react-dom"; +import { version as ReactVersion } from "react"; +import { throttleRAF } from "./utils"; + +export const withBatchedUpdates = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => + ((event) => { + unstable_batchedUpdates(func as TFunction, event); + }) as TFunction; + +/** + * barches React state updates and throttles the calls to a single call per + * animation frame + */ +export const withBatchedUpdatesThrottled = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => { + // @ts-ignore + return throttleRAF>(((event) => { + unstable_batchedUpdates(func, event); + }) as TFunction); +}; + +export const isRenderThrottlingEnabled = (() => { + // we don't want to throttle in react < 18 because of #5439 and it was + // getting more complex to maintain the fix + let IS_REACT_18_AND_UP: boolean; + try { + const version = ReactVersion.split("."); + IS_REACT_18_AND_UP = Number(version[0]) > 17; + } catch { + IS_REACT_18_AND_UP = false; + } + + let hasWarned = false; + + return () => { + if (window.EXCALIDRAW_THROTTLE_RENDER === true) { + if (!IS_REACT_18_AND_UP) { + if (!hasWarned) { + hasWarned = true; + console.warn( + "Excalidraw: render throttling is disabled on React versions < 18.", + ); + } + return false; + } + return true; + } + return false; + }; +})(); diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 1f0e31760..c2afedb32 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -14,9 +14,7 @@ import { UnsubscribeCallback, Zoom, } from "./types"; -import { unstable_batchedUpdates } from "react-dom"; import { ResolutionType } from "./utility-types"; -import React from "react"; let mockDateTime: string | null = null; @@ -555,33 +553,6 @@ export const resolvablePromise = () => { return promise as ResolvablePromise; }; -/** - * @param func handler taking at most single parameter (event). - */ -export const withBatchedUpdates = < - TFunction extends ((event: any) => void) | (() => void), ->( - func: Parameters["length"] extends 0 | 1 ? TFunction : never, -) => - ((event) => { - unstable_batchedUpdates(func as TFunction, event); - }) as TFunction; - -/** - * barches React state updates and throttles the calls to a single call per - * animation frame - */ -export const withBatchedUpdatesThrottled = < - TFunction extends ((event: any) => void) | (() => void), ->( - func: Parameters["length"] extends 0 | 1 ? TFunction : never, -) => { - // @ts-ignore - return throttleRAF>(((event) => { - unstable_batchedUpdates(func, event); - }) as TFunction); -}; - //https://stackoverflow.com/a/9462382/8418 export const nFormatter = (num: number, digits: number): string => { const si = [ @@ -939,36 +910,6 @@ export const memoize = , R extends any>( return ret as typeof func & { clear: () => void }; }; -export const isRenderThrottlingEnabled = (() => { - // we don't want to throttle in react < 18 because of #5439 and it was - // getting more complex to maintain the fix - let IS_REACT_18_AND_UP: boolean; - try { - const version = React.version.split("."); - IS_REACT_18_AND_UP = Number(version[0]) > 17; - } catch { - IS_REACT_18_AND_UP = false; - } - - let hasWarned = false; - - return () => { - if (window.EXCALIDRAW_THROTTLE_RENDER === true) { - if (!IS_REACT_18_AND_UP) { - if (!hasWarned) { - hasWarned = true; - console.warn( - "Excalidraw: render throttling is disabled on React versions < 18.", - ); - } - return false; - } - return true; - } - return false; - }; -})(); - /** Checks if value is inside given collection. Useful for type-safety. */ export const isMemberOf = ( /** Set/Map/Array/Object */ From 1aaa40087606570ac28f17362422b42714c54a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=91CAT?= Date: Thu, 11 Jan 2024 20:09:33 +0900 Subject: [PATCH 024/112] docs: fix extra space in UIOptions/tools (#7537) fix typo in UIOptions/tools --- dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx index 5c2c40ccb..9d77e390a 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ui-options.mdx @@ -73,9 +73,9 @@ function App() { ## tools -This `prop ` controls the visibility of the tools in the editor. +This `prop` controls the visibility of the tools in the editor. Currently you can control the visibility of `image` tool via this prop. | Prop | Type | Default | Description | | --- | --- | --- | --- | -| image | boolean | true | Decides whether `image` tool should be visible. \ No newline at end of file +| image | boolean | true | Decides whether `image` tool should be visible. From 3ecf72a50750e04b28c62c45ce8bf1388b9d9020 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 11 Jan 2024 16:40:45 +0530 Subject: [PATCH 025/112] docs: add changelog for ESM build (#7542) * docs: add changelog for ESM build * move to breaking change --- packages/excalidraw/CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 652c77848..3fd3f3783 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,38 @@ Please add the latest change on the top under the correct section. ### Breaking Changes +- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed + + #### Bundler + + - CSS needs to be imported so you will need to import the css along with the excalidraw component + + ```js + import { Excalidraw } from "@excalidraw/excalidraw"; + import "@excalidraw/excalidraw/index.css"; + ``` + + - The `types` path is updated + + Instead of importing from `@excalidraw/excalidraw/types/`, you will need to import from `@excalidraw/excalidraw/dist/excalidraw` or `@excalidraw/excalidraw/dist/utils` depending on the types you are using. + + However this we will be fixing before stable release, so in case you want to try it out you will need to update the types for now. + + #### Browser + + - Since its `ESM` so now script type `module` can be used to load it and css needs to be loaded as well. + + ```html + + + ``` + - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) ## 0.17.1 (2023-11-28) From 872973f145267a3a17d95d611baa7e3613ee293c Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jan 2024 16:00:07 +0100 Subject: [PATCH 026/112] fix: do not modify elements while erasing (#7531) --- packages/excalidraw/components/App.tsx | 168 +++++++----------- packages/excalidraw/renderer/renderElement.ts | 35 +++- packages/excalidraw/scene/export.ts | 1 + packages/excalidraw/scene/types.ts | 2 + packages/excalidraw/types.ts | 8 +- 5 files changed, 101 insertions(+), 113 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 792db29f7..2d8967a4c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -57,7 +57,6 @@ import { DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, - ELEMENT_READY_TO_ERASE_OPACITY, ELEMENT_SHIFT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT, ENV, @@ -247,6 +246,7 @@ import { ToolType, OnUserFollowedPayload, UnsubscribeCallback, + ElementsPendingErasure, } from "../types"; import { debounce, @@ -402,6 +402,7 @@ import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { getRenderOpacity } from "../renderer/renderElement"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -527,6 +528,8 @@ class App extends React.Component { private iFrameRefs = new Map(); private initializedEmbeds = new Set(); + private elementsPendingErasure: ElementsPendingErasure = new Set(); + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = @@ -1075,7 +1078,11 @@ class App extends React.Component { }px) scale(${scale})` : "none", display: isVisible ? "block" : "none", - opacity: el.opacity / 100, + opacity: getRenderOpacity( + el, + getContainingFrame(el), + this.elementsPendingErasure, + ), ["--embeddable-radius" as string]: `${getCornerRadius( Math.min(el.width, el.height), el, @@ -1583,6 +1590,7 @@ class App extends React.Component { renderGrid: true, canvasBackgroundColor: this.state.viewBackgroundColor, + elementsPendingErasure: this.elementsPendingErasure, }} /> { pointerDownState: PointerDownState, scenePointer: { x: number; y: number }, ) => { - const updateElementIds = (elements: ExcalidrawElement[]) => { - elements.forEach((element) => { + let didChange = false; + + const processElements = (elements: ExcalidrawElement[]) => { + for (const element of elements) { if (element.locked) { return; } - idsToUpdate.push(element.id); if (event.altKey) { - if ( - pointerDownState.elementIdsToErase[element.id] && - pointerDownState.elementIdsToErase[element.id].erase - ) { - pointerDownState.elementIdsToErase[element.id].erase = false; + if (this.elementsPendingErasure.delete(element.id)) { + didChange = true; } - } else if (!pointerDownState.elementIdsToErase[element.id]) { - pointerDownState.elementIdsToErase[element.id] = { - erase: true, - opacity: element.opacity, - }; + } else if (!this.elementsPendingErasure.has(element.id)) { + didChange = true; + this.elementsPendingErasure.add(element.id); } - }); + } }; - const idsToUpdate: Array = []; - const distance = distance2d( pointerDownState.lastCoords.x, pointerDownState.lastCoords.y, @@ -5098,7 +5100,7 @@ class App extends React.Component { let samplingInterval = 0; while (samplingInterval <= distance) { const hitElements = this.getElementsAtPosition(point.x, point.y); - updateElementIds(hitElements); + processElements(hitElements); // Exit since we reached current point if (samplingInterval === distance) { @@ -5117,35 +5119,31 @@ class App extends React.Component { point.y = nextY; } - const elements = this.scene.getElementsIncludingDeleted().map((ele) => { - const id = - isBoundToContainer(ele) && idsToUpdate.includes(ele.containerId) - ? ele.containerId - : ele.id; - if (idsToUpdate.includes(id)) { - if (event.altKey) { - if ( - pointerDownState.elementIdsToErase[id] && - pointerDownState.elementIdsToErase[id].erase === false - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[id].opacity, - }); - } - } else { - return newElementWith(ele, { - opacity: ELEMENT_READY_TO_ERASE_OPACITY, - }); - } - } - return ele; - }); - - this.scene.replaceAllElements(elements); - pointerDownState.lastCoords.x = scenePointer.x; pointerDownState.lastCoords.y = scenePointer.y; + + if (didChange) { + for (const element of this.scene.getNonDeletedElements()) { + if ( + isBoundToContainer(element) && + (this.elementsPendingErasure.has(element.id) || + this.elementsPendingErasure.has(element.containerId)) + ) { + if (event.altKey) { + this.elementsPendingErasure.delete(element.id); + this.elementsPendingErasure.delete(element.containerId); + } else { + this.elementsPendingErasure.add(element.id); + this.elementsPendingErasure.add(element.containerId); + } + } + } + + this.elementsPendingErasure = new Set(this.elementsPendingErasure); + this.onSceneUpdated(); + } }; + // set touch moving for mobile context menu private handleTouchMove = (event: React.TouchEvent) => { invalidateContextMenu = true; @@ -5831,7 +5829,6 @@ class App extends React.Component { boxSelection: { hasOccurred: false, }, - elementIdsToErase: {}, }; } @@ -7815,18 +7812,14 @@ class App extends React.Component { scenePointer.x, scenePointer.y, ); - hitElements.forEach( - (hitElement) => - (pointerDownState.elementIdsToErase[hitElement.id] = { - erase: true, - opacity: hitElement.opacity, - }), + hitElements.forEach((hitElement) => + this.elementsPendingErasure.add(hitElement.id), ); } - this.eraseElements(pointerDownState); + this.eraseElements(); return; - } else if (Object.keys(pointerDownState.elementIdsToErase).length) { - this.restoreReadyToEraseElements(pointerDownState); + } else if (this.elementsPendingErasure.size) { + this.restoreReadyToEraseElements(); } if ( @@ -8087,65 +8080,32 @@ class App extends React.Component { }); } - private restoreReadyToEraseElements = ( - pointerDownState: PointerDownState, - ) => { - const elements = this.scene.getElementsIncludingDeleted().map((ele) => { - if ( - pointerDownState.elementIdsToErase[ele.id] && - pointerDownState.elementIdsToErase[ele.id].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.id].opacity, - }); - } else if ( - isBoundToContainer(ele) && - pointerDownState.elementIdsToErase[ele.containerId] && - pointerDownState.elementIdsToErase[ele.containerId].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.containerId].opacity, - }); - } else if ( - ele.frameId && - pointerDownState.elementIdsToErase[ele.frameId] && - pointerDownState.elementIdsToErase[ele.frameId].erase - ) { - return newElementWith(ele, { - opacity: pointerDownState.elementIdsToErase[ele.frameId].opacity, - }); - } - return ele; - }); - - this.scene.replaceAllElements(elements); + private restoreReadyToEraseElements = () => { + this.elementsPendingErasure = new Set(); + this.onSceneUpdated(); }; - private eraseElements = (pointerDownState: PointerDownState) => { + private eraseElements = () => { + let didChange = false; const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( - pointerDownState.elementIdsToErase[ele.id] && - pointerDownState.elementIdsToErase[ele.id].erase - ) { - return newElementWith(ele, { isDeleted: true }); - } else if ( - isBoundToContainer(ele) && - pointerDownState.elementIdsToErase[ele.containerId] && - pointerDownState.elementIdsToErase[ele.containerId].erase - ) { - return newElementWith(ele, { isDeleted: true }); - } else if ( - ele.frameId && - pointerDownState.elementIdsToErase[ele.frameId] && - pointerDownState.elementIdsToErase[ele.frameId].erase + this.elementsPendingErasure.has(ele.id) || + (ele.frameId && this.elementsPendingErasure.has(ele.frameId)) || + (isBoundToContainer(ele) && + this.elementsPendingErasure.has(ele.containerId)) ) { + didChange = true; return newElementWith(ele, { isDeleted: true }); } return ele; }); - this.history.resumeRecording(); - this.scene.replaceAllElements(elements); + this.elementsPendingErasure = new Set(); + + if (didChange) { + this.history.resumeRecording(); + this.scene.replaceAllElements(elements); + } }; private initializeImage = async ({ diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 2617d4694..94eda49f9 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -5,6 +5,7 @@ import { ExcalidrawFreeDrawElement, ExcalidrawImageElement, ExcalidrawTextElementWithContainer, + ExcalidrawFrameLikeElement, } from "../element/types"; import { isTextElement, @@ -36,10 +37,12 @@ import { BinaryFiles, Zoom, InteractiveCanvasAppState, + ElementsPendingErasure, } from "../types"; import { getDefaultAppState } from "../appState"; import { BOUND_TEXT_PADDING, + ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, @@ -94,6 +97,27 @@ const shouldResetImageFilter = ( const getCanvasPadding = (element: ExcalidrawElement) => element.type === "freedraw" ? element.strokeWidth * 12 : 20; +export const getRenderOpacity = ( + element: ExcalidrawElement, + containingFrame: ExcalidrawFrameLikeElement | null, + elementsPendingErasure: ElementsPendingErasure, +) => { + // multiplying frame opacity with element opacity to combine them + // (e.g. frame 50% and element 50% opacity should result in 25% opacity) + let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000; + + // if pending erasure, multiply again to combine further + // (so that erasing always results in lower opacity than original) + if ( + elementsPendingErasure.has(element.id) || + (containingFrame && elementsPendingErasure.has(containingFrame.id)) + ) { + opacity *= ELEMENT_READY_TO_ERASE_OPACITY / 100; + } + + return opacity; +}; + export interface ExcalidrawElementWithCanvas { element: ExcalidrawElement | ExcalidrawTextElement; canvas: HTMLCanvasElement; @@ -269,8 +293,6 @@ const drawElementOnCanvas = ( renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { - context.globalAlpha = - ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; switch (element.type) { case "rectangle": case "iframe": @@ -372,7 +394,6 @@ const drawElementOnCanvas = ( } } } - context.globalAlpha = 1; }; export const elementWithCanvasCache = new WeakMap< @@ -595,6 +616,12 @@ export const renderElement = ( renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { + context.globalAlpha = getRenderOpacity( + element, + getContainingFrame(element), + renderConfig.elementsPendingErasure, + ); + switch (element.type) { case "magicframe": case "frame": { @@ -831,6 +858,8 @@ export const renderElement = ( throw new Error(`Unimplemented type ${element.type}`); } } + + context.globalAlpha = 1; }; const roughSVGDrawWithPrecision = ( diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9bfab7e77..6220c59da 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -266,6 +266,7 @@ export const exportToCanvas = async ( imageCache, renderGrid: false, isExporting: true, + elementsPendingErasure: new Set(), }, }); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index b4320866c..401ab86d5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -7,6 +7,7 @@ import { import { AppClassProperties, AppState, + ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, } from "../types"; @@ -20,6 +21,7 @@ export type StaticCanvasRenderConfig = { /** when exporting the behavior is slightly different (e.g. we can't use CSS filters), and we disable render optimizations for best output */ isExporting: boolean; + elementsPendingErasure: ElementsPendingErasure; }; export type SVGRenderConfig = { diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2ba9bd68d..3da06bec4 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -633,12 +633,6 @@ export type PointerDownState = Readonly<{ boxSelection: { hasOccurred: boolean; }; - elementIdsToErase: { - [key: ExcalidrawElement["id"]]: { - opacity: ExcalidrawElement["opacity"]; - erase: boolean; - }; - }; }>; export type UnsubscribeCallback = () => void; @@ -751,3 +745,5 @@ export type Primitive = | undefined; export type JSONValue = string | number | boolean | null | object; + +export type ElementsPendingErasure = Set; From 86cfeb714c63bf7efccf5c26bf1f30522395f5db Mon Sep 17 00:00:00 2001 From: Are Date: Thu, 11 Jan 2024 17:10:15 +0100 Subject: [PATCH 027/112] feat: add eraser tool trail (#7511) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/animated-trail.ts | 148 +++++++++ .../excalidraw/animation-frame-handler.ts | 79 +++++ packages/excalidraw/components/App.tsx | 59 +++- .../{LaserTool => }/LaserPointerButton.tsx | 6 +- .../components/LaserTool/LaserPathManager.ts | 310 ------------------ .../components/LaserTool/LaserTool.tsx | 27 -- packages/excalidraw/components/LayerUI.tsx | 2 +- .../LaserToolOverlay.scss => SVGLayer.scss} | 6 +- packages/excalidraw/components/SVGLayer.tsx | 33 ++ packages/excalidraw/laser-trails.ts | 124 +++++++ packages/excalidraw/package.json | 2 +- packages/excalidraw/utils.ts | 34 ++ yarn.lock | 8 +- 13 files changed, 482 insertions(+), 356 deletions(-) create mode 100644 packages/excalidraw/animated-trail.ts create mode 100644 packages/excalidraw/animation-frame-handler.ts rename packages/excalidraw/components/{LaserTool => }/LaserPointerButton.tsx (87%) delete mode 100644 packages/excalidraw/components/LaserTool/LaserPathManager.ts delete mode 100644 packages/excalidraw/components/LaserTool/LaserTool.tsx rename packages/excalidraw/components/{LaserTool/LaserToolOverlay.scss => SVGLayer.scss} (80%) create mode 100644 packages/excalidraw/components/SVGLayer.tsx create mode 100644 packages/excalidraw/laser-trails.ts diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animated-trail.ts new file mode 100644 index 000000000..de5fd08fd --- /dev/null +++ b/packages/excalidraw/animated-trail.ts @@ -0,0 +1,148 @@ +import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer"; +import { AnimationFrameHandler } from "./animation-frame-handler"; +import { AppState } from "./types"; +import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils"; +import type App from "./components/App"; +import { SVG_NS } from "./constants"; + +export interface Trail { + start(container: SVGSVGElement): void; + stop(): void; + + startPath(x: number, y: number): void; + addPointToPath(x: number, y: number): void; + endPath(): void; +} + +export interface AnimatedTrailOptions { + fill: (trail: AnimatedTrail) => string; +} + +export class AnimatedTrail implements Trail { + private currentTrail?: LaserPointer; + private pastTrails: LaserPointer[] = []; + + private container?: SVGSVGElement; + private trailElement: SVGPathElement; + + constructor( + private animationFrameHandler: AnimationFrameHandler, + private app: App, + private options: Partial & + Partial, + ) { + this.animationFrameHandler.register(this, this.onFrame.bind(this)); + + this.trailElement = document.createElementNS(SVG_NS, "path"); + } + + get hasCurrentTrail() { + return !!this.currentTrail; + } + + hasLastPoint(x: number, y: number) { + if (this.currentTrail) { + const len = this.currentTrail.originalPoints.length; + return ( + this.currentTrail.originalPoints[len - 1][0] === x && + this.currentTrail.originalPoints[len - 1][1] === y + ); + } + + return false; + } + + start(container?: SVGSVGElement) { + if (container) { + this.container = container; + } + + if (this.trailElement.parentNode !== this.container && this.container) { + this.container.appendChild(this.trailElement); + } + + this.animationFrameHandler.start(this); + } + + stop() { + this.animationFrameHandler.stop(this); + + if (this.trailElement.parentNode === this.container) { + this.container?.removeChild(this.trailElement); + } + } + + startPath(x: number, y: number) { + this.currentTrail = new LaserPointer(this.options); + + this.currentTrail.addPoint([x, y, performance.now()]); + + this.update(); + } + + addPointToPath(x: number, y: number) { + if (this.currentTrail) { + this.currentTrail.addPoint([x, y, performance.now()]); + this.update(); + } + } + + endPath() { + if (this.currentTrail) { + this.currentTrail.close(); + this.currentTrail.options.keepHead = false; + this.pastTrails.push(this.currentTrail); + this.currentTrail = undefined; + this.update(); + } + } + + private update() { + this.start(); + } + + private onFrame() { + const paths: string[] = []; + + for (const trail of this.pastTrails) { + paths.push(this.drawTrail(trail, this.app.state)); + } + + if (this.currentTrail) { + const currentPath = this.drawTrail(this.currentTrail, this.app.state); + + paths.push(currentPath); + } + + this.pastTrails = this.pastTrails.filter((trail) => { + return trail.getStrokeOutline().length !== 0; + }); + + if (paths.length === 0) { + this.stop(); + } + + const svgPaths = paths.join(" ").trim(); + + this.trailElement.setAttribute("d", svgPaths); + this.trailElement.setAttribute( + "fill", + (this.options.fill ?? (() => "black"))(this), + ); + } + + private drawTrail(trail: LaserPointer, state: AppState): string { + const stroke = trail + .getStrokeOutline(trail.options.size / state.zoom.value) + .map(([x, y]) => { + const result = sceneCoordsToViewportCoords( + { sceneX: x, sceneY: y }, + state, + ); + + return [result.x, result.y]; + }); + + return getSvgPathFromStroke(stroke, true); + } +} diff --git a/packages/excalidraw/animation-frame-handler.ts b/packages/excalidraw/animation-frame-handler.ts new file mode 100644 index 000000000..b1a984466 --- /dev/null +++ b/packages/excalidraw/animation-frame-handler.ts @@ -0,0 +1,79 @@ +export type AnimationCallback = (timestamp: number) => void | boolean; + +export type AnimationTarget = { + callback: AnimationCallback; + stopped: boolean; +}; + +export class AnimationFrameHandler { + private targets = new WeakMap(); + private rafIds = new WeakMap(); + + register(key: object, callback: AnimationCallback) { + this.targets.set(key, { callback, stopped: true }); + } + + start(key: object) { + const target = this.targets.get(key); + + if (!target) { + return; + } + + if (this.rafIds.has(key)) { + return; + } + + this.targets.set(key, { ...target, stopped: false }); + this.scheduleFrame(key); + } + + stop(key: object) { + const target = this.targets.get(key); + if (target && !target.stopped) { + this.targets.set(key, { ...target, stopped: true }); + } + + this.cancelFrame(key); + } + + private constructFrame(key: object): FrameRequestCallback { + return (timestamp: number) => { + const target = this.targets.get(key); + + if (!target) { + return; + } + + const shouldAbort = this.onFrame(target, timestamp); + + if (!target.stopped && !shouldAbort) { + this.scheduleFrame(key); + } else { + this.cancelFrame(key); + } + }; + } + + private scheduleFrame(key: object) { + const rafId = requestAnimationFrame(this.constructFrame(key)); + + this.rafIds.set(key, rafId); + } + + private cancelFrame(key: object) { + if (this.rafIds.has(key)) { + const rafId = this.rafIds.get(key)!; + + cancelAnimationFrame(rafId); + } + + this.rafIds.delete(key); + } + + private onFrame(target: AnimationTarget, timestamp: number): boolean { + const shouldAbort = target.callback(timestamp); + + return shouldAbort ?? false; + } +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2d8967a4c..2d88d3a63 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -384,8 +384,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { Renderer } from "../scene/Renderer"; import { ShapeCache } from "../scene/ShapeCache"; -import { LaserToolOverlay } from "./LaserTool/LaserTool"; -import { LaserPathManager } from "./LaserTool/LaserPathManager"; +import { SVGLayer } from "./SVGLayer"; import { setEraserCursor, setCursor, @@ -401,6 +400,10 @@ import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; + +import { AnimationFrameHandler } from "../animation-frame-handler"; +import { AnimatedTrail } from "../animated-trail"; +import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; @@ -537,7 +540,29 @@ class App extends React.Component { lastPointerMoveEvent: PointerEvent | null = null; lastViewportPosition = { x: 0, y: 0 }; - laserPathManager: LaserPathManager = new LaserPathManager(this); + animationFrameHandler = new AnimationFrameHandler(); + + laserTrails = new LaserTrails(this.animationFrameHandler, this); + eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, { + streamline: 0.2, + size: 5, + keepHead: true, + sizeMapping: (c) => { + const DECAY_TIME = 200; + const DECAY_LENGTH = 10; + const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + fill: () => + this.state.theme === THEME.LIGHT + ? "rgba(0, 0, 0, 0.2)" + : "rgba(255, 255, 255, 0.2)", + }); onChangeEmitter = new Emitter< [ @@ -1471,7 +1496,9 @@ class App extends React.Component {
- + {selectedElements.length === 1 && this.state.showHyperlinkPopup && ( { this.removeEventListeners(); this.scene.destroy(); this.library.destroy(); - this.laserPathManager.destroy(); + this.laserTrails.stop(); + this.eraserTrail.stop(); this.onChangeEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); @@ -2619,6 +2647,10 @@ class App extends React.Component { this.updateLanguage(); } + if (isEraserActive(prevState) && !isEraserActive(this.state)) { + this.eraserTrail.endPath(); + } + if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { this.setState({ viewModeEnabled: !!this.props.viewModeEnabled }); } @@ -5070,6 +5102,8 @@ class App extends React.Component { pointerDownState: PointerDownState, scenePointer: { x: number; y: number }, ) => { + this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y); + let didChange = false; const processElements = (elements: ExcalidrawElement[]) => { @@ -5500,7 +5534,7 @@ class App extends React.Component { this.state.activeTool.type, ); } else if (this.state.activeTool.type === "laser") { - this.laserPathManager.startPath( + this.laserTrails.startPath( pointerDownState.lastCoords.x, pointerDownState.lastCoords.y, ); @@ -5521,6 +5555,13 @@ class App extends React.Component { event, ); + if (this.state.activeTool.type === "eraser") { + this.eraserTrail.startPath( + pointerDownState.lastCoords.x, + pointerDownState.lastCoords.y, + ); + } + const onPointerMove = this.onPointerMoveFromPointerDownHandler(pointerDownState); @@ -6784,7 +6825,7 @@ class App extends React.Component { } if (this.state.activeTool.type === "laser") { - this.laserPathManager.addPointToPath(pointerCoords.x, pointerCoords.y); + this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y); } const [gridX, gridY] = getGridPoint( @@ -7793,6 +7834,8 @@ class App extends React.Component { const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent; if (isEraserActive(this.state) && pointerStart && pointerEnd) { + this.eraserTrail.endPath(); + const draggedDistance = distance2d( pointerStart.clientX, pointerStart.clientY, @@ -8041,7 +8084,7 @@ class App extends React.Component { } if (activeTool.type === "laser") { - this.laserPathManager.endPath(); + this.laserTrails.endPath(); return; } diff --git a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx b/packages/excalidraw/components/LaserPointerButton.tsx similarity index 87% rename from packages/excalidraw/components/LaserTool/LaserPointerButton.tsx rename to packages/excalidraw/components/LaserPointerButton.tsx index dbb843293..ae3cfb31a 100644 --- a/packages/excalidraw/components/LaserTool/LaserPointerButton.tsx +++ b/packages/excalidraw/components/LaserPointerButton.tsx @@ -1,8 +1,8 @@ -import "../ToolIcon.scss"; +import "./ToolIcon.scss"; import clsx from "clsx"; -import { ToolButtonSize } from "../ToolButton"; -import { laserPointerToolIcon } from "../icons"; +import { ToolButtonSize } from "./ToolButton"; +import { laserPointerToolIcon } from "./icons"; type LaserPointerIconProps = { title?: string; diff --git a/packages/excalidraw/components/LaserTool/LaserPathManager.ts b/packages/excalidraw/components/LaserTool/LaserPathManager.ts deleted file mode 100644 index b6e462aa3..000000000 --- a/packages/excalidraw/components/LaserTool/LaserPathManager.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { LaserPointer } from "@excalidraw/laser-pointer"; - -import { sceneCoordsToViewportCoords } from "../../utils"; -import App from "../App"; -import { getClientColor } from "../../clients"; -import { SocketId } from "../../types"; - -// decay time in milliseconds -const DECAY_TIME = 1000; -// length of line in points before it starts decaying -const DECAY_LENGTH = 50; - -const average = (a: number, b: number) => (a + b) / 2; -function getSvgPathFromStroke(points: number[][], closed = true) { - const len = points.length; - - if (len < 4) { - return ``; - } - - let a = points[0]; - let b = points[1]; - const c = points[2]; - - let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( - 2, - )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( - b[1], - c[1], - ).toFixed(2)} T`; - - for (let i = 2, max = len - 1; i < max; i++) { - a = points[i]; - b = points[i + 1]; - result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( - 2, - )} `; - } - - if (closed) { - result += "Z"; - } - - return result; -} - -declare global { - interface Window { - LPM: LaserPathManager; - } -} - -function easeOutCubic(t: number) { - return 1 - Math.pow(1 - t, 3); -} - -function instantiateCollabolatorState(): CollabolatorState { - return { - currentPath: undefined, - finishedPaths: [], - lastPoint: [-10000, -10000], - svg: document.createElementNS("http://www.w3.org/2000/svg", "path"), - }; -} - -function instantiatePath() { - LaserPointer.constants.cornerDetectionMaxAngle = 70; - - return new LaserPointer({ - simplify: 0, - streamline: 0.4, - sizeMapping: (c) => { - const pt = DECAY_TIME; - const pl = DECAY_LENGTH; - const t = Math.max(0, 1 - (performance.now() - c.pressure) / pt); - const l = (pl - Math.min(pl, c.totalLength - c.currentIndex)) / pl; - - return Math.min(easeOutCubic(l), easeOutCubic(t)); - }, - }); -} - -type CollabolatorState = { - currentPath: LaserPointer | undefined; - finishedPaths: LaserPointer[]; - lastPoint: [number, number]; - svg: SVGPathElement; -}; - -export class LaserPathManager { - private ownState: CollabolatorState; - private collaboratorsState: Map = new Map(); - - private rafId: number | undefined; - private isDrawing = false; - private container: SVGSVGElement | undefined; - - constructor(private app: App) { - this.ownState = instantiateCollabolatorState(); - } - - destroy() { - this.stop(); - this.isDrawing = false; - this.ownState = instantiateCollabolatorState(); - this.collaboratorsState = new Map(); - } - - startPath(x: number, y: number) { - this.ownState.currentPath = instantiatePath(); - this.ownState.currentPath.addPoint([x, y, performance.now()]); - this.updatePath(this.ownState); - } - - addPointToPath(x: number, y: number) { - if (this.ownState.currentPath) { - this.ownState.currentPath?.addPoint([x, y, performance.now()]); - this.updatePath(this.ownState); - } - } - - endPath() { - if (this.ownState.currentPath) { - this.ownState.currentPath.close(); - this.ownState.finishedPaths.push(this.ownState.currentPath); - this.updatePath(this.ownState); - } - } - - private updatePath(state: CollabolatorState) { - this.isDrawing = true; - - if (!this.isRunning) { - this.start(); - } - } - - private isRunning = false; - - start(svg?: SVGSVGElement) { - if (svg) { - this.container = svg; - this.container.appendChild(this.ownState.svg); - } - - this.stop(); - this.isRunning = true; - this.loop(); - } - - stop() { - this.isRunning = false; - if (this.rafId) { - cancelAnimationFrame(this.rafId); - } - this.rafId = undefined; - } - - loop() { - this.rafId = requestAnimationFrame(this.loop.bind(this)); - - this.updateCollabolatorsState(); - - if (this.isDrawing) { - this.update(); - } else { - this.isRunning = false; - } - } - - draw(path: LaserPointer) { - const stroke = path - .getStrokeOutline(path.options.size / this.app.state.zoom.value) - .map(([x, y]) => { - const result = sceneCoordsToViewportCoords( - { sceneX: x, sceneY: y }, - this.app.state, - ); - - return [result.x, result.y]; - }); - - return getSvgPathFromStroke(stroke, true); - } - - updateCollabolatorsState() { - if (!this.container || !this.app.state.collaborators.size) { - return; - } - - for (const [key, collabolator] of this.app.state.collaborators.entries()) { - if (!this.collaboratorsState.has(key)) { - const state = instantiateCollabolatorState(); - this.container.appendChild(state.svg); - this.collaboratorsState.set(key, state); - - this.updatePath(state); - } - - const state = this.collaboratorsState.get(key)!; - - if (collabolator.pointer && collabolator.pointer.tool === "laser") { - if (collabolator.button === "down" && state.currentPath === undefined) { - state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y]; - state.currentPath = instantiatePath(); - state.currentPath.addPoint([ - collabolator.pointer.x, - collabolator.pointer.y, - performance.now(), - ]); - - this.updatePath(state); - } - - if (collabolator.button === "down" && state.currentPath !== undefined) { - if ( - collabolator.pointer.x !== state.lastPoint[0] || - collabolator.pointer.y !== state.lastPoint[1] - ) { - state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y]; - state.currentPath.addPoint([ - collabolator.pointer.x, - collabolator.pointer.y, - performance.now(), - ]); - - this.updatePath(state); - } - } - - if (collabolator.button === "up" && state.currentPath !== undefined) { - state.lastPoint = [collabolator.pointer.x, collabolator.pointer.y]; - state.currentPath.addPoint([ - collabolator.pointer.x, - collabolator.pointer.y, - performance.now(), - ]); - state.currentPath.close(); - - state.finishedPaths.push(state.currentPath); - state.currentPath = undefined; - - this.updatePath(state); - } - } - } - } - - update() { - if (!this.container) { - return; - } - - let somePathsExist = false; - - for (const [key, state] of this.collaboratorsState.entries()) { - if (!this.app.state.collaborators.has(key)) { - state.svg.remove(); - this.collaboratorsState.delete(key); - continue; - } - - state.finishedPaths = state.finishedPaths.filter((path) => { - const lastPoint = path.originalPoints[path.originalPoints.length - 1]; - - return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME); - }); - - let paths = state.finishedPaths.map((path) => this.draw(path)).join(" "); - - if (state.currentPath) { - paths += ` ${this.draw(state.currentPath)}`; - } - - if (paths.trim()) { - somePathsExist = true; - } - - state.svg.setAttribute("d", paths); - state.svg.setAttribute("fill", getClientColor(key)); - } - - this.ownState.finishedPaths = this.ownState.finishedPaths.filter((path) => { - const lastPoint = path.originalPoints[path.originalPoints.length - 1]; - - return !(lastPoint && lastPoint[2] < performance.now() - DECAY_TIME); - }); - - let paths = this.ownState.finishedPaths - .map((path) => this.draw(path)) - .join(" "); - - if (this.ownState.currentPath) { - paths += ` ${this.draw(this.ownState.currentPath)}`; - } - - paths = paths.trim(); - - if (paths) { - somePathsExist = true; - } - - this.ownState.svg.setAttribute("d", paths); - this.ownState.svg.setAttribute("fill", "red"); - - if (!somePathsExist) { - this.isDrawing = false; - } - } -} diff --git a/packages/excalidraw/components/LaserTool/LaserTool.tsx b/packages/excalidraw/components/LaserTool/LaserTool.tsx deleted file mode 100644 index e93d72dfc..000000000 --- a/packages/excalidraw/components/LaserTool/LaserTool.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect, useRef } from "react"; -import { LaserPathManager } from "./LaserPathManager"; -import "./LaserToolOverlay.scss"; - -type LaserToolOverlayProps = { - manager: LaserPathManager; -}; - -export const LaserToolOverlay = ({ manager }: LaserToolOverlayProps) => { - const svgRef = useRef(null); - - useEffect(() => { - if (svgRef.current) { - manager.start(svgRef.current); - } - - return () => { - manager.stop(); - }; - }, [manager]); - - return ( -
- -
- ); -}; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 7dd362063..8cedf689d 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -60,7 +60,7 @@ import "./Toolbar.scss"; import { mutateElement } from "../element/mutateElement"; import { ShapeCache } from "../scene/ShapeCache"; import Scene from "../scene/Scene"; -import { LaserPointerButton } from "./LaserTool/LaserPointerButton"; +import { LaserPointerButton } from "./LaserPointerButton"; import { MagicSettings } from "./MagicSettings"; import { TTDDialog } from "./TTDDialog/TTDDialog"; diff --git a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss b/packages/excalidraw/components/SVGLayer.scss similarity index 80% rename from packages/excalidraw/components/LaserTool/LaserToolOverlay.scss rename to packages/excalidraw/components/SVGLayer.scss index da874b452..5eb0353aa 100644 --- a/packages/excalidraw/components/LaserTool/LaserToolOverlay.scss +++ b/packages/excalidraw/components/SVGLayer.scss @@ -1,5 +1,5 @@ .excalidraw { - .LaserToolOverlay { + .SVGLayer { pointer-events: none; width: 100vw; height: 100vh; @@ -9,10 +9,12 @@ z-index: 2; - .LaserToolOverlayCanvas { + & svg { image-rendering: auto; overflow: visible; position: absolute; + width: 100%; + height: 100%; top: 0; left: 0; } diff --git a/packages/excalidraw/components/SVGLayer.tsx b/packages/excalidraw/components/SVGLayer.tsx new file mode 100644 index 000000000..feaebaf94 --- /dev/null +++ b/packages/excalidraw/components/SVGLayer.tsx @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react"; +import { Trail } from "../animated-trail"; + +import "./SVGLayer.scss"; + +type SVGLayerProps = { + trails: Trail[]; +}; + +export const SVGLayer = ({ trails }: SVGLayerProps) => { + const svgRef = useRef(null); + + useEffect(() => { + if (svgRef.current) { + for (const trail of trails) { + trail.start(svgRef.current); + } + } + + return () => { + for (const trail of trails) { + trail.stop(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, trails); + + return ( +
+ +
+ ); +}; diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts new file mode 100644 index 000000000..49a0de5be --- /dev/null +++ b/packages/excalidraw/laser-trails.ts @@ -0,0 +1,124 @@ +import { LaserPointerOptions } from "@excalidraw/laser-pointer"; +import { AnimatedTrail, Trail } from "./animated-trail"; +import { AnimationFrameHandler } from "./animation-frame-handler"; +import type App from "./components/App"; +import { SocketId } from "./types"; +import { easeOut } from "./utils"; +import { getClientColor } from "./clients"; + +export class LaserTrails implements Trail { + public localTrail: AnimatedTrail; + private collabTrails = new Map(); + + private container?: SVGSVGElement; + + constructor( + private animationFrameHandler: AnimationFrameHandler, + private app: App, + ) { + this.animationFrameHandler.register(this, this.onFrame.bind(this)); + + this.localTrail = new AnimatedTrail(animationFrameHandler, app, { + ...this.getTrailOptions(), + fill: () => "red", + }); + } + + private getTrailOptions() { + return { + simplify: 0, + streamline: 0.4, + sizeMapping: (c) => { + const DECAY_TIME = 1000; + const DECAY_LENGTH = 50; + const t = Math.max( + 0, + 1 - (performance.now() - c.pressure) / DECAY_TIME, + ); + const l = + (DECAY_LENGTH - + Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) / + DECAY_LENGTH; + + return Math.min(easeOut(l), easeOut(t)); + }, + } as Partial; + } + + startPath(x: number, y: number): void { + this.localTrail.startPath(x, y); + } + + addPointToPath(x: number, y: number): void { + this.localTrail.addPointToPath(x, y); + } + + endPath(): void { + this.localTrail.endPath(); + } + + start(container: SVGSVGElement) { + this.container = container; + + this.animationFrameHandler.start(this); + this.localTrail.start(container); + } + + stop() { + this.animationFrameHandler.stop(this); + this.localTrail.stop(); + } + + onFrame() { + this.updateCollabTrails(); + } + + private updateCollabTrails() { + if (!this.container || this.app.state.collaborators.size === 0) { + return; + } + + for (const [key, collabolator] of this.app.state.collaborators.entries()) { + let trail!: AnimatedTrail; + + if (!this.collabTrails.has(key)) { + trail = new AnimatedTrail(this.animationFrameHandler, this.app, { + ...this.getTrailOptions(), + fill: () => getClientColor(key), + }); + trail.start(this.container); + + this.collabTrails.set(key, trail); + } else { + trail = this.collabTrails.get(key)!; + } + + if (collabolator.pointer && collabolator.pointer.tool === "laser") { + if (collabolator.button === "down" && !trail.hasCurrentTrail) { + trail.startPath(collabolator.pointer.x, collabolator.pointer.y); + } + + if ( + collabolator.button === "down" && + trail.hasCurrentTrail && + !trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y) + ) { + trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + } + + if (collabolator.button === "up" && trail.hasCurrentTrail) { + trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + trail.endPath(); + } + } + } + + for (const key of this.collabTrails.keys()) { + if (!this.app.state.collaborators.has(key)) { + const trail = this.collabTrails.get(key)!; + trail.stop(); + this.collabTrails.delete(key); + } + } + } +} diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 1cd837fdd..7ec828cc1 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -57,7 +57,7 @@ }, "dependencies": { "@braintree/sanitize-url": "6.0.2", - "@excalidraw/laser-pointer": "1.2.0", + "@excalidraw/laser-pointer": "1.3.1", "@excalidraw/mermaid-to-excalidraw": "0.2.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index c2afedb32..4630c5bce 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -1013,6 +1013,40 @@ export function addEventListener( }; } +const average = (a: number, b: number) => (a + b) / 2; +export function getSvgPathFromStroke(points: number[][], closed = true) { + const len = points.length; + + if (len < 4) { + return ``; + } + + let a = points[0]; + let b = points[1]; + const c = points[2]; + + let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( + 2, + )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( + b[1], + c[1], + ).toFixed(2)} T`; + + for (let i = 2, max = len - 1; i < max; i++) { + a = points[i]; + b = points[i + 1]; + result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( + 2, + )} `; + } + + if (closed) { + result += "Z"; + } + + return result; +} + export const normalizeEOL = (str: string) => { return str.replace(/\r?\n|\r/g, "\n"); }; diff --git a/yarn.lock b/yarn.lock index 87493423e..f857f7fb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2247,10 +2247,10 @@ resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.3.tgz#2122ef7413ae77874ae9848ce0f1c6b3f0d8bbbd" integrity sha512-GemHNF5Z6ga0BWBSX7GJaNBUchLu6RwTcAB84eX1MeckRNhNasAsPCdelDlFalz27iS4RuYEQh0bPE8SRxJgbQ== -"@excalidraw/laser-pointer@1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.2.0.tgz#cd34ea7d24b11743c726488cc1fcb28c161cacba" - integrity sha512-WjFFwLk9ahmKRKku7U0jqYpeM3fe9ZS1K43pfwPREHk4/FYU3iKDKVeS8m4tEAASnRlBt3hhLCBQLBF2uvgOnw== +"@excalidraw/laser-pointer@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz#7c40836598e8e6ad91f01057883ed8b88fb9266c" + integrity sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g== "@excalidraw/markdown-to-text@0.1.2": version "0.1.2" From 0c24a7042f3abdbada1281dc5dfa61160f6b881b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jan 2024 17:42:51 +0100 Subject: [PATCH 028/112] feat: remove `ExcalidrawEmbeddableElement.validated` flag (#7539) --- packages/excalidraw/CHANGELOG.md | 5 ++- packages/excalidraw/components/App.tsx | 33 +++++++++++++++---- packages/excalidraw/data/restore.ts | 5 +-- packages/excalidraw/element/Hyperlink.tsx | 15 +++++---- packages/excalidraw/element/newElement.ts | 6 +--- packages/excalidraw/element/types.ts | 8 ----- packages/excalidraw/renderer/renderScene.ts | 4 ++- packages/excalidraw/scene/Shape.ts | 25 +++++++++++--- packages/excalidraw/scene/ShapeCache.ts | 4 ++- packages/excalidraw/scene/export.ts | 17 +++++++++- packages/excalidraw/scene/types.ts | 3 ++ .../tests/fixtures/elementFixture.ts | 1 - packages/excalidraw/tests/helpers/api.ts | 1 - packages/excalidraw/types.ts | 6 ++++ 14 files changed, 94 insertions(+), 39 deletions(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 3fd3f3783..9f59bd4af 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -14,9 +14,12 @@ Please add the latest change on the top under the correct section. ## Unreleased - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) +- Remove `ExcalidrawEmbeddableElement.validated` attribute. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) ### Breaking Changes +- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) + - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed #### Bundler @@ -265,7 +268,7 @@ define: { - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Introducing Web-Embeds (alias iframe element)[#6691](https://github.com/excalidraw/excalidraw/pull/6691) -- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateEmbeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) +- Added [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) to customize embeddable src url validation. [#6691](https://github.com/excalidraw/excalidraw/pull/6691) - Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581). - Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728). - Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2d88d3a63..295c9dc08 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -246,6 +246,7 @@ import { ToolType, OnUserFollowedPayload, UnsubscribeCallback, + EmbedsValidationStatus, ElementsPendingErasure, } from "../types"; import { @@ -529,6 +530,15 @@ class App extends React.Component { public files: BinaryFiles = {}; public imageCache: AppClassProperties["imageCache"] = new Map(); private iFrameRefs = new Map(); + /** + * Indicates whether the embeddable's url has been validated for rendering. + * If value not set, indicates that the validation is pending. + * Initially or on url change the flag is not reset so that we can guarantee + * the validation came from a trusted source (the editor). + **/ + private embedsValidationStatus: EmbedsValidationStatus = new Map(); + /** embeds that have been inserted to DOM (as a perf optim, we don't want to + * insert to DOM before user initially scrolls to them) */ private initializedEmbeds = new Set(); private elementsPendingErasure: ElementsPendingErasure = new Set(); @@ -869,6 +879,14 @@ class App extends React.Component { ); } + private updateEmbedValidationStatus = ( + element: ExcalidrawEmbeddableElement, + status: boolean, + ) => { + this.embedsValidationStatus.set(element.id, status); + ShapeCache.delete(element); + }; + private updateEmbeddables = () => { const iframeLikes = new Set(); @@ -876,7 +894,7 @@ class App extends React.Component { this.scene.getNonDeletedElements().filter((element) => { if (isEmbeddableElement(element)) { iframeLikes.add(element.id); - if (element.validated == null) { + if (!this.embedsValidationStatus.has(element.id)) { updated = true; const validated = embeddableURLValidator( @@ -884,8 +902,7 @@ class App extends React.Component { this.props.validateEmbeddable, ); - mutateElement(element, { validated }, false); - ShapeCache.delete(element); + this.updateEmbedValidationStatus(element, validated); } } else if (isIframeElement(element)) { iframeLikes.add(element.id); @@ -914,7 +931,9 @@ class App extends React.Component { .getNonDeletedElements() .filter( (el): el is NonDeleted => - (isEmbeddableElement(el) && !!el.validated) || isIframeElement(el), + (isEmbeddableElement(el) && + this.embedsValidationStatus.get(el.id) === true) || + isIframeElement(el), ); return ( @@ -1507,6 +1526,9 @@ class App extends React.Component { setAppState={this.setAppState} onLinkOpen={this.props.onLinkOpen} setToast={this.setToast} + updateEmbedValidationStatus={ + this.updateEmbedValidationStatus + } /> )} {this.props.aiEnabled !== false && @@ -1617,6 +1639,7 @@ class App extends React.Component { renderGrid: true, canvasBackgroundColor: this.state.viewBackgroundColor, + embedsValidationStatus: this.embedsValidationStatus, elementsPendingErasure: this.elementsPendingErasure, }} /> @@ -6425,7 +6448,6 @@ class App extends React.Component { width: embedLink.intrinsicSize.w, height: embedLink.intrinsicSize.h, link, - validated: null, }); this.scene.replaceAllElements([ @@ -6659,7 +6681,6 @@ class App extends React.Component { if (elementType === "embeddable") { element = newEmbeddableElement({ type: "embeddable", - validated: null, ...baseElementAttributes, }); } else { diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 76a8c32ca..ff4063593 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -295,11 +295,8 @@ const restoreElement = ( case "rectangle": case "diamond": case "iframe": - return restoreElementWithProperties(element, {}); case "embeddable": - return restoreElementWithProperties(element, { - validated: null, - }); + return restoreElementWithProperties(element, {}); case "magicframe": case "frame": return restoreElementWithProperties(element, { diff --git a/packages/excalidraw/element/Hyperlink.tsx b/packages/excalidraw/element/Hyperlink.tsx index e5e7a5a14..a69fdeb83 100644 --- a/packages/excalidraw/element/Hyperlink.tsx +++ b/packages/excalidraw/element/Hyperlink.tsx @@ -39,7 +39,6 @@ import "./Hyperlink.scss"; import { trackEvent } from "../analytics"; import { useAppProps, useExcalidrawAppState } from "../components/App"; import { isEmbeddableElement } from "./typeChecks"; -import { ShapeCache } from "../scene/ShapeCache"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -64,6 +63,7 @@ export const Hyperlink = ({ setAppState, onLinkOpen, setToast, + updateEmbedValidationStatus, }: { element: NonDeletedExcalidrawElement; setAppState: React.Component["setState"]; @@ -71,6 +71,10 @@ export const Hyperlink = ({ setToast: ( toast: { message: string; closable?: boolean; duration?: number } | null, ) => void; + updateEmbedValidationStatus: ( + element: ExcalidrawEmbeddableElement, + status: boolean, + ) => void; }) => { const appState = useExcalidrawAppState(); const appProps = useAppProps(); @@ -98,9 +102,9 @@ export const Hyperlink = ({ } if (!link) { mutateElement(element, { - validated: false, link: null, }); + updateEmbedValidationStatus(element, false); return; } @@ -110,10 +114,9 @@ export const Hyperlink = ({ } element.link && embeddableLinkCache.set(element.id, element.link); mutateElement(element, { - validated: false, link, }); - ShapeCache.delete(element); + updateEmbedValidationStatus(element, false); } else { const { width, height } = element; const embedLink = getEmbedLink(link); @@ -142,10 +145,9 @@ export const Hyperlink = ({ : height, } : {}), - validated: true, link, }); - ShapeCache.delete(element); + updateEmbedValidationStatus(element, true); if (embeddableLinkCache.has(element.id)) { embeddableLinkCache.delete(element.id); } @@ -159,6 +161,7 @@ export const Hyperlink = ({ appProps.validateEmbeddable, appState.activeEmbeddable, setAppState, + updateEmbedValidationStatus, ]); useLayoutEffect(() => { diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 91b30beb7..00cae296c 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -136,13 +136,9 @@ export const newElement = ( export const newEmbeddableElement = ( opts: { type: "embeddable"; - validated: ExcalidrawEmbeddableElement["validated"]; } & ElementConstructorOpts, ): NonDeleted => { - return { - ..._newElementBase("embeddable", opts), - validated: opts.validated, - }; + return _newElementBase("embeddable", opts); }; export const newIframeElement = ( diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 38be1bda6..c468eac82 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -88,14 +88,6 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & { export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase & Readonly<{ type: "embeddable"; - /** - * indicates whether the embeddable src (url) has been validated for rendering. - * null value indicates that the validation is pending. We reset the - * value on each restore (or url change) so that we can guarantee - * the validation came from a trusted source (the editor). Also because we - * may not have access to host-app supplied url validator during restore. - */ - validated: boolean | null; }>; export type ExcalidrawIframeElement = _ExcalidrawElementBase & diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index c41d59bd3..a5b78d3b8 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -1007,7 +1007,9 @@ const _renderStaticScene = ({ if ( isIframeLikeElement(element) && (isExporting || - (isEmbeddableElement(element) && !element.validated)) && + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && element.width && element.height ) { diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 190f7562f..1d43aef71 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -21,6 +21,7 @@ import { isLinearElement, } from "../element/typeChecks"; import { canChangeRoundness } from "./comparisons"; +import { EmbedsValidationStatus } from "../types"; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; @@ -118,10 +119,13 @@ export const generateRoughOptions = ( const modifyIframeLikeForRoughOptions = ( element: NonDeletedExcalidrawElement, isExporting: boolean, + embedsValidationStatus: EmbedsValidationStatus | null, ) => { if ( isIframeLikeElement(element) && - (isExporting || (isEmbeddableElement(element) && !element.validated)) && + (isExporting || + (isEmbeddableElement(element) && + embedsValidationStatus?.get(element.id) !== true)) && isTransparent(element.backgroundColor) && isTransparent(element.strokeColor) ) { @@ -278,7 +282,12 @@ export const _generateElementShape = ( { isExporting, canvasBackgroundColor, - }: { isExporting: boolean; canvasBackgroundColor: string }, + embedsValidationStatus, + }: { + isExporting: boolean; + canvasBackgroundColor: string; + embedsValidationStatus: EmbedsValidationStatus | null; + }, ): Drawable | Drawable[] | null => { switch (element.type) { case "rectangle": @@ -299,7 +308,11 @@ export const _generateElementShape = ( h - r } L 0 ${r} Q 0 0, ${r} 0`, generateRoughOptions( - modifyIframeLikeForRoughOptions(element, isExporting), + modifyIframeLikeForRoughOptions( + element, + isExporting, + embedsValidationStatus, + ), true, ), ); @@ -310,7 +323,11 @@ export const _generateElementShape = ( element.width, element.height, generateRoughOptions( - modifyIframeLikeForRoughOptions(element, isExporting), + modifyIframeLikeForRoughOptions( + element, + isExporting, + embedsValidationStatus, + ), false, ), ); diff --git a/packages/excalidraw/scene/ShapeCache.ts b/packages/excalidraw/scene/ShapeCache.ts index e5a08c1f2..3bca88e85 100644 --- a/packages/excalidraw/scene/ShapeCache.ts +++ b/packages/excalidraw/scene/ShapeCache.ts @@ -8,7 +8,7 @@ import { elementWithCanvasCache } from "../renderer/renderElement"; import { _generateElementShape } from "./Shape"; import { ElementShape, ElementShapes } from "./types"; import { COLOR_PALETTE } from "../colors"; -import { AppState } from "../types"; +import { AppState, EmbedsValidationStatus } from "../types"; export class ShapeCache { private static rg = new RoughGenerator(); @@ -51,6 +51,7 @@ export class ShapeCache { renderConfig: { isExporting: boolean; canvasBackgroundColor: AppState["viewBackgroundColor"]; + embedsValidationStatus: EmbedsValidationStatus; } | null, ) => { // when exporting, always regenerated to guarantee the latest shape @@ -72,6 +73,7 @@ export class ShapeCache { renderConfig || { isExporting: false, canvasBackgroundColor: COLOR_PALETTE.white, + embedsValidationStatus: null, }, ) as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 6220c59da..cc84569a6 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -266,6 +266,8 @@ export const exportToCanvas = async ( imageCache, renderGrid: false, isExporting: true, + // empty disables embeddable rendering + embedsValidationStatus: new Map(), elementsPendingErasure: new Set(), }, }); @@ -288,6 +290,9 @@ export const exportToSvg = async ( }, files: BinaryFiles | null, opts?: { + /** + * if true, all embeddables passed in will be rendered when possible. + */ renderEmbeddables?: boolean; exportingFrame?: ExcalidrawFrameLikeElement | null; }, @@ -428,14 +433,24 @@ export const exportToSvg = async ( } const rsvg = rough.svg(svgRoot); + + const renderEmbeddables = opts?.renderEmbeddables ?? false; + renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, { offsetX, offsetY, isExporting: true, exportWithDarkMode, - renderEmbeddables: opts?.renderEmbeddables ?? false, + renderEmbeddables, frameRendering, canvasBackgroundColor: viewBackgroundColor, + embedsValidationStatus: renderEmbeddables + ? new Map( + elementsForRender + .filter((element) => isFrameLikeElement(element)) + .map((element) => [element.id, true]), + ) + : new Map(), }); tempScene.destroy(); diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 401ab86d5..57a52fbd4 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -7,6 +7,7 @@ import { import { AppClassProperties, AppState, + EmbedsValidationStatus, ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, @@ -21,6 +22,7 @@ export type StaticCanvasRenderConfig = { /** when exporting the behavior is slightly different (e.g. we can't use CSS filters), and we disable render optimizations for best output */ isExporting: boolean; + embedsValidationStatus: EmbedsValidationStatus; elementsPendingErasure: ElementsPendingErasure; }; @@ -32,6 +34,7 @@ export type SVGRenderConfig = { renderEmbeddables: boolean; frameRendering: AppState["frameRendering"]; canvasBackgroundColor: AppState["viewBackgroundColor"]; + embedsValidationStatus: EmbedsValidationStatus; }; export type InteractiveCanvasRenderConfig = { diff --git a/packages/excalidraw/tests/fixtures/elementFixture.ts b/packages/excalidraw/tests/fixtures/elementFixture.ts index 7f1231a83..ddd7b8b9d 100644 --- a/packages/excalidraw/tests/fixtures/elementFixture.ts +++ b/packages/excalidraw/tests/fixtures/elementFixture.ts @@ -34,7 +34,6 @@ export const rectangleFixture: ExcalidrawElement = { export const embeddableFixture: ExcalidrawElement = { ...elementBase, type: "embeddable", - validated: null, }; export const ellipseFixture: ExcalidrawElement = { ...elementBase, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index 2a18805ab..d22d3f221 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -205,7 +205,6 @@ export class API { element = newEmbeddableElement({ type: "embeddable", ...base, - validated: null, }); break; case "iframe": diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 3da06bec4..201a186ba 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -19,6 +19,7 @@ import { ExcalidrawMagicFrameElement, ExcalidrawFrameLikeElement, ExcalidrawElementType, + ExcalidrawIframeLikeElement, } from "./element/types"; import { Action } from "./actions/types"; import { Point as RoughPoint } from "roughjs/bin/geometry"; @@ -746,4 +747,9 @@ export type Primitive = export type JSONValue = string | number | boolean | null | object; +export type EmbedsValidationStatus = Map< + ExcalidrawIframeLikeElement["id"], + boolean +>; + export type ElementsPendingErasure = Set; From 5245276409b8d163c656c9d3a5e9028b8322a59b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jan 2024 17:43:04 +0100 Subject: [PATCH 029/112] feat: erase groups atomically (#7545) --- packages/excalidraw/components/App.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 295c9dc08..acbe56741 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5129,6 +5129,9 @@ class App extends React.Component { let didChange = false; + const processedGroups = new Set(); + const nonDeletedElements = this.scene.getNonDeletedElements(); + const processElements = (elements: ExcalidrawElement[]) => { for (const element of elements) { if (element.locked) { @@ -5143,6 +5146,25 @@ class App extends React.Component { didChange = true; this.elementsPendingErasure.add(element.id); } + + // (un)erase groups atomically + if (didChange && element.groupIds?.length) { + const shallowestGroupId = element.groupIds.at(-1)!; + if (!processedGroups.has(shallowestGroupId)) { + processedGroups.add(shallowestGroupId); + const elems = getElementsInGroup( + nonDeletedElements, + shallowestGroupId, + ); + for (const elem of elems) { + if (event.altKey) { + this.elementsPendingErasure.delete(elem.id); + } else { + this.elementsPendingErasure.add(elem.id); + } + } + } + } } }; From 8ead8559e059c25d93d4dea58ba9b494535faae5 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jan 2024 21:08:17 +0100 Subject: [PATCH 030/112] feat: redirect font requests to cdn (#7549) --- vercel.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vercel.json b/vercel.json index dc077e41b..b8edf9f3b 100644 --- a/vercel.json +++ b/vercel.json @@ -28,6 +28,18 @@ "source": "/webex/:match*", "destination": "https://for-webex.excalidraw.com" }, + { + "source": "/Virgil.woff2", + "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Virgil.woff2" + }, + { + "source": "/Cascadia.woff2", + "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Cascadia.woff2" + }, + { + "source": "/Assistant-Regular.woff2", + "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Assistant-Regular.woff2" + }, { "source": "/:path*", "has": [ From 41cc7468856be773e0f25271f8916a35dd3930e8 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 11 Jan 2024 21:29:29 +0100 Subject: [PATCH 031/112] fix: host font assets from root (#7548) --- public/Assistant-Regular.woff2 | Bin 0 -> 20232 bytes public/Cascadia.woff2 | Bin 0 -> 86812 bytes public/Virgil.woff2 | Bin 0 -> 61248 bytes vercel.json | 12 ------------ 4 files changed, 12 deletions(-) create mode 100644 public/Assistant-Regular.woff2 create mode 100644 public/Cascadia.woff2 create mode 100644 public/Virgil.woff2 diff --git a/public/Assistant-Regular.woff2 b/public/Assistant-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e17d6eccaa7e85cb2a516565fa415ebb46d0e57a GIT binary patch literal 20232 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∈%?q7f-cLnRe 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 TcQlRP`t>*4_1V8ZM$4EBNmmhp literal 0 HcmV?d00001 diff --git a/public/Cascadia.woff2 b/public/Cascadia.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b2eae9f40b917f56468f63766bb8aff907d9d2dc GIT binary patch literal 86812 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

H4;(BXGc?le%FC2c4K(amM!3jrZ$`#9?hR@ zxeTtbDH2EGwhV~YXx3SmNyH+kaf%w$pjkcZeoS@0J*z}&-RUE(`?Bo5xXnX{BLIWSA8CUHMJpF6@ueJjD!J|h8#NMX@tU2+K$2uRA5>o zFposT6e=(+9kvsJxj|uUF)*5;WVOkm`ag}XZOh4O!z!x{h*<&ZkZxDn#%o)e+yh$M zSh}_WB)`#X8^&lxyWXz-c5mpp<7919J%`q|<#cUh>Dm_WsgY!DQ}+g$e7d%=bZv|K z6p;Z+wV%_lUS>h`+7=6SH6HyjjmY{(VGxaw=8mFD9FDlw;Y`-ZFai^SnHvQ|)<4=# zTWmX+m(lu%c{zpY8c0_2vdt|TZPm#}qr%jFvINHA?AV2{M8)cnRj_xd!uE6lyz7qP zGaW8vEHe~5VZMUrHU&>f=JZT*z#1iURN!Kfl`J`4T^}HvDeCJB##zC!==sXxtO}8NQC4FYZ5~FVTOYRuArx%fxmS!Jg2npUR>K zHjPRS>Jf~Nj1aHz3_x7LlO;zNS>gR??opS7!|K5Z_#!tQz5qrcUoRADxg3)GJ?t%- znM7nwCU}fG(QC1atfiV|8J7_?r;%3(!ez8ww0ilG0NK$HeRdlt!DIEIG5XzX4V>Nh z!B5A*hL-T*!>s9v=fUDPfr}GDt$kn_s|m5jP$>K}Sx3kSh}P(@@&|!QqtY4_W}pJo z8i9Ev8m3TzX-6#urc8mM);=5QEH2d5co-p#s3RJ&zse(s)rhfUyVl{@YGfG6BOowy zqhN?e?62|&RPA6+KpGKqLMoFJ@Yp?~$zBbR8JxnHbU~RZ$ix+4qd1x?eC<@BicjDT zwK9~sCPo+1QOr;90(-#P9N88<=P< z7tap;4{Us_cEdxkmhA(f3uwN3WdD{mTvDht|D*rOTIG*q?ne`^?*`LADdaJ$*9?T? zb|S;60jtT%IYf@pBXwYr1=mq7S>%3uKh}Y;V6`9%O^McC*SP7OHGz{4Z2~iXgkJ}? zRwDhtm^XX~bhV$r;QirQY-&~)Ei5N%Wd`mA;ZjMpS3cnkH{py0gfmp@f`+jN^bDt3 z5hQ)&u4}{wS4&X{wRxYivfe^Hc6gAEPf+`WbluEAUIp zb|qU_{Yo{vH~iN7ue=X)L#S;z(q%$MKEckov4GzaZeT~Y)L@R;j@nKk+SMB{>N#CZtCUv&p06yB zW#8Tkj<}Au!l5oi#YVmiwN=(aVqT;jGJg}Ibe!5UqA-ODOiLVHu^r7y3ZXZJ`HE^C zvo4CaBq-#GAc{CSJI2~%6z!&JyW$f^V~@N~ZC+{5o+TxbaU9&e%CLC!=-!dxxc2_t zVybtTW=^dl0|RXgT;sxx_=|tWn^2kWi%l;g; zjj+#^BmHKp!dF-mYYJZhz2MvMZ5NA7zi>*p@Ccj0b_q9yzrlTzcFF8bS4S*uyvk;w zFas5s)(Fh1XqZ9;MvBn6V%m`+bkpNS=)870W~MwPJ%`pKrR{bRx(ou$dWiHci+C{v zqjtSr`|aM)b2LJydQKIg^SVidF3wXki5H{pEfS$a-Qfsbyr;-&JUuExr;tA~equ9D zS4Zg7BaSnZ*E*cZ8W~28&|#PfYM2YcLJ3BTwD1;rE*a*7C@9j~TovDN2~oWvylG2w zRd{o0?eSK(bS+*>1g1uYk?tH?{zfJ;KS7x^4dQ zATk}1u)@Fem@%N+;GC`w|Ls(4VbvXzcIJA!vUNmyU%AVkTTzzO57{T|NJWr2W7vyGTfDC(a9`O8g{n~q2Q+@FE^@~=9uROaTF&CT|IuE`LdVyXA zu;;LKBff3`li4pv){i*(3fvYh9L=tJbt#uRu;mz=@UQz?KO2JRN^m69U!>-l451x| z&QNGCMM!PREK0g?B%-|s(cT}(NOajnvrROc^)6j7NpBMMY*0uc!}KcVSAn#xL!yE> z66o+3dHkw~X{L}@RBESDLIwJty#L#1(tyE;C8Uu zB?VGlM+83K1JA*0Z4CTIwpXT;n3)sXD`q{yj{&B5-f;%+b}_yzyHgI1)`2~M1Ih3! z82%Xj_r#$?Xp#Erdw2s5c<`F zr}1OJf)w~Agi1fnH!h-%nzciv=YPf@GC4a9*j$+%#h0eZz;IdSahz^oj00-oCeY z&48mDkG?y0+UBAC|8V=D@tHG*AKeC5V5=PCL@02d+N#U8t7MhJ6e=(+@n8@Ia>&|6 zVJ^N#U^=T~-m&dS-fjM`wL>Wha#V2>91}q;twof~Wby)ux@6I7)TIa--r^E1`XHZ1 z3??ZUT!3kbdB)ZgsXafy8x(vNB`jf@yga1;as{`&_Ge&)Zvgnslh4A|ON(zw>Uu}F zdp9i*{|YiDOxXVjw+EPiH~{D7eX{q{E8bUih7HT6Z_X?Np(5~Ded|<}?Pf!g>I*ns z8j*Fm7_&|^tAkuLd`|2*f1K+3MeYz?yO(h|d4 z-7fZ6dhZl)BJa6w#JU5=;6QBOS4#Rp%QyoD0Xrm-$O(2rsDBKuphen--Y1dgTcm!@ ziY3mV6+{bq*2r;;R(_tw`IS~8o;GgnT~JJ0M8DrT@76jU(BbcvR2sh}XNrjOpTHjJ_BL``FlHA~nUxs0}si`+g zMd4rK&laXJu_!mIV_cDWz%)h{CFa%)CN*HUBO4|KnUg8iZowiFbF#osC08*gRSc}Q zD%oAwnw&?u8GhQms+`)B)R6QfgV)sT4L9Cw%?tR6K^ftOISQs>)p!O*kB3lZvzjtC zP8AXBBJCPt{iEkeY?`W%BD`Dg1Ag*C+N$OZ80i2b&%hdF=A`kT-&la2cA=+t5!p=% zSb43PGid|^-u#v$6cxm7^>cow^x}M67S~XBda-MD<9)8hr|aN5)uw9Hq>EeAx{SYc zV*xj{bq~g#v`yx0Pn0yPS8fVZsKCgQs&5BVrobqYs#ni_Y;Up*>Q!?;0KwFdSWHsD zV$cGf*WwH<&jMfocma;O2zr1X7vU)I!bSLR_;2<#_Fv%(Y)050?$5fz z-=TIBQM-?l9U5=I1aumX)$4*u^HxoYuv-(UhLbge}sB8 z2GR~0L%o_}23AE43R9@SNEEXO?O@6j7@1-QK~1qlq}v`Px~*O<<+kYfb1Y60T@CH~ ztC3-3DaSB#qhN?^)~jVR&e&!sFcQCG7=_=Fj^MiyS|Dc2)iX*!>tKVW{(`^)6m-c`C-%#X@iXExhOjGDqJW`p#sy=AxxP9b2SbO&X+Qh zz7Y4}%oW%q#bW~KGmcrL8A$F^k~3=OH6}wr;3L@wGdqa)34A2px446dkpx3LJ*Ax( z%TR6yTbMIDpoa{D$GTYd7%|T4_-;%Rlgso7q&nR$(c?A*GlVQrhq%IGMlL(fUrAt$ zk~iZhw-XzOn%mUI_7bbG)XRI^cCTBLmn9&d5eF8L<(=Vnfm45d{pZgXPki_x=sE+m zZ4L0C!EHP!PPwOF*4AY_S-`4!VwX(a zb;4vcnperpeMJ4=JPQENtM?h#2{$P)=PoHQ^-(a|uP6+Yi($~*e#98S{bX*-3joL+ z7kNhBotzwLS&6ja_Hm@mPk`C3v5~g;eVt_z<-UsJ_JyC)<-|Po8S3#jpVGb`^VIpa ztC4Xrw)jt}sQL+|SV1gR-_RabTKk)4`|`NRQ)-$`j-lBn63t2p za7Q$Y?d>dB{#(6~=Pt)Q_Zhh3_dKU_#XN`Uc7o6iJqXI;Kc)Fxq1&17X}nA!Z-fGT zHw8+HPj-#A%3|l>Y+^^8y-R`ljOhSI6M?yKDFz11JF=y)ymw*BnBqWsK9YC0K`2oP zdk?0on4BRdccK#iZUGG>Afc})@OLEgS|N5TQTv-pAZzj+kqyR?zo`&_Fn&1Qd>lB% zmdvWvgF>IF%Gf`>6q%2ek@+AzvOt;ZPZiluV3Hd6-bnKwY1Q^C(}&bYcpbKFm#lJWo(wG`#yV(vb$3U(qdcyD^`Z|B9LVuuXS~!aV0vRpgG}^q#krse)0;*+K*8FrZyX-d zdsppL*HobSW94((p5!=K;(9PxUXhePq0_SG3lrV2a_^ryS(lKKvk8ss( z+y9cgpNRSv%eQHPA2 zaPVoWn@R2t*VifQUBr3y2&Pig%vjJ`lOAxGoFZc~2CYJ($O~Fz4@mpe=tcKJ{dlN2 zsUP45&ZwhAvF&_9+evHJ7tvP~=EFjzuP4;DfDX-&o#q^w3}l)eeg81<NoO%2_bjMO z7L7V>koSl-J|gu*;N|`K46@arqNk72lb=!4G%?J=O7Q$o5>2 zFsn%vJDPGZ_0XI-hg$l@Ogl@sGapH)8&S;{aOzEoqRGSv9uZ?^RpuEJN|sdzQG`?v z5;^6l24q9<2l&yAFF*WF2p6ZkSvO$ifTXFrw%xPM%qDG61QxblA6BvUeyjPY!@zCZ zx%t7zBpJyka&(@`ERiENHCB%Di5*B`&bbtrC!%2@?KHbOY9|8o35D^+!u*F=Lg%Sv z;-Yrfqtv3wPLnMf@M|l=b1Fg?m`b@yDQ#PM2}Iro$=6hUYUTnP#-$t%o3&&(X14PX17Ms zCC)sZ?cdQ{?owbr%kKb26M?x9%M(}%kZp#gAQyR{c>%AfGZBJ3ECfmEViKcBg6&;p zmn@zvL?T{UG$QtEvf;mTFpYxc&0ii&sOeBnOnHOo<|>Yrxd9cL$moYFBRij_j7-VV zh>>qjlY~=ZZFv9}Ux5F=xEf9w1kM7*Oc_+351|olWrsguuTVZWZk+aztty$E9 zAY+hs9{o!6z67gcYz<`C-6Vv+4L%P>gR4Q&Q!%|O{1QA5N3-h=J;LU?j_3rqr|wXA zDY39Kc^$DfvY1YRM3)duxA{Z|8fls#MbsM5Sb@k`Wz$2lc$|4TBoPN2bh5%N^&^u+ z!zpU^B?B1y)K-UK$6~5(z4qhC-nn>G(z7u0m`(TRy|X4wQ55d+)?V-{c*V#yE6SF5 zB?v>J!+uhG$=NoQbWoVd3QThZroJN>$#=|e4^ynbe127FXJr%&kyZ-x;eTTAQ8Hee z83*1owhLC-Xf%?UE0S|wiQS0zI_+6xnYY`Kz32c!{gI}b6K@bB@!g~0(d_eg%!Q}I z2XV{&q?VKLiK;}4SL6&@ku?}JMvFG6VS-Mjhsf@ZlHZHiBDOzKYyz|3&PcEP8+g@& zd!l=8Yis)($(~$^#{CP;UxY`QN^F&RWApn)YpyUxx<`ihJnhr%HqkBwohGwrGDtzR zB-=?jr2-p2$VmVt(k_&n-PU*f_`dzdjSHUw+hIN11q|ljCI8T`@A#JDL+sm!TQ?q( zvn5CK*(5G%)*?>DIi!LtAF8}!oU16eb%<>|grApk14k2{2&?U0g9d0@Qcf`-`XQ)Rs^xFoXMs%oaYQmxt}>lvFXr8g!tEGcnUM6NO*`owB5pQckXV&V1_mL)^@f z1U);^1Yoi>i{Pj{oim!=H8ei8wW*DP7b7zonOQ!&r5R=%b6B3-%UvYOAnZ~o7a7Tg zqS%qJed;}lIE1KE1FQ!c~{(n%tKm<5YE;=%UW#&Nh3o>6BV z$Kg?R0&-+dhASgE-$xnAet9GZ(45qgkr2-lqwbpSa*9YbV*#S7IG+4a+LLr-LC~_- zWpr0yn!}lFP0jbmA~^ms_L#%^#9prKP;0Nh{db8KNfYjQF_I<=&P>#4kt?NA{zU6X z_0XVd7tflyH8fV#8b22EC@>Ix@d@)1jp#ac%DhntUO+UZFW|5kIXgc8fI3FX(k!d8 zNM48^XT0<*^|M;w^slNhhCyOLvFcz+O9? zN2MwQ_kwUS(S7DiWGBMaKHqr(8ovXdCpTIsE_xs+n2=KwzZ(-i8+M7t1ocrxEGz7_ zA{q-CQDB0MNiivC424oy4w9=}p_b5Cu_*Vb2g?U( zQ@aR_&PsP1_7J`75L9}Zzz|>0O}Gksdb~j|8kit&6M2hUwbR;HH2Em88ittbTgK&x0 zPhI<-L`d_v*E{NZI{k&U|IFhbRG=0W&X8{;(*CCDz2j3&_5OX@cn_iHl*Jw}(PH)?}IlxC$iwY+x55)7-_W4Jdp z)n@#AFIKtsU1*^^t}@l2;cwqAR?fQMl}4h=c}e#I)k-7#Ie7KQx#%WQaF7^=N*ht@ z-a$Q@5+xQ@)qLej^SPgXI=ArNdl#a&3uFKRXhAys4E_Rdzzd;c2cLcQ)n^YLQ+uT# zO%-ot6XHfHB$#uI0@FTHA;Ek?VNzn-IfLT@7zX?442?pprv80;qU_(NbP$D*p8Hbi z0Y@S7X%r$YUKFCRU2kW>*5B$4J+~4^A)=o93@rH_&ygrZ)N|(MHfvt2uy%Uuf0XRi1#M>G`@j8^jn()*;p1OZoC z7^3d)2_cRWCT%d05fcR=O>_t}21+6T2=bJ%4%djSsK{KT_LiQUwjE%#mbS%2aoNl%HjZ{ulhY$d)@m%2&TdmTj~aY-n4G z@H>RFqnRw6ukK_sJwC5VuhRl#a;J!PW6)^OPS+-iT5bD$bq&c^$MFN2u+C=58S9yX z6JwkQJfLg!_=m1EFPu1G{B;_o z*#bYs`lkT2Y@EOrCchf~?%2WF3E@+zl;TUJc_~(NQ?|Ap_YPNPh0ZUTm2L9gnG3-@ zLiBnj)0rs^Wav^v9dESaxR=0Ntqh+h^4eg6Lrh?@B2-v_JSP!aR7|6U*fI4k*r`*}9#|JJ8_b3~ z;10aM1&jGgGy=SPk`wT_O33j5 zA&W{i@>we{y$Ncv6CA*?Z~N&%qt>qlJdSn1%OLyM=>@aC{s3M)BxmogBvQ~@B?TbC zppt@Em}Vvp46<%kk`<#j(cxdUjic6AVzl+O3rUNMM2a&59z(X-;>)n7>KIp&=+cs- zwX*j?_rr44x!X7`K3j@xMzZ2v0xK+jW=KNyQ$wHH>Myyn`yoW!PTSUbZeG^=`MLoE z-+cP<8!n6OxXo5EsIa@aU;d)Shj!t(XWv2HI@t^J*R6c!aXbQo85v~-r3CfI^IWP? z#=`pAUGbnk{YC9yq< z{>IFeWgocnzaJiWCB3`XnbW_Z;I^_C|2$?{kAz`01uKRX<&X9kC8m}P8(6cniCuH} z#)seS**~HrWx#KgDY^4V5 zX5?@i1YI>YzwHX!`Q_XN;w{oL7s`G*XZhhncoi6$z~?aSo(GC*h9(SNi@ZW`@el~W z0Gr$)7jvjmSw>Xy8eu8FJK z2N;y@)hLYk;X`ow&{>Ma*+b(bEE=1qW2=E+LcD#f)x37(=p#L1V5%(!Q?)?|@@~cD zm$Q-8K7R}i*aOJ(9~y@a5%<46IS@DZLEMb!fw*}Hm5NJrr(Z1Lc0OQ-Ywa)uwc@$}10v!^at)U*vAv^o>Fx&HAY zdn`O~(jOjrmhIKL%X7eRX3hdhPDy?f7^(J!y=GNxb(fsEC&8S%s=z!S4HIeS6AF{u zt{q~TQjx~oLnKbQ)5AvssZJx6z-V_+Ts}?`< zde5*WY9`K*5sg*PL^`h?5u(i*V*8I((`+A;fQOZ=aso(udU zQQ`Iy&_TkRN+zPy+v3VcOiG(tTBa&v`1ERIq#L70N@zAxO*5}TGx@v{8Y!*?YTy}! zogA4dvPYdXI0aXJ4l*4e;d8i=&u-nq)wDFD=aGxWi|Bb=W5K57&76TTkVZ^O^j1o_ zi9Ku(THILaSpLRx$Hdc*z}97^WvJC<_^<)Drlpx1-ue<60coAg=yWwN+SEHPu`rUv zyV}7JiJu-N@%R`)MB-yxl4Lp38yYVT;0{LPrDqL*mYg*Zf_izh%2@-albki+L~svW zdl|yZa3xrI8HRshO>6>u0L%|x4qpN_a0_m=6zSths8u`Ob*B3bS}t}n%JIr2&xb_{ zl9hsUaAGr?5Mx9eLBKvmjd%yRtGSe z2+ReFw*$0mXSf1$K2d>DBm(b8n8@y?d=f#RClO?SAnGJS9a|lKl&cCq&CWf{zIpg? zxX)o}$H_#-beG!JH}Ek#g=&~)rUMv4XA1KnRA8Q$y<(hC#w+<`1%V`sgM>L5T}(Qe zQ_0*SN4Y!7CPStu_I`Os(ea7lVz>&dJexqX$?k*y{ruGF3%`*{#xp-6Dla54Yb}X# z;ygjZ(;9>q&rEk9drTL*zlpViRDC9DKa)Gq6avNQN4T$gACttdj-j7aM_BA`>-oWJ2KL#8Ii2Q6qa00vE@lq4QlM&!yB(^n5R2j`5}Z zK33p5A#M<^9YGVkLsQH2W-6GQm?6vvW(@OG;FhkPI_2c}e5t94{y?CjV(8EzLq?8t z_UzS*Wd*@tuvnaSvFN6o`t=(zV({QGV?1cK^YXH?($kZZJqe=I% zCYw|amcFEO@5<-sS4aOIc_Ha=BK;oq&MNVfH=X0yx8I#b-WZlcZ=cWU1M+Y5?wJN> zryWj%t@wxX*S;6h-n)Q)10Mg_(f%(P070+D&#^<%Kf~6Rp6U-5$uAtBiGCj-|Hu9- zeIFqILKjHP9WH&s@S8Z~|TlqpqJ|@x>np}R4bT4Z7fo{Fq~fZo`l+1ej>)tCq-E!F{5Aj{-0+LJ zMws8H?}k8j(a7Nl8pe0yWn)Ne)(p&v8_$Mqkt7iz4u717YiB-u5WBp z)mpX3uWwvL!kt1^q|!FB@oK>ERj>c?kFT%l1up~JiXA&}CD!e8-dMfr?{nCFFlpV~ zd28jBeje!`O)SEp0ev1ISM-aj@hbP%z-4UgGK~gb!>fV98j`1upc*ub2YV~Cp6&-6)ckmKjpU}Y;1_r5H$?I>JiOWMzrdJ zxVp4Q;wUH@{?H`oQP}1cCUNm&0E;K zmLiw|GEqMdRECZ}UB=sFs*EI5$zhbwWR$cDMaMOP%M_S6b&3%!lG|GNhc#Q#N;@!+ zU(rvj7+zv97V~K%M)-z`X+B^2aBL3`*9AKlh@I2%X|{Cf47Z;YHljJ3cA_{C>AWSx zgGc^)X$g*mmJ z@-x#yQ)a`4(rot5@MGDfVC3v62n3!D4AmeIerSJq7J*E?6O1g)4nM};nOzDS?wpG0 z6TtiBNYV@zYFke8-EJ)4yS1E-oSzBH}BY zY40&xL^JRva9u=FA}Z4-W=1L~6_iufk(i^j6p@KAhgOS}NR}ICBOwN%%t7{75O>rz zo}4kvL>gCRLjIKVZsSQ6iX5x`M78HdF z%RZ2O*PkKWA@jHmd$Ms~5I*ycKCWBT=L18Z*O_dT)dFP(}<*;`%m}Ud^X1KTb z;s1tmy7Nn0gWTqp=8e1ilYVi#gtH!}oJDhDt$LZWNG-WbNc%T1X0)<-5$CvV8jq;c z*nwL_iW)G2Ty~P2pCX&mIFySkf8~_;-8?S-<+SD4vw=-x=UEm8!0`j%4cPAnvO)Rq zOu$0u+Si3Ouqoa4?duZ$Jp6OPKK5glcmY@372zsX#XvN9I*R&MCNW^v zGooJ4n{`1G&nlPp&^4XLFsaIC2Yz#^F}MOGG&ZunFT!9GT)1N+`|OPctdG=O`IO%v zSD40vs%1ElF;quYn6}fE2)9<-2w>y9MqEf1Pb^*|R8?AWD`vp}v{5y05qbQQTuHB$ zjjtYf6*rR4pT+MR3-IVxO1RLvO9IBms3rKAt0PSn$QgpFSC-Ubm8#8_eApQwCyg;% zJw`@ft;5agr0fFK7=2`ntgaF6kOp~U0a_I^WgBFkRBsMf;I-m@@A#!giIDu>(?F3#;&`jYF~ z-ME36P3r8;H&Ivo$x!>gIAz`pq_?bq)5sv}%`S4b_T!RFZ=xN^RVJ8RK<;s6R0lAH33MGrOQ~-(G_UhvshM{!J0!Pqtot_U)Th zIINU#aNx}SfQPe-S{JrlwWLIk)s4`QR)r#U{Xl!v*t67QKifEO>GthQ;fb@Lskpe` z$dRN%R;>0Ize?6oeRy%&OA@U1SWXFEBATZb%M9NjbGQ)q6R=;nevqs7Iu)fR<1y`a=r zLJn<_>TKA{1kLNeo3LfVrWr4MU<;pQyCz;bwH@{QcMt%>;M=Zow>>bd^xC=5sn1cz za3g#Y{sk_TBX>l*^=Fhw-fz}#m+Fk8r$!U{;p#%Ur0#cmz^-**ev)dvnU!gj{emn~ zr_HY|X=JZE@Z=6|k8kc~BJH80RrC{BTjo~W9E0t2^ItSv{u7MOoiiacF z?OL-_H0y&nIg1ObtaeH*uWaWy9CFGHi-a?$s3-l~Zu3-zb{qFUbL7u|KJx5-{ciBT zu_H!|MQ_YFU<~LEpMv+mweT6x1&j>u+6O@r$N^gT3;YCLgb1&p$XdXoPG|D5+?tF& zujq4mu>5#*!Auuk5&lIDV`2>wgSfO5d13yNZX9;{$T}@h>ju|}B#z%uUtT|FcE;Rc zuCg_Ek8EuQpEkm)qx)F>-uaur2fzdWbN7P}YC?HKR{d!}B9}7a!A0+!L-Os=_UrTR zl)3B&04`s?WRo;2Rb*BWZ$-@t4kN1zRnzPQX=ZRFyCkEe7|jf@+(1*aIRxHBdfd_) zB9pTp4udy^+I|Tmcc86R^2yA2=7HWM@_IdkZ{bj@N%eg^QjNZbBS)~W28O~T?645K zHU!H<;g^XQf>w&d$XdLO85$@sloT8Jv{9pc!-)ej64SQ-$UyZ-U2s4@aX=hLq&v4Lvl71G`|!O{hNQ(*nSb4Iw%9Ck*| zWu~D8J~f^{LhS}@S9c90sqAX9YGnJwY$mf>(d5)6`F++k8szV@u1AM%8J+km#x=|V z*|x>YsC})nbgY;o>B5cGy-ADM(GbB^(G?M!N4Ysbyi;jr>shom|vOp;XBUUIPqS_9X2S7e#;Lc>xKeW%v!6 znDaB~htG&$q%};O&-U;5ZD(-U-3!af*}VooG}i zlH}-*ky^?oPG>NR8nfBJiUwVfVp_*<2GL8pS+E%%b-<&<;k(e1L3}8YpC@EW z^|+B7xV5PWKeSwWK#FK%rUOj6q<`W3i+@S}Q!Y*rEkz!b2QQjh*B9q6Up_y)IJj(C@R9Y&U_6)rW`NsM)~`=S*2Ql4G^|fr&)ovF zz@FZ+KNaZ1t?+s}KOr6dPqH9zwI+d72V$9}=yM;W%MzF@%-zm((du+EI()-aHIt}5 zwN7%6(U3}POiqp$mz;DV) z!@3>bHHa3w=(`5%Szm$!%D=_91T8 z<%;t13RnZb8Ftf6!x#Yi;2LuX8zug!9R4A>h4|QfANI%a=X`dkb!S@}g(o}X|De0) zgO&KFoQFYR259o7_@_YPpOGWTq+OzgQOo@r!zcM{z{7s6JJF2g!ym~mgN2^S)3IiU zXvC|KAt<;-L5PWe(ujy0{}gceGyjg_Zftl(V|etD+%Cvj+5&$B@7{L#NwBJQCoINE z(A>*AuS{X<<=tbx4Dri(Ci;xRJIt@viXglG6edpn3LMXAyUK6kyZu(w)1Zoa3cnfk zEKzKY62;bVt89%D#nx~uwuW1YdX}gx4a`J21>WqjeRFgBj?MNK44XaI-g802f}Zwy zcMdD?>R5_v|5=oub8Hld=q0ZHrlC9e$L9u z!)HvXIFiZ|MLdxU^b?ssOTQaCYv?P(mp}N_KRX-CHg4#$ciEsh;}VCju09Whb*n)U zY;_fn-d@~!RN&T|_hnRc>DSM!Z!p+aZ@zhx-PQHO+5?PorZAgC6-)*m=yXQJeL#>x zosQU8XMK|kJo!Y3A5g7|CG3IgzJGL`oNlV?H09kLua1v$Bv z!3h(FKUp}~lAGfz7=7oE^g(uiU_YM?N97efGwSBlkSlXv&oV9r)a=W2g;H-G^-Mt? z9EC@Mu_PmTU!4zDpwbLX1sbh|=ss`2j02oFJ4;UI_ZLV}fk>QF5GpDs<+88|J~-rH zsC3OjwkIO*EcoE-A5UcD_!Ma`C15piW&^R5grH7IaHAGdyDNmU584ib>=Q|Q_U%dH zv+)%|sNH;gg;49+C>~JVK&NiD-3@g0P2fmV6CBDDhHq)cQo*Qo3`z2hw5*D=DymT( zXRYU^lh*KXk!mF8I?4GI)KZzEt%ecp^#N&lBHnc(Ez4EWBj-3(RnfnPbGTOYo)rqw zcI8Yk0q0#Qd03ch&rz;zQS!Nzyr!-QwbhztwElNyN|2|JkZdt98G;|#r+TZP&0)HU zi76>=M867p`nSSQcDvhE9g9A?LeGxi6P{Gw0Z!N?8BYSpUbS%1>Zcv96E4Tos~0U? zHRcX2`dd2_99y+=P`|$6P5lO~Ty;}F7hpgN$OHNl?7Z-{6Yv`R2!7edRE-NCVs9A$ zUO=J0SH8z&mu1P(QA zXhaHSC!T)1a4Bc5rB-MrCynY#d$O_d)0(DN8eVOp387L_C~gJevX01%FJPyw&}ghV zf=@@$*qWw>11~jAY>c$ox&`*b73=}?ENYjNWd^sZXQt~E^!!P-cVpwR&*D+S*Klha zxkW9_XsNbfyX_bnXC-P$x?u!a<0cLVu@p&(j>l4yNV0^EHG!&w$BrFLfp3C7l~q+p z9_wFy=9yPn3p`v^Sy?4VVsZN>>Eypvv}o|QTa?U1MPlV%)>6Wd+F`BUrM=j zY2u6-NSb20;gs2;G1%0A&Bh_?*aXN)i^u_WdwD^}(bt-$DNh_c_yl;TDLkluf7NiX zVqi1jh8Ytl&Ojr=di5dHmlS>VYVmEOIQl9m(N|f;{=VpI#h6~Ke>CnJ{&=)kExW8w zpCO}0h5Aom-=zp}i^AWof2=|oFN^n?2o5y*>J|VAxN2Q?PPE&j=gyBAEQ-J4L|?0# zemFjdV9@jfYn(U{^^Zos5iEimpV^Hl&ERku3{_PQuavX=h<2t=tnnl-G@~aQ>hX#m zYmm;B&ytSj4Nn=KF-k6ndcI^`7c*nZ6@&3+?u|3^47HQT#?0TR*T1rwjyZHISFr=6 ziIj5lyOH%2*KX6BL{5t73hisRsbjjJ&*{_H|0GvcJ$(rLI|ToC2u?sPc1P>YW@HtQ z#WCIf6=MH>V*e4;%^Eq$ zt;^20Vw=*Imt9BVxyz`lr5V!y?#wh~AQ<&iC)efVS+V(vf$p42s(erybw<*Y80$Is z(nMc|wC11mQ_cLlya#nCw`1>NMdR3M`M>5oz;a;JjhR9G2L;z`632DQaa@7KaSbXg zV{C6Zj!W$Cb&cC(3!EBF#0tkJX^`!VPtxeH<)pI&c@}7qQ^(3q9oE0VPQ$3!M4s_E z@>_hL!x^ZNk9cqEX|7w#w=hrDk_}^Nu>|#;!0CXFoI?Chpw+=2NJ-asbOf@raZ&>}nF=C#0rPyZlnS*TXa(IpPsA{&%T4n(kEX!u0-!Q|ntX=IEZu8c4>$Zjq@z3bmNla#hOp>OlMpDY^%C+Ou9;!H>6nM-kk%Avc{o2^gu=PLS z&3~TTw(hZ~o4^0l@)Zka&-lY!ar$M~H-H5nfgk?%%!-{Z3m-bz*c@7Y_tLeYIk%6S z3Qxe!=R?iXKF}oOUPAON#rkrKL*D1utaywmoD5XaseU z?b`59gRAG{oh|A6(DQ>AkFu1{AB^lGYx#s3`S5yak4@Vr*HtIn`Y+e+Ew|MS%+~wO zU5W<|0h(hP?#ai;% zB#$tasZMo^soIF)OYIY&$|h8&6RNTam2|>Xc>dYi%I(z~ADgyo(p?GXUf=X&*;B=d zo|#KZf#JvfYg4Nm<}LjO7Qe84X#WQmPhQgJHQk&!f$9~yzR@k|3-QELlbFEZ>WHXIhkEgl`y>ft@p^sgoo4nrc-N8ic+FpqT>4<%-3$IG&PV-% zrck?%C69|#pG-qKBtg%M$WAvWie_z4%LE03dg)SU5MeE-^0?H84mfSyZo>m_z4d@` zH}<&RkbEr{cCU~WZT$J1>~I}XoGs#Q;oZ9jkT25>ub)~Wxf5DH?P&1!YNdowWc|#{ zCa1V@nGUpK(M>$0%Driv+r>nmfj9$Ok34kOyzE(zJy`~Z8{lID<_1b{>oVcl&12#9 z4_Ds5ebLg*TU}Q^eCgd3Z{nCKU1u%qX=9N~!&$ocyZ1~pCG@@XFKfSn@W{dYHtnce ze}7%Y0!h{Zd( zaq{-1o4U~F=h|oe{{P+e{wjr?mjb^6842Mn3F=+%KH$xkW8h8LC(cei#&8Jb~apla5z9y^7C^@S;Wobb3*z3o6G`Wc;1@7_ju7$$irr)*hM8;kF0KOFs zyaI=CYr>nr0a(G-9cB|*4ML$M{Pp439O>A+6Pzzh^Og}#yXohc{Na&O<|EF^a##%p z-UxIevPLmta;m3%+6;TxM@IP=F+F(-EQ8fE#+;ZLm&XC9p zxXl8-ds=T|c~)=4mt)I$3xarhK-4*LBw=!pcX=jr}%M}Js0v1uN- zvL0lt$ASeW!u`ArpIkl_E@K;!oRU6AN;w&M0WXeNXc#lzC^uV0Gfp{H4k`KNn?RJC zx6zxN*w# zBz)$~nTSrw#(lJIkKCS3#<7FkNJ!#II=~~1z;Q;iXjF0pah9fh4CLhJR~4fVyGGB$woz`!$zL+F6J95V2%eV` zZ-n1VDZk?pdMOnPFTv}0pqHEAfx0tij)ZSVSR4h8-rz@x@!jC)D>sAh3FjwId_}@a zokz>2{YoCKauX%nDGEw;6D6*s02g9S%p~L5ASN%DByx-nsZYmrGN`1JBsMPF&HY$M ziQmO)@mop17Ai$V?svDO^@A%7>_Y;_z>u7&%!15!$Cd9 z^n!!I{_K8*U`bw~4wlRJ+^<7^3gQ%OWti}>CqvL61chWdMsHKyW)pR{y%!gIldI@N z>dgHp-&eE^9yBGzuQW$$8pYmSQ-^#e?5De#PE2mVV@noo#-QGil__Rr**K5Lv83i+ zh2%((5>+vtI*l(ov4if9cG~L{U%fH7!Q{Gp`HJGQ!(z=n#9e>fY2jacrm=a})bJYN z5OviR&mGTIHo|ZLKa%la%hTc%Ef%}S60{3Jy;fBT3}DxdMNUj<8td?8+x<2TzA!#b zqw)5-WAxH#tH$JM!2YK228{+5&-m{C4?zBj2mAhY(BuDtUo_syajk_@S6`SGvApV# zAJ7HqBDYSPm?tJOcGR=Y0)mV$djCYq(x63jq&wTaTDdkIbseMZuSr`zT=d-DP4|BB z@8*VC_dc_*dUDOf@6B6$_QAzlA6Yj4fvv8){<-eyH}sxv>u2qG?vHyb`wr|iG`&;J zYjqEPxai0;4=mmO6)=p|@Oi4EW)XKtP$ zF%*2OecQCgVK*U5_#7Jyzs-$=55rg9d+mY8VMar*1x01R(hA-Kf#jn{zlLjGd-|`B zKLGznJYmEev;uZ&z;3`?VU1J7`S;eqZdt{zkZ;zI;%wxcF^;pbzaC1v(u{*?%~zHz z)r0H$rAwAB(L~wE71$BtZ*fI?dMxR`0l$8T8^Fl{(GEhv5LgBz66+u#GUCOZ7fGA?2xpkrrc`gPsM`PY5 zWv{Bcw_G{@flKvx0kRT6eUryJW+-^%G59K&^%U&ug3r!gs^j;Sa$YNcQt@ z!qraEoMX=Oq^Bk)0gb_97d^(HK_ePKWIsv@&*sU;DvCT_AQd&mN`upSwA@YL#l+;B zdnIQj_)Ayr>I~TATlflnJZZG`Tqsb+iYGV;g5o$M0b>R|YO8V4vQ zy3~tfA$ra!h;F;n7?gCX5oo2Fq0-TGId?9$931wLox4=^;5r}PQ{Y6ZN8rPY<}cs4 zhaKIz0T&{>a3PE<0x$xpPKD{57_bCV0W=_Zi*Ia~?Wt^0Rv=3mR;_!wSMYeEb8)Cu?2~M(sM>~j`JdCf|_JZkv^CqD1W z`8j;v)#RdN1h~0dnC%8`hJWqG!fu#?0<(h{}gFtf`R_)H2d=vSXbvw&vUx$;~(_qh(QAlYn z+6+HtJz*dG&VrVH_#5osj9 z*$IBK!r3R@w;s2?e*(_7B4YfdE4acs!r#NUx@(GWyxSf0CZ9}Q;dtB|n(ba_wtd*{ zwpbXC2X%+67BM+ev=cjcaUz<^AIhDv)9zkD!Mn$KDYP97x!WhVU?Wc%mzq?pXO7tdxa)AQtfJ$oaanoG-uwRUIYe;kKALDPTixD z%gqi}^%z|ljLZdrsZ?W?=}}=dQrUkfqcF50eVS3#4zWWCOdkqEbK=wWQMYmw(>!0c zABAamp9r?)7V>u@`S9RK1GZd*pxdD30M9dc6}MJLG9?`CtQV)^n&qjin9who$xRN= zG5D6skeLhr*7?>x@Fwu6zar%_T!d}C1(MCLWvs|2M1F?H#_%TMyz5k^ba}*k&yl@& zRjnDe3Z{TB!I$W#1^x(s1Ri|lQpNc3iZg#i_Ts^+@fG;qr3x$+2c+r}1|$_u4KHHt z%83FW)ZwMYB$+QXn&h)xMTiU_gMfg@AS$9pj0lQ(3^EEC^!XUZ zm2s3o#}$*lm9I|Sd%JIELFfH`-ya|0b~?AJPMtb+>YP*CIp5!Qv~Se_`Y1X{zoq}g z(?2@`!bJFQQ{VqVr!#<260VId-?;6*7FzgcO$)g~SvC_u(BKbZ4L~ z(zoWfLpjDCnoNoq&v|7~V(&lPzx~k1b?FITU%2m{(&tL6CVjv4RQ&l@`NQ%zJg%fSw%0-kXJK&AuTCoWvLFd0GSUZ?X40C^`ch+(qzEQPO=bxi z=G0-J`fgBf^~8&59P2Z5K~JKCuG(bz z^ZOQGz3@fg)g2p_u6nGJUQY1H&Ln9iiY2l%M2A=uLD${^-1DrwQVD zdJcWxus}E75``?GtB~o7Gddk6F)2?<5~9;3#<4=St}VI2-T2TPTetq8k7VHp+v@m- z{LCL#Klaay^D6|zX?q@g?u|E|TehcN zZ2BvkPJc+I-7~R#?%eW;_sHWJo2_2GXznsj`v#7)O8a<|lUbW4zB^>72oBWHNv`;u zOq<83I@m497i=!8Komv*>~1*m5{2eJ{QWy5(z}N(ucBWqTCvVpn~YNxg454wV;3sX z+wlvJRU#*lKU`YDO`$ZO-5KGC6a_I_5^daga_CPnXo8AI(MU&Zo{)p$G*KnT#G+4b z-3@1tQA{Mw=@%4L`2B~b;=!jHPvC@k6Xn-PA-T;0M{8wd~E`*qc#J-;j}bK6}F^Y=MjH4bzrn8kX3bDz(%o23f*W z*qcYyJpI|5xxCbR_02R^Y9%kVK`jM&X5k9;*i1P#}6I?CpokhGmXe}v6nBx=J-UhCAFkC%1jO+X_}c?2EW-{M@@nxbo-qUAk&?zhM*q zKoU_*^k!ph-);*`o`kNcTdgsJ`gI!?lMq?j=g#SXliKTwXQ9X_NiauBL^PUJKT7$< ziWLtUuRzgwE>9uy;o*Cw`?OxoH;3f zV80af!20r-JI9sJXPl*Srr_lJ{tlNQCA$2viBhCF)(=XKsB3x}kAX&D^Gugcxk2?V zC%$X#*3TQwdpDb7CzVXOXTzH5)5}TX@&{aR9jC zQc8k9Dn*J?c|lpHQAS3QUW=(pOhMeCHQ@FYk4tymao5@8q&KoGIDgkvb)gZzP^2Z^vkig-8PofF-=cLHh|2?Z*ZA`oH2<~ z4AGWIa@ExeouYyxUO{xw<`4~6tlRQ=VR`v zoFu}`JLYAC1=u}Ml%XRB#n((CdnTd$wRqbK&|;&Tn#6KG#$$vGp{LK|Op@GAzctn^ zMMcI$M@AcC?G<5T-x?hIiiaxg&64bTvta)9G`Bz(jY zVKT=Ob6iT-a@OSPx z{8nkmaDV$Dk~b$iD<>GLB>n8>Bx^0R7lYZ6;vJhAGNu?qEG2JHy<#nw*B411);Z_uHkFQ$=Vk6G6c-#6C!O6 zsV&y1@vxm*1Z-ArGcCnu1K~9{5x6umL2=*I1#4xLVn-FZ1f!Q>yM%JbX;uj+%(NTe{!8Q>kCRDKApN;QF zOxB{h*OBvqe443i!`pOYunn-k8Rdt*i17;{D&?yO2pCM_C~)91l`^@;lM&)A8j%{t z18g=2hx5>JObW&`p3U~fJBh(A#-<7WgfuC^Q4twymZD-qGLA=mMupvqLW@nAibY^_ zxxyOq@#;tWo=z@|k`-e|SO`8H)aOy6z?kJ8z_u&{t5rfrFdc)UuB zWI*WceS2n11c*x%A+1PPAi~)5%xH^rJ^1PkB>`IMq0m<%v87n@?+nzHlJ}6`*pGSs%DuYck)s z5tc9NWu8x!U@Rk9AGlmkGS!?YqUjcL)i_p(Aa9G6Og6vCBAKd!X9$%0y*l6dA*zU%h8@5LE--js>E z#!S51C?2vz0#(j4%wN3quLa- zsK?0GupJ348Z$em{p{QDOP3g;qVevDV|GoPGLIp8@7)aL(Gv^?hUhELj@><}lP_ae zV&7ZJ_Kki1G|Y4tFwCzSrzzfv1cL;;B}!;rkXy8qn+tFS9ghxOGTl*LI=_- zSW=1dE=q=A$@oII?T zbX+c$k_>DlUVef&_&0umpIjKo{+38aaI~#dMjMP>aEno1!*@KqEZ5rBb9u$7JQstl z=3=mqaoE$FMv0~T{I)XU&B};Z#KKV~6#yT3G~~f1k}C*}UoZ*2la`WFfiemH276P% z^3>8&F`L;YIqc2HjTKBbzR{GyQ|j=?+}~{uKGBrHrRE9YW8ThMmXgg=-heks8Ndqd z#~+jN{49CY%WR^Ktx8)EVz$nH6tkxqf6NUBgm$u=rLE+2xLH~?N}}JZSsB*rS>9eO z?;!k!_>mx-WK$J5?BBErD<^BNtgvra?`~tXR z!?_}h^{LC?G8oj~VlSG2zM}OgPrjjj8;X{1M9Z&!WdG=D@Efaf0jqJkTBFyO5XBhP z6dQp_WQ@^lfWy9K#=X%7zBm!wqsl6TAI3!LZ@diUu{ypASe4&Hc{OzTM!H=42K{?Y z7)ti@x>9^rWHewnjmz7^SPGhy8`c`KUml2SXa{sj!Ts`Q_Rm0yGbnFsP)wK@MMG$e z>7ZyhMxaZy1Fjhg#V-RUjQU9=id6I7e!J-w-rH{r=Xh^_MxyXpwGV*@wLWAz8vX42 z%E^+K%gb3`1YJ(co-SvmsfSAzynG& zvRDj)nV+!cqePAL5(1%u`7xbDp`j#Q0mXo!UtSgFl9Pr6!NJdK*d&Y9Z+BSC${Lo% zELm2nC#=-Cb47)H2tV;u*oovY(ijh1T+-L^)9i>X&VI{2*h6aOem7`bo(<)l$ip$Y z@h1bLBpD(8DX%cTI5!v? zyQmPs598)$Rm0&JQgfr8lj+74Q$~lS4tL!|e(>N~^aqY%H+O zX*5X0=(jL|VDX#GLCQgUp~YDchK|+lLS$QDUSJA&iaa4dfUD+W;UE^iqxs2Jf@#pAg?vP@AA`QCfushi37iY8Z1}*COCC`w zO<02CluExtKc7H9s|c_*;Y{)sxuBe1wF^?5-EWAN43>yEZs%Cl3rXsnt`lbSR^VNx zvePiXL|Xupy7zl7T(q#~%*Kz&f|3ix^DE{Tcj5o~!h?%=r2`IST%-*RStOPLv9Q$ znKNj`Ak`Qex}yKWm5MP$0wv5Y*v4!jhypi8DDxEz9XPN@mx6*GMbeN#gL6*@e}t#&St6@d{F90d-yQ#)<`dyJ{p z!gy)nyI9wOoz*=}{ug>_Z`)=T!XsOm44|&?DR;!4H2E#d=oy(_|81zeU`Ds+Pv6uU zEAn@5JLZO@OE z1?HoCs%?U?k@OMn!n97Aw!RQ0@K97ne{@KO>n8bGOykJ2wR!y>u@raFrt#gmPfQV z8CHz5N)d5!C^bb&MWA&n0WIr!^)?zTO|X8`Vd&Oljq z8hSo#9f|!u#hO8Gc!f(0lVh-##qj9tOED)orI@57K_n7pb`V3v1RJSH!fQejTf<2c zs#$H|MYc6=6e|Mz$fTb#wH2&`>X%EX>MW|dOnI$NH2sqnd$R(t%&=wXXf11j6aV=%V$mcnhn2yV|?k z^}Va4sJ**g?cMFXca6VX6LvHFuJQFg=-phQ2+n6`=jBW3DS3YA;Y7h6B^eX#eu$7= zsogzbNULXpZ_>}e2ZlD%Z!B-E{~H#x#u?=8=0pcFMAg)^;1tHOfscZ1ishV=<%@SF zNmi#H$R2GJ_()>oNofm^zxjx24foIp+bB+L+$~NG_Dcx6Dg~?kA#4c8<`l;Ua4Ga< z#Kzbi44)_`AmNCX><)?HWHb7ixn;LX_BEOu)a|nCm-ICAr&Zi)NVmW_aq_Q+THq$z zFQW)MinvT4I7@wc3_l6d3oz{JOK0fBGIV047^|dU>R{w@NH#lMKG2P=ma+(`qbv-& z1u8OztBrfa$$@jE+pm{f!B02H=yl!bT1!juBF@D-8h-|^#XHsqrJAy)rt5ujMr2dn(`&dwz5F8~j7y`KQSB#$#VkM;B^Pj~Y}@^J?gs8h%Fl zm$7P%$#CKzGTI*z$<&ZiDPi@SxL`lSnDLAnGqTWKq&`ZWCiNkW=e9RoH{3U`A>G(} z-mjxgkvq=r5HB{oKhN-V1DS=c zeZQT*FJV$`w(`Dlzbe(ngH8yu9IAtN;0%nf@}^tJ1>;!3E9CL`q8&ZyQoP3>pD4vwRdmVCwK%L+OO({L zLkDvXLS}?S4k^e4Hcq^gP$0|uB&c*}%baEIJ6A`rq7PKmbc=$jDZ_su1gJ&Qt> zPWixt#LcEfbYm;D!oyLUYpy++c`YvtQY|-g#%PQDOd4J)-sbnVq+NG^(M?xnPe0#n zMu9@khf<5ZbX#H11cj?QkW)e1cciaVa*`C6?1vjw>nc1sevgG2!;Fxb>FMS)jDIg% z<=%vlq5JQu{cCf#&Pn@}C02POP1opvs?i5@c+2MFKClXl`-JRYCV9$P12JFkd8Y9@ z#af;i9WG@@Pl3K!kZhqK(a}`K*CcOefD;rQA!A|NL{^ zWxgpUO`ZP;y&uw43W}+kjB7!}<7W=Ux#3+rlXO z3|~`Y7fc)-upl{d}3O^z;a))ygz*Lc4aR zM1+|BjW9)+cx#eH!Ot6%ThIm@{6<7)-VVB@&G^mYy$z8!kJfyY$7W`I)rIL3k?WHK zjdhxSV!Y1YlkI#5&BSNYHZUfPMw4W$_P@D^QX^+v*7SAruV+1w}+Nzkq}6{5vtkW6cJGdgY)^JPHffn|MH z7knZxMt-NB)woTZe2E*n^Ba-LOb^p;XMvW}SPkb?S~hULKhQKAY{g$bW+{i2l%`T{ zD<1fzLdnrM2iBN*bntD)Pn0v1WL1R`W^O8vKok`2vBUMttA_P;hV@5}8bA3p{U~2c zg>rm>HIDv9g7iEV8y-loemO$CjCYkJwdO`v-J6C`cQoHF%agOmElYmKl zY<6JwR%&EaPNG{%OH33}QG#S32v>N849PXsq3jcbr-$70oQYvCK#e!TFpbkYIDoq$ z;NdB&TaK-}fq%(n-@5Nc)2d#1_l3O|u6}a9Th;dMsQcl=^ep?medCHpHW}6BFZtWYMiz|Dv6*&R$}mozY_0C7H_be~{syAfySozWC_a zSYrx!ls6ekg%u@B@y%k(XkN;ZEtAinpNN=z(@9pI$Uh8ui37Yh!J%qa~Qui*{NxM$E~e zKftY1`l2|*P$47=>405~)oMzUq9Y>ACb&tNDkU)NB3s2S8y9x77lqL4&g}?>o-;1q zW;Ei_m#_cpZ&j<_$lW{Sks*%_NEo(jI9e^OMfy2c;Lkpv*q3@8t(28Om{FLj)g3you2^-VslD zONe$G(?eK>9K@{bxOhi4a>hH1Mzn72;D7%6lWuGCVq%NC<*)6wuy9LK=1W#9gf2W8 z8(XxtaBv@#;7)(xmLVsyXqWzvO`l7TC!lRFpcha!y0-vjCj?9=Ie~uMdBPJpX#pdG zFoS0kP+nA`_+Mhc-rgx@TjZ}lSV{7GeGjqogq32s_zn0U7kcCekbJq>?r%ag-4~+On7|&-ho=;~ucVt*+d*bW~QeLXJz1fnKnBk7f zib5{CO^UUXiVnymwF3cR(eCT!=N51imj`3#tHSZN9OVXlh~2M`Jsv=)T zc8=uC_B$ggY>`otJu=c5D^wWWil>(pDNg6PfQL{sg?oL0l`w2n zJ-^WHXlA`6)!Gwu-e1;}X(0zNX!4QSvyU|P3HJfhc!LR8B#cz!b+(7oHSv}NQyOY# zLhUW=Cn_gH%CS^fqa0GKGGG+1?J&bAF4pbr6#(NfW#3&W2=gY-2p=De>FG%)BNt-4 zu+occmmjO|deFOc@S+3h=n_pr1bsm}4?zWMS9IEcj3$jj-&A>XJsDLED0Y7N!^6iN z*;|#G)-|t+zQ)D^YJ%O@KFla9GW?NA{EoilD07SyX(knkDQwCuF&p2BB&QJP7r0I- z=F6PLiY|8Bg6G{s?pk19QvJcE$iM#c>iY}U7q9AlzkmL+dAJK*haB`@Xd%x1;{8is z4=ver=bEJz%f&S{yKDCF^8q8MN1VfG2wr{SVx#O*g2gDgEEXXK$QJYx06v@x)D2a| zyEyc))t%2tmTu$DlATOMmPO2dU^ZOxL_b$Zaeb$Dq+g&H>4>RkcT>-aSliCM*j#l8 zXFFdtGc);E6-{{K>qQQRN1E!j%LZ$DO615WizG%#;JM4Hb9EKGRjTtj&VP2VSV0cU zDY%RLK9jts{!t|+*V2^Q*Q@>^a1K8yKEyR`ijX06_9Z2{(j;4kWV6H}m!%>p+7c^8 znWq{pigX2y!0LL>Ud;UjDEdk_LftlFP+K7zn`YYZT=_fX3{o4@srYBbaP)`PSc z>QTK9z1?MOCL5gjJ@F3I3oTT&*2pc7M9F`br$y(ATy5nI6TO-AM|vmS8D&QN?Ak!a z-x8ns^z^k$G$e_sX##Aq=Nj1Pu;*qu@jm)`6>+O!;(f(&X3-Krz2mCMi+T7x=#PygY2 zd=&0lg+I`oS^b5sSyv_h1*dxQOyI*x^@C?jXHvVXFDWZICSCHRq$DH=wuq??OAM*V z)3X_9STL|`>N&hZcMh`(l$dy+Wq4>jm=0QVz`MF9!^D0WZEzmS?HZ4A(7FRPhi7}T z6BA}1I$pPJc0yvdXZEf|A8$qzYLeO^>o+I@jp?zYQp77Z=h3e(A3lsM6Xox4Au%%s z4lJXG4$<{B4C{Lt))N^QbP$StX)arWlp2*5=`m(T8VynGM{wE*vk!HcH$s+V{Qk$}3mi20_y>CsjRM@L1Q z5>qS{%B84$TPS9`a#W+J&{PPcEX!qZ!Q+h6vu5Fj~;q; zALGJ|;~85r(3|w`(OX6zXV1d1Qhq`4{(*h!E>8*bx0vdSi6&ObNQj6dVq*of(3fG* zw^)mTVFj~P1lyVStkGsGMekOjcQ5=@^%G9PowHFg{W4qrK>h-!pt&6aL&@{Xc7mlu!DS&tuvi$!TZ0@A&H;+;#o=>mmG@DBcF`Mo zYz~hS!feZyEdgYB_ays88{c(0H=!BMP56;2{D}N8JXOjs@fLcK4nZAtc-i>6Zbx5Y zs#S`@&J@W)oT=ZPJMyU%a`3Q-`yp%qcI^nSIRb3B(RxIWQ8fh7R|%@SS4L3*Px3$&G8iv z>|-@KJS4-TaGVm?iy6}ViVfJRirRPvJ~aEDC2df%z@>^2+^z$10JnV*>o(3@jV<*GM8LKc3OeVto>BZ# z!3vP-ENPTzmABAIqLn@8Yik?x)!=j}2rZt+FCj+BN}M=evSEVFab{-InZd~@Z2bWrmLBM?+FJTn59JR!5^d7SmUwl& zbC5678Wok4WHu99Muy9kmc}HpB_TQ`T>uZb(M;|+!)0+XzeyO9#;QanjPQgzCWRkm zO^|qq!&H!i3LGAIj@>%{?vWSzZO^ak`iFiOM&3RDR{G~bqXzkHoor2lv-e_bc6t}# z0ab9y7mcIO!S5>hbv!_RolM@eNiIRp(23|7UEJz;@TqQzj1dh|EE4??xyTVG_~W7~ zBDr6X{NP%hjjx=6S?*RtSZkKCr*ID%O^>kY)hsQN^(+)a52La4aNV0#hpR|5cc}UU z`}*dauyUo|yue1hM^OCkl!()eLt)S|)5F9y2RAWC3{@WYd5V9T?m zKj#GHA=hha=n~~=YHF%R2MkGgDK?4;{Pb~u>cKNjR`7W3vj|3iXc`ineH8G?#t4{f z)Q9F4#tXlWQ05b8vux-7V59iTo<%ai;Z~xp>>*od>S9BiLvt0`ZYqiBL9yO~l|=&KVZ)`}PH)7^`O6Jn9LkJSg~crB4; zY#=6cM1&~#jUqoOtgeqBRD{4`q6z)F9L=CRmZK?X%5u5`%~(#KKCu`rK}(j>^=QFT zx|*(DjOOz`ev-B2-+Z+=8QK$PmExkKB)A&HPb;0z%{|}zj6k!UU0l{<%$OcMM~{&& zkgsTz^2ccM(U=}RMvv~%V+?(aF+u0i@Umx*(IAbcqdBbB6wS*z93u-mP7~rOm7f4w zK&8KkdR~N6U(pzRZJS9MzWon-|0d#(f}MYJNL_Ht#^DL>{THZv{~KTZ4Edg74A}Fv zv#)(l-+T&Pk>7q5&Efn0a~Zy{@4p#nzgf5>j^byQSZxFwO$<#Fl9>KyW7Q~1#^7lW z-KusWa&3lS4y5){=*$K{o64Z2acFLz^QNbsLg$^tv_J`|WmDcS^rx7(KUm=&u@{FH z3FkF2F&Wti6)gVdIPz24AsijS;Ob?!w|H>#0XVvc! z^HumAeJSAiO2IiIQW7Hkdd!8+!4oC?`j&d69BL5rHT(kup%PSf7L`Fw^ft9WYZx!o zDY0i6<|2ZV=?ey>LU5ZwoyaE^2% zpYeMm)>n*TPz&cn_i)GP9&!$eC(bj7<>f89 z9lq@!H}3x8605bOxc9JO=>0xJhxXxrqOy1QK6|=s%9OIx&+dKq`0<0Y$4{6&YvS#* zX5Bt<*6a!6XM=A_HdY}bR5XlolK|e+P2o{>!2=LX!oczETJ%18|MHJN;t}X;8YjQV ze&^A8`9)P*a7pii^OWWo$*fLM} z49VNcpW>BL92IaZad*g7k4@>)B!14Qh3*LpU;?fk+DJoloB8uu%pGhrH23n8^ups$ zyl|xY@fY@%4H|mK9YY6|$@lZi+$oUo!V^4UNLksCL1k>LrnC0^DXcwxg<(Q(Uv^?b z!oVKVz-~S%DH$5qi8ZZTioctsqJhM9*t*EzT$6rAj7njW17lf3Q{CSCWzZ2iBp*d12!%$J|EJFV5%GN6}gGK4*-*^fnz_*mw1f>d5!TxzAxEoinJJQq9w*5ijEkIh$3-?i{D99Olo_xy5nK`)1r2_ zs}3!pOIsqa0{Lrda9kJ;Uvt}9U^9rL^fp*MvoWD99+mB=FMUf(X?k3rf*+P2(NcsD z^eN=cz^z&ew&_#ErbeWtkWu;+W1nA7X({4k`V`YcH&$pVhWGU;=GtFMv=laX(xq7F z&lpm$Ej)$&tzuw!KRm_oKJw9%3N)cwODV8btH6{Llou+4@I-uuXxm}|7>`X zT=fP@@OZC$;f!~eAlPq$TaJ(`bY>d6Na)G5Hk@N+*cKI#aEx1G-mDb`%^%jH-QGH- zQC+<9LEbWQ(i=!<*1WK1w0NX($!=)xZjvTP?1nb)MjPq&-Dh?g2YYwZxvcH-K`-vY z2@pu|l9QYY4QqMJZ~pnBc*NVdgcFCPdF2RBA+*s;w*#57{v#?46GjSSgxiIQ!W7|d z;T~a@aGx+=SSTzN9u`&#YlKIIO~Ml`dcMuaz-Vq#=-xVwZ}zlC|2Fyr9;E{^y^Y6s ze~?Muz%t%Fc#Bu=&wC4<^U^5Zc}Voqde)P@ywlt0vztAyjN=R=4)r!(QMhJ>H?Ut}of@w^N8z9Ey|g=L zBGkc4DdVJR-X0*ih6%5UGsKIGkA$oiep_~3Te%s98;G6u`6gQS&J z3xFoqTjBG*n*zT4Xn2K zu$snH?RoDIgNU-}#|&C{OUeL+YQUwM;qluw@#B+jKEe!erm`8S_Ee!04BoTn(W&Om z!qI@k35Lv19FMTNoUOKs)sw^bsBOj;-8_bqW|&H;X8lvfDwh+2$iRMesIEkQ?yr^J zDpJmX#3ALFZI*gcG_2QhGBb17gLaJ^G-#x3htKfNGvd#TMmf!h7T&YFi@dqZ?t9p8 zJf{onV`w-;a!+ENnHGloHdC0^UC6ZUCRou7P5tBBxSDBd>q_VUh4;8Dqka1f_MB9I z(`5C_JL+%z5A{pnN%aevroGwLJdeCc{eqs>;04q#VsHM7J;5*9v#3FlIwt;Hx@wrcO*Rjc>x zSzR(}R0(^~W6Gba_Ez)1*`t%SaKl%%_?uPw0d>V8IP*-I!pu1U|#-h+=i{*^ozrUZB7gj}Ft7!e?Ms7V3Y^ z8o_522!*x+<+M}xLd9o>+i6tG50ei*d!FgwIT{zq2S4R16LcoSLbb3Q8XtNT8fmoy zzW9u;X4NiZHEVX68t7f^YQ-)CVsOn1b!qyU*X2?xQNgo{1U}b1qv>ZR3q6H^ z5&6q|hd{KR;ZS#JTAX13TD&k=wNh!dQlVxMsJS#eH3Svh`20B7+d`HF1DRo3I)~3q z59+KwKEutj7_}VC#;>Hy=rT1YU4|-oxHx?YBa|?Nmk7(>GB>fIP<~x*Vr~t#=+*-d z+|U6RTIR-XcMy0W6ClpfiV9(T53@IDt0AW-zIl65<G6AQGTN;cz(mcX7di8 z#cUL?xkBJO2FfZef(t=87L5K*=b=qsp+C@j7NG9*EUjnUaYpVT_c)A`aFTqL_YB%D z9wp!NwGoG#91@L(&~~j?f@vltHOuXALyo{n{FiZZS0!UL$`cTK=8#IYhxuv~>s}a~ z&FkQ6BW=_GGQe$fvtn0CWnftdJd;`>_$H+W$O*O3KCscm>#8P|x)}{oBodQCT=;}Z zE0Zd%7z~!qCYIM_JI}?8@Nmmiv0Dn^I1dl>=rnq=V8;#OJ-T_|w>UIx{)P?naT0vW zU*eQ5tQX`j*yn4(w^!$Hs4ngjN)mK>O63F%ABTdof>Sj*rJ==G;}q@lpIRC2-|@Yz z{Qv3uw!Vu=?eg;4u_th+`UOQd|01tbzmOTtGq-%xvUu=|*qi^l$wTY^H*cChI*U_V zhv7Ylf_`fXL!6~OyeskbYT5_>3vI#3DzJHw`fwQ4Yz!z*kQr2%f)LRHhJHOr!Kt9o zV^E*AzBj9f4ZBvqYb}6(tO>eba92)`3HVO8DfN>=T6c!_{kmwg%~xA!G?&X0s{qi#&!R4=Ktq8H#cYg(jqYGlkw53UUm* z6>@gZ2hd-8KCt=jyYHsgd_H{LCkNPfB(o1m_wtph0cc#+0FD|N(z9nzdV{6u*Qrub}=u6(JZ=A*I0SKg;K`?*@ zQJkzQW$~#%y(lOJSuOthUr#HAFiKKu)ylYnhklbpAy8k>YqT4CYnAbOgo`SsCKXTi zF#H;p87cuUT2@~#K#L$B--qRP9a13kgOE@HpQ=Z!463Y`4?;n4YoKp^J$b8f76-uV zA=PSmAxA66J|&V@&xB-U{l$8N|JT##ATI^2(bdQ=?gI_^XHq@MlMk{AFB*F{JXwzp z^4bK20;D>$v#eZ?V7SQ)FTUx{H{M01e^l5uzHX#z578a)47IBs2+m2UZ(YNaocIbk ziPCEvuGa4ijzhVk0sRhe>VuuG005Mf9wbT+hRUO0t994}gnB(13IMe~k*=K-k-hN{!*| z_5^X0&_MtS@C+9l|H?DBXnF8Qt6wVuD5!<7PU1xdLqgxp-LDR9A(>@5O4h`uM7d6&mc*xt|&@GyB% z+4K&{6YmT^iGnJX9QdzjEnO7&4n7qca$eQuFDw^u3}e04dp^YNVLi`a=zB`zceUqL z9uCW)^6*6s2I_54S|>0`1@u*#Bq(W|v$Qml7nY{sNK^{4bZ&mJJW{>H=I>dIa#bqj zLULYrD~UY&Y2%Jhhq0d!9fR}~89nnEJ-^eJS83Xew%S9^!ZsgW)cg~AJk(C4^0e9* zB9t&Z?+xM^q&eV9rlH8jzoN)hC^8&H)@+7f8N;tmU#>OG$ZRx;?vsBGeL~Nm10*kW zq6zY&16uzBt}IQVE&q{}p#nH4!$j=^4#S}3Q*!lIMzf%CnF2`}sUBsVa#;tpf!t(u z_g3{pkV}-xlwquc6UME|iL^GyH{(SGX^m@x<2xjgnsGSf*YX1Fpri@Qr@^#PJv^Q- zTFVEQj&<@?3dceU>I=>jnp{++07wf(3P64>uSz3}LTfI)T@n}FvPyC$xuw?Vp-Or# ziRwdud4X2&C{(O7g&xXiWr{)4k5^UpsyX$vM!hA_A-vaG>YP@4W~*8tTv|u#dK0eT z>Et!FOS%?F!E#JiEEQs)9Bd?c3JdbZIJ=7#T=mI1N!pBrQ*&6R1M5CnRX^ucldxI7 zBdBe(a`x_-1qCj?3B}6IIfaDHlC<{IRZxxtsAsUC9Nf9Xa7r@<^aI~@f;)BCD0(C* zSOYpxzv|Pq_<(%J<|bB{OMbYkr<6k(nc0{Zi;H8mibr`SC?TN(ylyDTYOja+coPzj z8r&d-TyTM*0HFdnwdFwwU8EgXp^NC^D%z2M(2h+<@H9N_h`ft`$h+8|@-7_|Hqs0L z!v);9O3ZCCR2*s8cqBXrtA|w~=B{e|Yts>)for1!ViMOICe8sos6$X29bj6E*~jR5 z(>wT7NE3k-%Iuu$EHO+CDfH+4rk*Edc~mvjhSe3aKy+3YhY+&5H2Xufx`KTivZOSN z!mrsn$_+}cX_OfQG)2(*S(ifcLMcHFgu=8vNOlPZjKFo!0j7hN>U2mUlMLY7M}2fU zh*OVKP-GO$XB7M{Tz3)O!>I}X+Eh9kx2eJGIh!9AT`=cp)w zwz=L^#xT-pn-;jVqG%Kx@Tt&=VU|}_G|lxxfocs)UJb=j9bX48t;buDMOyIZUE9x1BjOQ>cWRTg}O}%XPhW z(O|0Zm~zoe=b%IczfkHWc}0#&A>&;N*Kn=Kbd^6+aA+6ZDmQ!*|n%LK=xi<8^s|~&1d=|uM=_%Cdr0bGaOwo^0xK>iRCsN}sWwxcv zfWemHov-6XK5w?@bE|o^oC;SascDsjI~-Cd7m-MvdDRq-1c| zv^FDcJ~!LShmZy_Xzvw<>^*gmwKgwvh0n{j^3kT+d%Rv}xo)hGB- zr425-XzUGZKw)%u=+`I;Z2GkqmF&++-I|&uL*tKiS$x;3FYkDwTY2`B z&Wrjl*ewl64qSWUyJO?c!^8dEx;s(n`fydFJnyLC$W%(2}jIAe+?7R{Ty^tG9=V#Dud z8+HqELIy;Kjg6OxHP(+DR!ML=B&WH;k}6r$JFA*61@Jtg51y<#9&zYS>>Wpaj@15m z`ENhe9zp%+8+Fs~fB511*>k*hNAkDteE7{bAHMT#@{Zc$$FX(u>Ic`Xd2sb+z$UZl znz-4pLP!#_!K0Wt&SJLa1*c>erbdCY454#w zL6Y@6k5rS9U1$}GM8T#+(glI!fndnfW~0OgBp}D^X_Nil!`@kYwyZ{GbZ|>GEu#&q zw@6dy2dMKDNmq|oNcY*@Lbl|xBpBn&Mj_UpIW>*d1`dMN=z!|3V0>6y>-gYR{}2L} zve|R*EbZoel~-8U^Kf6$$CXWcP*KEtJA&*JI+hdXu8*jj$z&woBJb4x~n_hJ9^ z#fzuYH&DOni#43YYl2gVc{wshkW4W!1|ardHda|XUUQ<3F)rKvZRO@gGd5bItQ%)6 z+FbcS%&Luf84-x&C@6u&;GEwiYD3?m ze|Yf&_yM_!<$j3q?M&`D!R3n(F&2!$h@Hiq;p`f}?apSN{m-0VXh)~d%iqI2ck!c! z17*xbA^_sKub zd~NCEd2C3>I45))JF|Sst+(~)k=H+~ebsw|F#+<5Bm*0;|d4&Vl<11^GoNn|(X~ z%HOLWUHaG4&%JSJX#a^bMwB2+*OTSfXv55%wB*5io}5nq9btLH`R+51zSZIRt#AHi z|F&BP%q<`C=m=W3_9Xr9x9Hak)}jZ-`R_!9kyw=)^Hu4UXB3tl54OfeD?OxB7h zLGih7FIGIhDr}>8j$p>i{@4CX{su2QSa%3lI^NoU;zDg>zIeV7;*1Yy$`ofA(}nIr zKPDNR@d+a7d23p`g8Ybx>^vzu!X|+ylg^5tHxzUf?$*bq%gN&P#TRmgqNH}~%v@5%AF93B_g7j3quC(2v!(6Xx6=iI%1{rXA2)sWBel3CNX=YM+c z9J+C6+_>hg$Pn6tw;^|yl$a?cxDylIH*3Rhwt#8u--rK03)Zh#vEuh&|G=#Km+wP2 zF8KW*KT?TNT*&oBJora6g9eO)y?#tEIimVMfmquNUqSD*rEPB}xNQ506We#Z{`!v6i4#lNL-4ClZhPgG zZBM>hJ#)<1nKQ?ZnF+IrYb1(fwh|8*LN;E@N5(~JKwyllEayi&C-_h@83aEzShaY- zJf0?d@NOt09}Sh{r4jlu80%E2XO&tH%q4!TQvy{&i$nR8XuP6C41Qg-OQi@=iq+MT znPgI6U*mN6WYYgK!?K>!3#31H6UW{2aB^{fj}YpZO<{-E|egoFpiN{>_N2 z_}D=R0(MX?RXvd^7P}k#E+(4IMh78AgTzea=yp;xgBlkafW@6d5l`G1nangU%x8KK z^f&7q!NjCIa>lEd0x>!H7y9n)d*0l>BIRHI{p@Q^!oU0fca{8O+>0lM4Op=Jr9Y;; z>YDQ)8iM>Ey!kbKW$fK^aQC~$mwbEho_X|!plhiEJU_=`BH3|_$tXpdF#E9xev8s% z?s8W128kT%DQBhiFgbIepUm{{Fql!g>7)r|(+^R5`rl?`MRB`OuU!aJx{JQOi+;_< zR}V&yO76`io}X0i=u1hnO9^TIs01l1%Ac7fW!fvmh)8Vm8?nk7nwK&d;);hC?un)? zA3RJ5^!RGM^Dukx+^YTitJXd9%(}w9eG9w$d<}Rq8I1eWO0-NqftJxqdCPMfHa+*; zrVY;>9OZ-iO}@$|`L=Tj3|DAKhsfIM=43`Zd@Fj5ro3VKgKOziXxA^(W zVdznDhKTse{>^T7qen+k$koR!_Llm(KNLs5QMlh?V793nLeT=o}(h?c=Y zQQne+yd|O_6cL2T)vNq$>p^@99iyc($Lk=DBWCgdU)SeXd5#`EisRt24nluKn~9Ay z@-*Ub;Ih|Vdkt+y-B2O@Gn7pgUL#Mmb^AWpCnGk7-@Zp^A^MUOlLNus>V@bO`jX!d z3EdpiZe+Q&4u`AP+9hq?wpuouI3^V@HQw(wg}!JAE3H+oFK=Ws7U z8$EP5gl<%5w<6${ z%R)FZUtO(?kLqec3`8_tBcCyS=N3wYA&e{T@a5-6JEhLHo~a{EgQaX!adMyZ5rb|; zXi$2x$&hMeKO~Q-=u$F3${>ykpE?E14?6R=DH9o+KA0cO4w>d=Q}V3rmUCE#GKV#U zlGAN1Qav!kWhNst6qH|-_I~gj-Hg)uO?>@XKGQ83+AUK~JU;3$+6eD&z4P^FQ8|C# zJ%ha;`P^4Mmo+?o;*sH>9V7w%Ys!2y8x85>e|^}~FTf=DwqZpN6w`{O&(U)=^gAa{ zEI)qR(=X6dkXXF1J5Q{8js9@wJoG@_RCHJ08vZFDIgXws+n9~TEiBO_i7P8$5h!F~ zd6bn~`AS`#p{8LVSZ9H$Oxnu*S;>>3i1jXK=Ppv`&RJrllqLGJ(xj{^JtSSkdhoIT zJHxfZbl9a_Cx)nhQOwATyK`A#Lf-{lLt*N>F6f)!e(cT*U*R#cbK1|o4S&T=$Z{Tb zedWUbD~76J*9WZ_G-nA9yZ+%j11g7g@@4Ew?0d`LH3QCl#@bU69$`Jw$U~@^q=?od ztgH9LhMT?OMdMZ)`k$j^4NVk%yocS3&OG2cvs_J1#KpAf z-DnaGA|ebFk%B&fRXhzUj`F=4ei31)2_z6fPm*_najQywT?lTM4ig+-yL74DnhG)b z|1|e3&{34<&Ogt6W#5~}LPDM-m=NM7Ap|fi4@00lHH7de27Iw|QiTZ0p;T?LN~y<7 zg(GOGcodaOJ?g2C(ln*CdV6vatx}}c2IO$P++Ouyu2vq&X1M?V&+KM*XLd6)I~&_( z*^HU_|L^^O-}is>ekv)U=1fD)9Ji7DC*Ze2nT=KJ@|LM)0%)edzi}!{3 zfE@d@cr@|_VSVnr-##w9R3n@Q#bDtdTWN5*bXNDy!~f|7lZ2x~&m0G=8T@$Mfg9U| zpJxf1%MLt%`T+)sqtr3_1W`_vi@l?%a@0E-b^C*V*$4kJ2KS3Xy)Ugkm&=+JB52xT z3i(NrWvv$8x?OBeQX7d!hR=X6a}`NtfRWOH+TjAVIpMdD!5@48F*P0iNauPB%ft^p z-#hX_=X~R?okyv+MQFn`hIXRZ=^8>S_UHnqsGA*=ox{`F9s*Jgm^-*U(&40XS(67fsP}l> zyeBd()L?vOr&Bz!GjdQkkV8Q38S13Euio2xwVSKDaOTQ8?0gIf*x&|tAt;9akpLBi zq1uO`%7QWYkT;wg(QgiQNGkp`e5WTLr^9665OKrZt^ z7qY&Hq4tHFsqUdpa?jwLD}C%ma8zizn$4Ah9UI8ptz>qe@VHypMP_%0`@(fXcVA4e zbljaCWdYpc8Tk&1v#RaRK6vo6fj=?(esT33cK7GUKWDx(u>2Qn^VQXg-Py4OAneW3 zDeY#+c{AWybL0f{?eV9ymx;uO6NT4fYX6XWCA=^~VZqW9Bg1)Eh9Q@;oY|o~t1Xvy za%g-D2SQGccW#eR(&diOv7x?+f?NgCEcMkC=Ie`p**e2><$uH5#VI+DclNdqo`sz4 zpV>Oe2V(_G6o4wyS=H9u4|xl%$4Agc6k{7PG2AS5Q=Uw`4%d$P#4hq8^Ay%x+e-_4o9j0|xE?(Xv zYys!FDU!Ac7yB;kbnpDM5Biak&O!dZ46S`1?$A;f97kHL6wPub#AS%617ey5QMEx_ z?f5|c!0`X2-jPvl6TeCEc>XJg#P65;K)?+GeZr#S!lL2dC2bca%S8l&<6Jh9%5t)- z*@8OUiXsMS0jG@!SwL#mAM?>Bl>vmfl3o)II}bb# zo+BxiwmLn$)5@4FJZ&}e&~dX4>-C8ky_V)KE30Ra@h*e#Ts|Vs4M<4+qwK}YtzZXN zdNn)7%zXTDU$}e7-q+VHu2iQX4_-%7cx?k-hbCSgFRo>H9RhGo7Gd?E?gm~WWb=kd zV6~yMkgascD(PS&-j^#!@YpG_f}nuPVUd-2_JeJ+z09WrV?8sro_ayFFa6DJRZg<1 zrwaHcwa$bxxhr-S55e0!$!94`m}PK&bQ;T^Z#t7tBh79^n3PGF|xO4>19fii+l3)u&IQnB@8@35CRLCQ$f@5siK0eEv z&71QG-URY^OKy&pMja+GY$8+<3!eZLw)fD+Z@==t@epwy>W3&>z5!fPfCXIY7hZ4+ z2cdPk2(8n2Y@MpZk4e^PDzr}YSLYDx zj(3Q)*)uCP~tzv5??*|PqBaZexwNpWtvcZc@D<>xxtm#BYW-I zC35$$PtVXCOB2wchhii1=|R1jbOCbrKp}H(u$ays{LDh~$H~s2@xo?mf&kq_>dGz{ z)r^%zHP8g|(7O?%nz;Bd8l?eobkN!xZXKRLjL$$-!aVfX=Y->+Jw7JPvN`Ou2?m64 zVG(0OI(1Aa&KN#~Lc%NS6DL)e7*SM;<7E_u(JG=S70!im&3iDeL7n8Pi6y~WQ(+}P zKC8IIKgMFdZbHFCo3jWMLb=MzbC&Yi9*?WT>FUDUHE=l;^Y4?xSm9%6h9VvP1%ClRf|MkqYRfG;ZMiy#m3 z%lK;Zb(PsUEK^$IFSl6>$K|`a+%Def=A91S`AC<~TE*L8Oy`OAzDECxhIL|NmAHsb zPJ)D(4z8MmHDXLRKqLGaS_J|xj$=>Nc5!*n<`v@;sq zEf@BS!5!8+F@lTe9g6K15TfQqqP_$1n=qqRtrgUS#~~)3!)SMxem_6i~cRD z1@lMT{>V~h3ZnMdk<>v{g|A6iA(Y!A!=z<}R)7p{cBxYb2%pdW^MD=VCdjj}k`vjCR z&><@;V%Xkq=w1wqm4;4Oe>1Sy9t4(a#MhnI2;`FG7W9d}exX-%$?Ep(aqrm!>5XY} z4AKO_H$7N7GJJ?QssYTwUL(i$UVU8zkZpwk{`1lWQ|^T`mrtKr-H(9lkC4q<*gtf7 zYu$s8dJo3%%LgGVX5B81wgCbHmdT>cPJ5_SGSas@Tn)9ehJ2rT z824V9L-rg5J;y;$pYXO@c)Ks=caet?k7^829n42g)fnhi0iQoFn`EJDl>?#4jXGCi z--(!rF=kPYKp^xCI}*iL&Wf(ZW06Qw$>oyV3+z=$awY0o9?=vmb3=8@b9H!tH8;mX zdQFnf`5<7~30j94I2>*(X0KHaA74ljwbjT9pwc^6HSPvOh z3Kzgn6ICmATH{R>$;%afrY!hv$Q zWay1k+XipK4i8JVpV~n`Ca&^(2hMtEVxjoyKpWzLhYmOAp9swGrM?m zRyGl$vtgVbLaH0d$G94CRuTE|h?Vu|1wU4jZl5olyZgt}?%Q(Tv^(hW;YVkkY`mxI zp2nIwaHRQ{zni{f`s#x2eY4tUtz8~K9%2wj8_y8e6E_g^iD2+LBEVNqoI`|~=J2^= zLgRAz5-%T|RWgpA(B$c2r|{M+HZ&oNpWu=LxTpl;hR7hk@D&OpE{4U;;7TGb;wCpt zCTb(4va!34oLCMR!)b>!Fws*(YR{8xy(gG0~933{Bwp331lIEm=V^ltwT_tR7d6goP6IANuHVadmLr zt+SyzZ@Oj4`o&A9J{;`0^$)WbPnp&}4=VG$%a(3jH7Oy^CiC+}oo(LLC28!~d@3f? zM%hJx{82xQJW7MvSz~!uE>D&5ToKPvt}NPH5%PIat$2>qxDiC80~zdSBf%ocFhyht zjv8EW|3j_Q7fzdYb8yjp>l+_EE}XjYr$4=M>dlL8X>Pp#M>C$mT?VVG%NrZZD=KiW z!HSB>EtApj$Y-bmcR`7N6rcSzF6e=Z&RJ1cMz|D`42;plhH{Yz8ui^O@`$;6Vb4DI z`92T?_UGvDhbyR$sC8iH&cEF*tnxYlRYw&EtNh}k90m+7+$rSx@doKJG>seMXH6Q z7fHVeHgqe$w}{%mIy^S(Me1?sQTeUIt<%=89BR9t{P@8q(8Zx;H@393wcNNY@*Tbo z-FN#6^64!@6%Xu{RaXvISps7JwJ+(VxWnnVpvz_@I494MU95|sr59kZf{1Uv)Yn0$ zz*Xq-Lba%`D|2~S@bG6sQ-GZN`_^{h;!fAGKRD?s;m&uDHLd;0uL{C<-?kOb&`WOu z|HQnFJLwgyX=-n8f**Pl{(>J=)6q-*Ub;Ksv~No3*(TW-!gq#8qljK$jgD55e7uxn zryqWlyR4^*Q z$|}G*H$7;JbV45eFWOKTmI03Fz1Akf1Le%6;3;M zH1R@dRY-&K>8;JlIiL*%qcq02wL4AOQfJjOz&CB)o+j&^+G;ZjxM=rJH9@Zp=onxP zqPu=g*QxPJGGqwdb!xk2!jBZgsDV2gKdD)+Qw9bj+jz}u*T}Fb5KoosuR>DNkK(3z&VZjkiyJsWZa0>MziG^8G3C%Ym6k)@+#EBu2Gs4 zq{tA;>(zM8q#wb~Yoe|uS}Iu15h8wX)wR*H8L!Zi6 zq$E6zV2#P;s+3q&(5VATlQ*r^Cmj(smE2a~GA)BuT^`&(U5_Chg);mx(5Ul;llr`= zTwH{C+*~R%@)~nCYH}E9CGRSpit22?|ONNa`X^~;}ub1J| z9`Vbt(`N<6IqbS)Xopdu$-(#&$N>5ft zt}9g#uRAlVGNYg|lb}D_k?bBduxZR|8kA{DldEad+Q?i}GHa&>ez{#ypKD5`wWF}7 z5UaI@e&SUCb_;F zk*SNBOhY86K3dWnC27mm>5GO8t6R~W_0jAnXo3A(s4vork8ZyF3se|s^;0zAmt0XZ zQe%`m93`4zl>4_B&o-gS(~My(nwX7QBN$Nv7D!InL9a%#^mkv zS;XX!YofBDSv&3ChYBl~lI-7B&TbeeEmc0FnzK^^F>Sh~owHLUTxy?IM{X+3cfyRy zEg4m5lZos?W@~NI@&1g<+8I-AV_;Uypn4nal50TLPG|l_tACbm-Y%W|OPW?kSJjQW z+?~1%l~Te$E$+H1_)=Mu)a$mUyqzYiksLNvbV@I4H=<3DK_^$al!VJv&Q5t<9_5@} zN|z;LIXgwvN%VevbhhNak<15@yFBV^>r}>hDU0j$=Im6)_j1WeW$Mz1f7N@P`l{26 z&OPX>Lrb&%EJL{m^^uaU3bVAUFVe}4WTO5;m4|3l-X_foj11|#t{n>9_D;jTQs(3) z{T#q3$cr4B%gNRd~`CM`H!^ooWmgKz+1Pz#2_x7<)iG`q>ghG z5A#u-xk(*?%6X=z`aqLMsK#wDH2PKJA*g_ERH9$??;R$7v{fHuIOnHfoycU)PiqB& zF?y3&fsmfg$5$L>h;zE4P~$nLKMqZYbIIb+n0Q}L?MSTcR~J9x@0Z} zbReEUM}403ygKst!+1&+H~4NXR2v>6Hip%;#;Ng3lC5#+RupUVXGf|i)_2x}!TxM2 zAcr|+P(5)vbk$2~1>H~Uw1h9@kUhf>y)0PzOnW_O>H||@o$yu$$c1*?^M!cUAx&DbthI=B~M3bZ!$H8@<2y@ z)uAV^q*XV-DwFqgV)%5=wTh12lza|7RX3VqP1)SW@4>37CDjI7 zM)zQ~!mE+4qq+yHN*1)i-=q_2O>gLp=vT<#QwNroI7|&Z>Ub|Ri-t#Gn$7~&tMdvw z6dA}`8koV*AH%17uk~Lg`=joJl+n)qq<)t=W6`@}Me=Az@2THl%3SoWT#K&Kr%x5V z$NTi!_as%tBL?nC>W|;m^u*G>Cuwl}uFfkkU_Vz={GNK%*A)$^iQiM56s!h!Ln8Rp ztG2EmTMc@J16FtR(P^9hfdI#qwdy?(Jr;HaAx8Mni z>`@oLryM)g92$0{l2&S{nL7jfQEK2x83)N=OD7H@d-Tvw72nkHPqjB|Ox{QRUQ`D2 zK8pG21Wp*QGjaLpI1SaK?+RH?~pfqP4 zl+HRJ<0z#^=3?Jx1>%Y&e=9WleB4{1qP&lk)tAkk^sP|s^>N8%Erg50Yoh1^G2VJ zK6LFG*iU{;EnprcS@L%T0k0ny7LdmY0bW0eujBqs0PrehqdJ(QkTaIo0L24hQf1Bc z0Mt_8RbYSi3Cc$PYG~25Ys6vt9L+NONtXEvK1TwAK25#KoX7at-*6KwZzG6Ffyw}U zIvKXtH7s7-FlEsqp;Y>XK8?SoNSE*%LUfWoO`gRgTV8|15+Xz~o@B%xVa1qX5XAv|{nDJu~KGfIx zyxFyZ$@mvYxdX4fa`S?PH@)%-efrQ-m6a>ju3b@C`P3oAQJ#L8YG&6!x_byu(Bgy~ zb%qF=I9vh?$^-RvCB;53*}m}yKiG&q9UUF?%Ny^!b0hj}y6yJcZ$ldMIenb^4|YD3 zkugE1m*NR;$Yc#UOg!NTiQ}S?J@s|9ft+jzCkC^mczpSIIJ)oilQ-Tvr6zmwOFiS3 zH!Q7~Paj`0aQ)bvHPe^$=M_v`KOM~=$z?n@J@rMt!_;c}Tu%?8(QS~j9J?Pv;rzA9 z&J(6k4(2363ZgW*z79^ul}{Ftvy(m_xHP%Gcua41_$Dy@wXexT@q_CPidu=_|1(x91ZaN@7+`~$vshMzkSvG+uY4$Vf&JH>tQppQ4!n1GN-Z4idY1e`76GT zXgbS$gs&qS%`$&O*HO)7nUC?`qngG1J>j|!Uq^I^WzOL1h$gViSv+1uidp6pd>xTF zmN|#6qf}&>3!;scXu>l8gKQ-suvOH{+!Rf=vLew|vQivXw}*!|+wS${OWI;y4BGuP5AbW|; zWo-cB$gw`sJFC3#rj9wYm;UmEARWxbJG`(ce&BHkI-*WOW4h# z{eu<>nZS7a_nW}z@}PGhA+#)kSPs-eD` zEGf3b5Bg?V@!L)zk~Sa$H<7i00y6$PJ3FVonkugcKK8}GY@Oi=uDf-1Tj#1xx3u5W zwjx;f<3011-P^H#?vk~^8xK3r(v&$MaqpWKp?T)Y1njuHP`^k-~7RYf|h z+M4@^KHCcE`U$jUS?mUd_2-k7r|yED!Y*M0vh*BFQ#nv$WcQI=!B;C;8dM3V6l`ha znq*mqK=JAh`ZOKDUXgXb7?Z000I60ssI20001ZoMT{QU|`?*Pl17fWy7Zjzc;gHFaSl6 z0UI{}q2C6R0001Zob8xhXk0}U$LFqNl|E^SV&exE6oPcI4}O-YwMG%UXedF7hz+t2 zF&GINYARR~4VZ@@B}foiXdXgY3c`X#`p}BdB1J@`HYjby=z}jU5!&iD^sejw-1$%L z*}L~9YfR}&0zb~o+%sp+%$zwh8(T3Wwqo+vkO#;#=*?hS>3F$XWQ{oio}?_(*YbZr z`|cS{)VEreV_=H%mAG58UnUjh7A%$+=mBFBm&xx@O=cc}O znKfEX+SJ(MS2Bi68FZ1(6teoP1o7dy6#BuVEs&P$E^_XEjZ6Ht*H)@WY~|9;PX7Ie ztps<09N1*tnelcGEAB~*aVOYj8}pez$M;*8?|j~vP=0mWI39g&^8H3sKhZa=KTJ8w zez)4j{M$ym&%1Ixq<`M7&kuU6GJW&8p_g;aQ%~yeIlZ7W#&p=m*pj)vW7F@3r|7re zgR&~WqV_M(z4mh-B%gz$T!%s0+r_;U8)LTdY>M7nU?0n3^FcMPk9R*-nSFJf_;@OD z4P<^V*~WMk-QARfY!kWD#%JC8;p6GBDo@!sHoOmc8{d&d+ZZ=f^xN;Dy3gwMY$Dqm zZG7K}ypMhz1Qj*!4_JPN^H~QzR(qv$mFV6GUIveIJ^R@%yvFaIaegmwT)!Vf+&A&( zee2>^_H+-|bBJZg0OvKpc)=3@YY}XtM1Bfhr|i0ZdeNMB5?}Uj4%CAaU^15G5tdJI zy+31p3uEU?Fw9u!a|G)lzlD_e`W|w~)y*&9N9qcaJ39#50vY2g>+Uqj;Gk;@d>o3c z3vP^{9sF)9VV}#9f|Pw-%Mj}kA|7jRLgIXeKckE|Teu7z*t-HBYQxR#QjeHJ`?&jd z1-^wA-+Lg>5!eoIx0S2NY1M+0LNs{lvV9Mut)v0v3QvG0p}VH7@3`*(`Oq|dvO z2js2%qIJ2lmuqdfh6CKQLc*ru`JJI0uyHOj?u}&!ZF^kK(n;;VgXpp% z>X*{qgyEX+$godai}se9Q+HerHYduh_(DX#PEG9D)}KePWeMh zeHmXQUIob;zU=F|Evp>W#~ivXYM*azmo}5qC#ffQccdfw+tc-zvyp|^T=Ye+t@nAp zS+~KjdF9u9^E{Wcv7~S9DQ#KnJC;38!+wor==k|dj*+*;vkJ*`{w;?4!91pP+7)+$ zhxi7UJLnd0*v(q|Uk81z9-TpXza9V>BY0Q!ON(V~_q4MW z*--sMj8VgVR6{~)!IQCCqmScZ}JU0#bE5^OajT4PE4xO6J1!esG3&70r!vFvP0018VO#oj2aR7_} zrvSnL-T?Ul3jrqqLIGX@c>$CGrvbVF%mLm3@B%ghNdjF0Zvv_UyaNLRI0H%pTmxk0k}GYV)5dJ3irxC+V&+zR*$6ALd3N(*HRcngaQoeQrE z!VA<3=L`7^3k)3$FbqNriVWNg?hOD9QVoL*w+;0UF%ClxRSswlvJS)!)(+?n`VS2c zSPzpAuMgc1?+^hH6c9NOOAuWUZxDhIl@O;8xe)6R{SgomA`vtZMiE&NYZ0yy!4cCD z@)95tG7>}*R}yIwl@hrU%M#rZ@Dl?Q6%!{DITK40Z4-YJkrSg6zZ20D;}i812NW3; zZWMqNk`%2J$Q0ug{uLP&D-}l-VikTBniaJb$Q9ca?G^tP5f*(GkQS{LzZX0gP8W+8 z#TVBX=@jP*pBb?k!x_~X=NbbV9vV6tRvK{{gc_C_sT#W)%^Keu zIvY$Ij~l`q6C5QRHylYETO4g1njF_0>K!2+K^;>aWgT}NiXELDuN}c26dooXc^-=% z$R6(>0v|OWNFRD1q#w2)?jQjm6(Ch0m>|U<*C7xgA|W*)aUrfDz#;S^1|k?DDk4TA zVIqAZnIg0z$0FJy>>~an5+fudHzP?STO(~FfFqJ4;v_jFOC((+z9j%9LM4PHyd};h z;3f1XK_*isWhQqfi6)#U1}8EnXeX^F@F)T(6(~z6T_|rTfhd$Hrzp87%P9~kA}KW~ zNGV$>Z7F~$k}2XT^(qG{Cn{DdXexRtjVhli$}0XV5Gx`pH7iIfS}SZTek+hGvMau`szX(JN5N@B{MBEH8VXkS~FrZYBO>(o-?pB^fUZ4B{WDhYc!WMyEN`K zBsE<%pEce!<~AcXV>W9xel~_SwKoAbQ8$A(tT;h9nmEWf&^ZG+IXP=NlR29?qdB!X z?K%HCGdfZ_pE{>H@;f;@Q9ENhfIGK4zdO-89zHrgLOxVJy*|Z0>^~1bGCy8FgFmxB=0F@kUO=5dr9kLGCqXYkLP1JFh(XUn zAVO_ImP05*enYH7(L@A9I7DegaYT7Ufkcx;)I{b*F-3(%jYc3wc1C_iiAI!0=SK!d z4o4P8KSzQ`%}51EK}d#3-ALs~?MU@W7)c;WC`m9$Xi0EMcu9ash)Ix1x=F%G%1P2m z{7M8$3`!JA97QRXPgRsv&{f%0;#KQa^i};<238MN7*-)xDpoUAJXS?k zPF7b|U{+~Xa#nj*gjS4JlvbQpq*km}v{t-U#8%8!)K>*pC|6ZipI69O0a!g)T3Dr6 z;aK-sPFbN^8Cs58D_gc)C0s#VRa}N#uUx-e=v@(ATV17Hu3gYx0bUMXU|w=whF+Uq z%3kCBs&>qF@OKAyEO$S5ZFi`5 z#&`XABzQ}Bdw8yR)_CK25_u_kM|qri!Flp|`g#I-C3;VKa(a4tg?gNO1bdNtxqIY% z4tzI!aeS(L?R@%u2YnQMA$=}=JAFufRefT8Z+(1yiG7xRqkXV_y?x4k*nQ@G^L_z- z4t^efGJZmSQ+{E7cYcR{)qiM!kASOy*MSXzGl5xwfPtogxq;1r_<|jRQi6wqy@KQdK!sa{c7=(BoQ1E2!iCa>>xKe`8HO;1M}}yIkA|IwvxdQj z&W7WL_=g3D8i!1WXoxI`PKa=bwTRn^1BqOTYKee}m5Hi}y@}C@?urbGEQ(BuY>I)3 zkBX#n&ypYO} z-jMH+0+ADuA(17D(a9G*j-k)F$+AfITTe4m-0xu3?L)}Q2`@}L5s5TG8QFrYr5PM~U_c%Y1+p`f;) z%Ann#@}UKxC81HFWubkcm7%hs$)WY438Jr}%%e1;U88EFrK8oP2c#~fRiw$J)}h1`l(5&j;Wie`>F@3 z7^*<3bgHnb!K*r}x2wdf)vN=oJ*h>(y(8! zo3Q_}OR;*gH?nB5XtIB@rn23$BeQw4-?UY<+qF@(x3(>|l(y=(P`AdoEVzQW=(#_+ zhPm9jL%O-UAG>zD+`KcqdA!iQHoc6!)4nObaK5I#?7u?4sK6e;guvRtK*5&5-NH1& zo5J$LIKz&^)x-?MQ^c#q62)o7u*L?)O~!=A%*P|gYR93+=g1z&MaX-|!pRxQfXVmD zUdpx09Lsdey37#FcFfGp63uAMug&MqF3yI|?axHdjL+lHE6`!ksL=k=MA3WE!O{5A z9@16PoYM2tTGO%9_|#9-mekGF6xD0hveoF;F4kq%jMl@~3)f=Tsn_AyDA-@vhSC-*W?c5RphMY6Xj~|{JXX%~kauQTk~@Bne))} z7xZrQwe;-uGxciqk@d*-5B5g(ZT6)0%l6{;3HLzvhxfks<@g-v*W%-i%uKB|G*7@`L4*DtjPWpKIsrtM6>H7Wq82dE)Nc(B~llzh>B>+(X1^@y85CAU#ng9R-%@2bB0{{Ye zoUK^PavN0;y-va-;jyA9D#b#TV&PD-9fwC1WTnXRgUC{l?1U^wBUzqAmc+~`iZ?7+ z^CN8d3O;}ZAHYYjpjgp;y6iBK*@!GFCzG*&K z`$Na?n0sq~>iFGY{N9JZU;9MI@0%|_`pf)GR_>d7X4`y4qeGLJ*raA+GWtI;InC8+ zl$t3%BbrHQG&Y;GQZpm^tC$YW4b3U7Wq2CU_kg5d(r2F}>$EQ;y%9Yn^eH;~w92wW zGb1e#$+HjJ^tVYKZIe$AOqXnYL;nv-I;@6`1s$txRTdrHFt40Kb{Y0VJkJPyD;?AK89hhja{{C{38lz9hm|99PHO`*N48$jY7N#T z!iTiN8RJalB=@!1qQ6Xc=cT=yYHgWk8N8a3wgg$O&^%ko!Hj3{x`J5G>3ar7NUVa1 zmFBq#;z~(^y%?A(J@+9u1;*!?;fUGJlq3_~8>^OqK1a|!MP|=PD#se{>cJ0-^#~F1 zF6)=<$bfTNnmKVXR4yk~^bI?3OqZ{}c6aapEae7GvMUV9&!hBTLgsW~ziyB@ME0&BhO*Mooq9Erq| z!=CL#Z!AY5oG=&FtL1sm zp)ymJ6yx24)_q#-DI=$-*#!Qi&~f6bzs!{~21D#Ug+|*ew@@3!ez1kpAv_~CAk7@w zqP?8M9(G2k5{_+)&^w@$q+wc=sRkiw^Z(dYU>2NS;;pc0pcOepO^lU4RYb$9gWxnd zMb!+IEw&znpHn>{#RB`7(^`U)NB9;gzo*LQurGQxbrpv>Y=xG5Pvy9|2S~5CI;+U< zAn?$47g?JC(G!(Tv68r6(?him3g<_rr(n3-o?%vo8B|^suPlrIO|%&}@|>RHpzq`t zRI+r9uo9Sc!hmgbz839Gf)4U}PbxGo{El1)Istcb*NfRA_ccDdr1tEMgSAQz%Mg?F z={9g4;z{Ji!eI%$;(aUA{Drlf0SAAgh+mwo6UYAhi2dqTFQ2zX?|Rx-Mvi@}*yfqE zrGELy=9)8~gKP<3r2k7E{5c)LPrfC|&A?ZlJ!7Ogvmm~Ryqe%9?&VW=Ul&H??i{oH z_x-QfdAUQ&`ID)}2rTI7*|m6<7a1xyhzx$RoektpcqKjk6?{6uSu#+LaDP3+jt#vd z_7T+xyH0gRvTS+a?+^ob{-lULMgPrReq%(+9xT(&y(ibX9NkWs#ktIaY+8FZ0`a1! z{Iq9_jmjb+u@BudYnmvsMLR{D2avZnotKcDFTIP+^xkQ!=QHgblS|afRVpRC=i4`~ z@Le#}(xN?#?>b4`)t)Q(WQg&V-Wg(G%vh)H);TueO|*Ot7EiuKRAS^u?xa)KXC)K$ zB{j0I)hkF!1&E#H_uBBzz54DmF!r8!7I4I(Ggfr`y*pJbeN;tVHrJWU_UFLW-WSIU zco(z#y14zW@T{q!6Oo(nQ~A%6BM#Zh+`RTe}2{x0QDx>cv9bd2w5zj%9Q?Ax_{^ROMj_X?4V6rsyRCU3veZ)`la z51j|R9=kW?7-y{YCHyU>uhnS#2Wtm3SD`nW z4y`<)ryZKzp_w}*(be@k$kbCBv&ObLhAz?3p-FIo?sHnPYn9@t)z|%*0-ccJX%;``8!p^oZuY)uLAiuxLFyq^Az) z-`CX|={|ry_P+|Gc-(S+?y9FXV8oh_@J@J4yJ}c@OydrHw(*WAe(b2XEyS=(>k>-? z9!Ms{qdI**Cfh8rM}8f_3$}Eml2t^=@$6tfTYZFC@vN;`=CRkZsaT31jQr2U%z+_KaH7@v0MGUk56j9rkaB#_aE&%fX_*R$DO#%Q#>y>>-K< z@MuE1OMW*<{=m%$>kh!Sx*{k#h24_*W`JQGx#IkNOI91ooEi`+Mj>^;{`)8d=bC=j zvA2yUpW~M1u(F?;-p#B1p7jrBHd@1YoP9h8cpSymvwNp0Dz+@ivfK@!6O6Qb zWkg8Poldqv*v7VSOp0@o*3zNVow#CKPVa&ALIMdS^cp%Lq<07q2&7j!>4Ee@l0X0M z%$qH<=h&Za_LX^U-n@CcSCEOszyDs0EO#)F_78WuFGrz!4HGZN{dff)z$?)`cokla z*Wk5y9h$`J(T#Y6vDD~BQ^qoM6MCP~gN{bWp#Px%qG_Yo=tIY%UmMGfesmmij1|TJ z`X)XCABmrbkHSZz`;2w?7<4~A);PjA5+8?-M<<|v7)KdL92JbBsCk0(2_+tx-mw!>1V)d^#S-XP_4vHS`OkZZwRhvEMjg z95fcuC@L9;@K(GHZ^vii9cT{k#JlhWJ`3+gW&Bcf4&GzD-FOG880VpRbg6MZDxfj+ zf9?WSjdvRFLYvTUjCUIb+Kd*A_ZaU*HGDRH8Gbo_1%4%}8yDcc=v@3N<3i&iT*Q-T z3px$`)415U1Wy^CK&Km*8keDQ^h4Azu0Z?I0X&VG*fFj)t}(7Pu0vsqFd2U^bFdCCXD-x`|&|^mhk|38vWaN5HF(hjEB%} z<6+}F_z-?IdMSFH@qP3L^hW#|^m_bS<45>)`1SY=_>K5Y#v{g~#$(3g#uI1{I@@^C zc*=O%_yu|ydO7-?@k{hs{AT0V_$~OY_-*JF#_!QDjc1H!jXxNFH2!4#+4zg`SL1K^ z?f4zWKaJ=xH+~O(FMc0>KmGvzApQ`(0Dl;N1Yd|R!XL#S!x!UA z@W=5d@TK@Nd^x@XUx}~6SL18&wfH)GJ-z{d65oh#!Z+hv@U8eZd^^4ae+u7;KaKCg zpTVESpTnQWU%+3)U&3F;U%_9+U&CL=-@td{d+;~$z4$(SKmHbe06&PojUU1fKUS?iyUSVEoUPacMSDV+E*P7Rv*PAz(pEPeYZz3DaTg+R{ z+sxa|JIqg+cbcCz?=nAQewG|ze$M>7`33Yb^NZw2^ULN}%&(eXGrw+r!@S$P$NZ*w zuX&$&zxgfm0rNrg+vY>&!{m9W7xkg#s2{CB185~$g;t|A=6A_a%s-Nih+3V8wQCa0p?(Cy@fD3?f}ucL3EJJ6@lo#@kvB)Se=kG_b$ zgxXMhf+T2yCE61miOxh1BsQ1Rf*M!HHo!}b&2(f z4T&QXM<$+^I4W^;;+Vv-iQ^K-Cr(J5n0S8Tq{PO=$%(TLL?h?}i6Xj~>>+2PkD`mvRpe#p!i0k;x|_V5yn?)v>?N-vMKVdI$TV@t zJ~BgQNr{|8=17^$lM1Pl1#&K_kveIRCfQF8kb`8A93rnKuOY7`uOqJ~Zy;|ZZz69d zZy|3bZzFFf?;z)q^T|8OyU4rAd&qmq`^fvr2gnD>hsXuw!{j66LUIxLDESzEauvCnT!TJCt|ixz>&Xq|ljKHn6SChRoIFAvC6AHE$rI#B@)UWR{DS|GB7HtRiEgAP(?L2!Eo##gP16j`Qcj2IDf9*ORQf{tBKl(b5;{V2G*3rqfsWBl zbTi#TPot;Pae4;bO1IJN^h~;g?xefu1U-xHrZ1&?=-KpT^yTyw^p$ikeHAUzNjgQR zsYCbC89GZ#^c*@z%XFSrXcavVG4u}fHuO$(K3zcXqUWMFCts6%E!vBIh2BJK=&Q7z zd;@LJCf!dD(1Uc59-^= zd+2-V`{?`W2a=a2FH2sYydrsJ@~Y(3$!n6=Ca+6gpS&UY$>fd6o02ytZ%N*oye)Zq z@{Z)El6NLQoxCggndE1apG$r|`Gw>clV3`HIr)|3SCd~$em(h(4V$LS~NrSvj-IlY2jNw1<;(`)Fp^g4Py zy@7s`-binvH`80_t@Ji}JH3N`irz^-P4A+gp`WFnqo1c=pkJh4qF<(8p^pEsU^w0D!^sn@9^zZZ^^q=%O z`Y-x#`XBm#^uG)-gPAPBlB|tkMi^y`wX+V^$-3AQwv=_VWvqwwvOcz)^|KXhfURV! z*lM~uEH&R|>FHnyFe$#$@vY!{ng zXR+PvrECv7o4t&^oV|j*lI>-$VnsH|rr0!d*giJHW?6}y!{%6-&9e%tvITZ7tFbz3 zuqNBj4zPo4ksV^MX0KtdWv^qeXK!F{WN%_`W^Z9{Wp86|XYXL=vGdtG*}K@g*?ZV~ z+56c0*$3DM*@xH#?8EFM>_T=C`)J!UZO^igv5VOy?BnbcZGUB#w*8%5#x8Grj$Og7 zWLKg0pr4^UdN+C${Rlmdeu{pMevE#C9zl<>tJyUeVS`=Eu4C7;8`vi?VK-vNZo(b7 z6L;Yycq#5?H?v!CFYd!5lQn0*(;>LzwdP4{$EX*V%_Jt6>*f5un%|?`f_7R_pjLa9OYSjuE zsm@d@&RqLQtyGy2s>w(}j2-zvPS-=J`x1ZI*s-l%F4kvd9CpAU!?7bA z$(=GocZMRpGqR{oZN6BUo-Ehfc23P6C?Qs5__~??nKnQUA z_~}cwM!{`SaAy=eI|AaOtdGS|eJn=nV=+`8i&}jwhU#NcPxWGpR4?kOUTl@>MLpGv z{z4XmxD{c6iclYmL47PrS30Ib^>HewkJGX~IGXmHm!h7@eRn z`hjf^;z%5D#ep&{FB`}~Q44~)0^p0~v%I8ALJLhIar?)Nw0m#jm7QSxKw<@>E+aPgP%@YOCd`>giSWqf!kjN>x;p zYEV(Ck&03cDoQn4QK~^jsYWVF6@pxaN?Hw7(i#jh9JO%StIMd>LrYK((c8+^%1k}k z@XBXHmd}PNpN+7brc8mR??N+hwke!#2F^Ak&Nc&Qn^9+*fwRqsvrTZe3FWgX%4bv7 zxC7Fq1EC6ez%QQ%WcgeS5f}aPxhTu$Vz74>p?oe%(T8ODJmhbRcDdU(7R!z9lB}fX z_?2`jS{y4;uo?yHQSeX%Y(KbhzFBUR7Rrm=mFE0j_}f@6%@q4W2p>7%0nN&^Q`@^x zthsy0O?E+OKd{&RFD{dn`Ra6XS_@tz3SNHK>dv%>+2ZNl<2l)On#V$R)usDen&MSh zH0p=Yt4DJlu+)vxv{QF8$6Xm0UUGUd0v0M^uyewlSS-!Vdd~LvfG=&y=E&@&`KWNR zb5=OtvAyV~(d?Xzh*$N+ipb3NF1NY6yCxiKpYX9=VzosA<~Hr<5(^bT`$ACClh-yW z{Fh5SBceAW{htY^(6peJL<*IH6e@`nl55m#ZWbZ7iNpeoYN?_ zZ7RAaDk&_~OYXUwJljPj3NCurtb3K*;vPC~^;}pex_6TElhZ}>jHbD@X}+}N-bR(Y zn48;6i5;`mwwB1s3i z5|{`vw{M$wW`N0KSLf?eU{WwUy;$10I6qk}w-tq(lb)M1o||qhC^w440cQ_+&U(a# zaJJ+*J13kiOJ^%h^I*wsbA&~S+HAGG?zy?IR4JC*#ATx4dfWt$7hG?r+&>p=t9r(= zlJ8b2Wbv{vAj!>;qf47nhx#;F&Zvgs)L!*N(_Rch?Sw<^s8G+T@UTNpt-8^B>GWPo zm3>aFRIPQm?TvrkIN;;E8nbR|tf0<)Rj&h5Kxuz4r(Qbf&#AlRr{Y77R|xz`F5aIc zZJDlA=c%h+Ubwo&MY~WeE}5qlu3VLp&($aSgfe+4G)YEJjl2>%+%BaibchR2hj#%w zyi2J=UU@nMpQl6cNgd)+>JW@Vhj%3+S#K@`F$w@zm;$^QscRV{9}-giNuFQ@FxP59 zo$VCX%IkWECm60fg)9X)2dCUbo~|}0%MNv0%Uao$cFernOS?U-+np`AYuoVdF)K4= z_ujEMHRm+EhI!uYGLMyPHBZ$4;%oQH(d?po>r?4+Yh>w=n{UOkTbjE>E9f@%ZjGKS zcb(g;HvGe=Yo_V8^Ul1}@XT1^Lqc1Rl(tt6qHSCmL4_%+q7?V$t>~%|q@{p!Z4q#9 z3!c?cT!hvQH1B>WkU^7 zjqtAlWXl;nA}6N9u}~;E$iPKVVah<1;+)$omIYx&l;Hc>2Z(cW*6r1cbL9ruD+d{n2r3+qWr`9+B8&;) z`&0Z4i3%dvA3}uC>jE>povT~5Dn!;utXOetV%aGbyHrrThfi~)GFe}6*P+WR*LBBT zE%)TEB`1rsjimP=go${2AxKWUQ<6^8J;A+~sixZySKJnQrs>Y9l*nvtaet*$Ow5%Q zlI3Eplq{EO#SZUfSF<+fHnnZ@#Y(Z!Rw=qDyx=Nx+aPzSw$;2d!+l+FoDT82#ce-| z755IsIoBz>_cfIn2RE8CwbESTK((@DvguavM%CRAo~>QerTwM4>yHRiha2b8{$klR z%~=tg5uSI>L06K;*zTS%-o06Qvf*Dg7JACnnbK6T?8a#a937n=)xDVV!j;%IJI_j$ zshZ;*MQx%ZYIhs;S??K5vFtWxBeh!fK)a}*;*w2Q4^-rpm&)R5iFdWbXX$7zj4Gz3 z@_MpZcLdy9t{!k|+bT{$9I-jKpPuVexHH|TyS52rzd-Ktj%mqS7w9Ddy}4N4C&1+b z9COd_Fw=@)E{Lp$WbX7%_%J)$=f+-2Z!Ro^r1uIiYHW|^YZTQBsId9VJzUIs5i;=E zz0&RO_a{Zn7P4Yi%QcY(h|8!^GEn16-%*-=`u4A(77V3Pd zHdS^y4$PJs4qUtYe+I4xycLmY;_~hem{y9FXh{hru0{zZU6j%rQ6q7EQ4JEgJRGTj z)em$?<{BUA=Ge4bq^U>X zw!6od8!Wf_*NXE!!jcGTnczAL7G;Nf7_{&6ZXu?OAICA?euI$!h=(%}FR-JoD+bX6sCgP%{MpipBtBFL)4 zaBNT^$(6+6U6~no&;Q-Q608x{P3YS@OdQv@w;B(rcT7Rx1=tU^)qA#U_z$% zhk1hN^@kY)v|IKCYFDi!?w5ONeqB?jWnL2!#mJL2gmJ|Pg;6mPln@ielvTA!=s3RLk6c`I3n|+1RrOahPCMc_0QDD{vjW)5MnW8Kmhc+@Gizx#dSrh<|u&M>Y74DH% zY9v`4Jj&`;6qvO^qlGPvFvti;OqENpN@oyfk07nqN%GVYv^6eGD1fwfZ$1ObtKvkJ zL!ANXKxJhZh}hPvQ9PsiG@P)eM~_wwJqt&k1j*`iin0Z7ino=K;9oZGWA*)+aVb2`Vil$@>4&@h2h*|Ef zz&P~vp#)tKM~iuVA-une%XOq+^|7kA%X{tS(K^=e-F8)jW~?=rJlm>%L$}7~I?Tq^ zh~bUv=i16J5Zkz6irTngoWF4eGP-dEN^M*p@KpGlZM~xWkBu9rhc|9e1pH_mZsTgi z@WvgNIn^IrDlF)v~gp!*v1WSb=`9B+@4rCY~L~vB?n#0$|xb!vuqF) zrA)XHZ57r;7&PLl2r*)6e~jeE{%~b!MV#ihGq5}{8wK~82!lpk6(L3zt3O8a*Dydw z^K3<&Ch&4)4%?*+M9D#qvNB2tbtp%<6qOu@UGa6Tn1Xd*--;Ys{Uby4!z}Td7A*d1 zu5CdK)-5dkFpIOM1&hB9Yg-V5h==2I*toB2#S}z;eJgTk4K}E^p?7$n&Tyv3%GH{% zyw;G<=-T!>m1aFTU#!)a)EA1=j=bG$mKt4C<>sUhEtwT_6xcm2pJDk3HZ|)^&ADRc z25N^<#THzPyoZO=c&b=)s{8PKsp5Tbw(d;1qFC9z8LjOs%~WuuIq%e}%zK|`-V-IC z)@^nUddF(N1pGJ^%H`5R9UcLV2Ty)O2zXK@5NC)d8Sn^hT^Qx=5OsT*cPfpV6CsE1 zK7kw-I9@&Ey-&10go@nM!jv^3`ow}p?2-=zUBL5mS@28?tPNp6U0>wkAVk7PF4lub zHOsZ^AA`a}WI%(NPJTq0%Q^;`i28#1;Cs!|4ruX_+^8I=5ZP3u? z5_&y2`0RsxcUR%m=NkmJQ()oa4+8**zwkS=Jqj6mOTHD~k|tjL z*L#sHJ{aJZ2{1gVU%s8Je{^95yvwYAuAp0g_qjiW2%!hU=tuzKs8tbEgw!WV+NV|| zQh$5AR|yC$gs z4nmEkRHvL7lU+=7q*Be;#nD*O5cy7>@A>}z{QYx%p3i+h*L7dd{dwQ_ulI95A3v!b z3C+2kZ4z7KRAcbBQbR*ji>6PDq7sVxG9>+mo}v1BTo=bwIryZoL@z9BjVLn25Hu~e z1L~8~Zjy;FX2vcE;?`xedD15WuAv(tt@dUnfr&EX{0JD?e!IA~Ws8jFZh!ck`O=YX zZMCWF)abMNX}m(C zJKiM;@6wuZXEWgr?~ZOB`mI?ntyzcsi&g_6FD`aOSQrNKPNCTHrg$RU`n>+R+q3!l zrnRG^!5i()(QZoLsuz8mgRds^1qz?rWHao2llr zRQJ{$V$U)~@_*YlcZeZh7-K&}Zcl&amw5aCg4x|imHm&x3ypoE)V=H`K91{D&yn^8ae_O#EY?CqF51TmA*eYY5~u3Nk8# z#o8cop;E6AOVIkFPIIg=Li&91yU6D&eYGrQFq|WO`TH z>^I<_LdSe>`W#Gcef~|0KjAYb0S7*}ME*r-yyCpD~-!x)= z+IClm691R{_frR8t;*bACFts@h!fs9zE*4Z8ot^c;y-k2KjF;Z>);LZ`u!m#g;&x# zudQ4@8#q4&f4(w09b|_mge-Qt;~xp6-Z|NS&Ghp0*&|@1M^Sg(rB5CyS8=jSK67aC zn+9#}$vFQz#20Ue&G*-;Q38Ot#^ctvQ4qipi21>m{O2b}&P0hb$;niyw3UaNRE1Q# zI@3=@=A8qPVR>g7(L0inA9t?&E_E#%K(Cbf*p{QKQAelKiIK>|`S98DAsSY=7U(a^ zeN|48;96nxl`!4A5_+iFg>+NcCZ4&0HULFOqg7Qm#h@O!Fizr;wK$>PN~CKD4q9Z} z<;*-L8f|4ZqPB?mb&G78NR5GS!r`V&t8^ckt4X3t(e`#m&5F!p-o*(fDt-CD*hA8r z(#O*P^9AY8fIXljaCN*GZ*78DLAb7=G9LvHkr^*99)%=_W?>8X_Ad=mkolOj6E8s9 ziL6lpW1ge_7wMJA{(%D93*!vxtz6XNHj$$WUT(B>--mN6eq9D6S(%Nh^&^0LwSy)c zZCG4UMpwr4nMD50Zt-S~Syw~wvQS)57`YV5Jj0g)qy~wtXsDSmUHOdtF_B`k?+Bxl zq$;DrbM5M1R6mkl7Msv30w93_Wpx*Yet64p_Pi;V73IEGN3a)==VXSa3iWE{BYTM~ z1TaC6g{R?UB%8USn2cvqi|xm&fLoq}KCDif19+u_rT|`Prya3-Q#YHL`eS*|tfzj( z?Yo9C;WN4)C76;~9V9;MK4`);5Z;0{NzB7CZ%NRKXit&cDuHAuH-=UP+EH87;`245 zXV*~+KlOR?jhrM7)ej}=_difv{RttZs=3gpRz-)9@+!AjDk3g?Q0QCOr5L&CkWAkY zcj<<=>R$9**tEZew`;XFaL0uZY5NAet4{sJA^o)IO8Tb~+w`;Q0oN|Wc3cU*qARu< zCIOx6zDs}$H9Pgyo{x-m^UMuTA4Ro{}uNvE|UzclM za+K8Fv-lKLcw%aW_;}~8hYIS;>CXnWgre7?>VP=F}`(w##H0+T_b)nih# zBOO99OGnlK7ZW>!BH`!r2F%;tN|p5vfz0~TTWzz$MBYaU_TriJ9Hih;(5@289-49N z_B!jx-+6zU)cE0RE5LzKo__S5(5r0mYxXCu z84CFWdYxT9NSBpm@;ne-&S%Y$9}<}=;3A`F8459Ln}LqR!e-R+tJ4zluOgsby-<7Z2cH_s#I>D#7~Pf=Z3T>x zDfJNlhFna4>R1G4RSf6_-36#Q;dFYHL)wzWF`);ZyWB$GJ3tB)^wZ}*nt7^BA-$YF zZ_AjsDQZYOEKEDY*Tv6~B;V&t_GdkpnLH-6l}~?LsBR)WEt8<@hA!}I#EyHD(dU%$ zXVcM*EOI4s@`~$u$c`wwlaI^a-=a6`{sFW>E5tt+$aOWWmlBZDgKaIqQQVuZd5|qQ z*@y3YMt75UcH)m$dRkv-M~A8q!qI?ijIrW{=IiPAb%z}@KRWe|%Q7pRH)!`dF2iCG zix!0Yz$6g;vm*5va9xW`fs1X4Wf)7bT+ynvD`$4h97VaVXl?K^A@i&S2FjlzKHF_m zHjYK|8&`-q+_0h%DV7vX=dTWv#^M4B`hXXUy^e0PPYCU08 zBY|DnIn!nfDQ+8DwP{*p`9{VzsjXg%2BYtqkwK;SOKEOw#neoKbgbx-84DpOW0(`` zwhfF`^ITHwxxWkCE}M4U%D{tH9s(XpihX}WHBr~gs1#eY%Y52#n^%X)<~lSP8r)fz z=BTsUf1QG(`rB!+-~(f_5B3RrYOp#Fh8OnUq%p0uJ-&kZ7tq zZc+?~(E+KRak%O21dk?}&sDq6S7T?Zg=f(XIQJDKwqi(Q`xA!W2%Qt1SZ;7<5sk{V zf$DojSifo61zbQ+;_Q|He_Rxm=ag7{o}uvf5&cPOYDj%N#hhNlB5?{B{LyM~q^JR< zdsGpCt=vhLv81Lmgcg{a90{2k&Kf@7dkJWScE*LpLQg>`0!0h}y#_bUq(`}HKyh?7 zZJtDnQ5!~1l*P=&LpK@1*_SPd^CUtH7J~~DgT9xbKuZ{Mq)9cT96uByNa-F_RK=c2 zBFeM)SWdz?T`?py848l7sFc7M!jF5{tQis~I+%=w6QCfyh0yoon8FNsjJer@6CX~O zCjx{ z7je1=k#fdADm@!|ynaFEhtqOK`alhl&E$~OXvuKPN3JX<0ql<=7SIA^O7}>O$b$D$ zc8V|vz&p3aOn(h{p2;FT@?bGA<^?P#Yh3ICQjQ2M08@I4V;+3g;(a@%XbLiH@-#D; zb5y2Aan^~7i`_%6W?^5MKtaaZG~xINBmxPQc#7@zp z8gh_w);?qjIbKtaQr?d6tP1k1@=93dT4WBgS1TT4QslWZr`iWI{n8q45jI zTl!JK;bbELZ#@8Jv5O2`VLE1d>^Ah)q4{)80mh9|s=WYnf0K1@^Di6xLo z#YxUYIwbP(BuDBJB+_}3Q`grbQwQGsntv50^Y5iQAo@lFSH-w&wsXT(ey+RXYL3Ergqn*^bX(8^;mRmY*eK3DA?3Wy-xLahpu5Nnu#iIR`_0eE$)O;UO9Au z#*d_v3(4d6S{x@Stk;$uU)IVTx%T0l(5$0>%UBv4;@vXd1p?#FcI(-b+~m7o#>L$W zk@OmT;w-zwBvA^_gC`!WJDVsMa?Fk@gXR=JXxl%v+8k0y#2!UM*G*myy)d84l_^dc z)09jkdu5d%vQEm)R{IT~v;XAyo0Uhv8`QIo%i->28j#=G*&4ArSF{a=DK-j-`Pew9NR8&s@0PjEm2><{90Xt9t0Pgny0{{R300000000000000000000 z0000DgvlKSU;v430X7081CUq*1_h602OwLgI&ni(89FOOTKRPo5#a3Fs>Z~+Mo_Ry zM8h`~w1EvUM@=Xp^)%fE|lMgLW6z2U`sDE=+c#M}q$ z7A2Z;V`#Qyh=!70u~Cu~fmS3qEHe{kM2iqZF@@s0&R>1ZKGr@hrVPE%JdIq?YICd&V08c;md^>aJ{bj*jM8PVCXi2tU=-ukT^Z)mL?!C{O z514KpEaFWlBBPm*Hjpf@ZTWC&y+6 zbMLf~As>(@9Si5-|(cy1`juZ(M6^Y+S(D9lEKaG}DAiDWg$osDOlo0OO8(PdZ*Z&2hn{rZgvS$Rjz8 z#>%~HE?Jtr|1T*b3BuJV1|b1`jbo*%tE*8H|2@C$|G(q-W)J+7;l^ zJ>wQ+oHITx_5T%Fc^Y-z8%Z7cLaa?6eZY&5UceGLg#a5u#zlK+&}Ys7@6PTlq1Sfo z7%+7j!ZJO=)sH2hWL2P$J?LG~1$1&$q`56fTWGu~l2GW5uA2@0}}vfcdBCzF)mJ zC!CWnIjpc}o(u5AaVZ6_Kusb(Ga2!W#~W$VIxVG$J#pnZ>#RPM9R%=hze|3sSx+?p6 z)Ah1RXMv^Tim$W+gGgv73?Mx7ud2CzQ1UzQDwT?~PN(!?Ds7lEEduBwP;>#^1kg?d z>59l}g5(BX>3vHn1s{|Xe%Q{#2W3Z~8$T%L5xgc)etx3TTOBi&DZ`pAqadfzNnf@s zTQVk-lV!@wlr>{s#@jMZ2f4~pZ9hIp4D6lC{eD`^jAcv5l7>m1A&ST%>&@f1{_%M^ z#%A-Y_ubG2B?tDRe)ewvdMn>ohuPq$v6D>OcnVVUGNW!v?fiVlW>KiKIR6|=Y~+*U zANy$QvYUQ#@`*=s)A(=2e@%bb2>q%5S+17fefVhfyN^7NpHT1c(`jRP5<~IsD|18` zV1gEVT<{`5kO*-y%(PI6DoeFmp~qTV?6Ti+E^>om9x=iv5NEqa`bp9glV>bmy)3$` zM@9Vh`$tyiXd2kC=hy?1SFKLJVWRywA;WA~2tpKT)G>o=Sim}RsGtQ30&H~PK^Soi z;{cB0B+lY8&fqd`;VvFy1aA<#?{!32h@h%k)~P-XY;dER*o@|Pe#=|emab@T*L8FE z_joUNs1N(5PK^>MMV~}wnt3|b*kYfvyuv9?bBQZlXNGy!*d#`hDieAbku7=BjqSK z>W&%5Ir$c?>;Gi`&!$iyo+cs!=Ehp2e0@Mg_8`L3KH?ifHfYL*JzfC9b5N2|!5_^P zMJ2<@;fLC-G}oL#DfC%o;kkmyUZJ^g^mAim$QsmgT7?ZQ9|_4_ns9NemUB_q(9#fL zt*z&wnH{Z-kRf+%z%|>>;%TC0%`13ZrP*EmriW-{xX9!*Qmu-)Dr%^BBqCMJOA)z+ zm9L7D&gF>O`O%)(M>Jku;U!m#t}62S@{t_L+N5-nS{==6`t-(&Cn#Sp1|z2&d9>I$ ztC*;JB&NUUtat-a1_zS)fc9f+aRT3?@$v;`q(dL2T03~ArQOhjLQT#=YV8sHu*Spt zk#e1L>}8g8wa_G}t{#*$bVVf8;VP0hQayGx=f~tgU9E+ZM!T7j_P&d`i}|E`Z|JLb z@4rg-YwJyyr#-+X)g|A^?qZm~rcDQ7VwtrO^8fXmG-1R<>7a4yMhn}{%Q2B-U#fhZ zSMa8|8)z|X(yuP6>IO8WK@%7qI>QWW7zQvb?^Wv|b+j#qtt%KusA1?eG}6i~O|tZs z_LdK11~`IDR<^Y?&)eHX#WeBri9;7YODw*E**zPcAOq}U`#RZQmNc$5Yjhcg%dZ#y zz-P=Mc>&WcDxM~&_VPjO^p%mKeX6+aH~UD2M8=4+_DK%dn8g~;6`cbeE7{CPPv5yH}Ad)Kw$s~M<6jUu~2Ai9AOIp0KkU> z0*Qf%g+gQFh)F;Y1bq?&fglKiAP9n>a`YVt!VyRev@jsPGp0EO#Yp}M7?8>Qu@-Zp zKBJE*hI+{T_>G#|Z|HMB4K(mJ3+Lcw)30sr*=(|kxou5y9Lnlqbv^&}AYV@y-0oUW zpIW{^B)bM}e0o}??a#EZ=EA?(G-83&1e~RUO99R#Fmb?DoXhhrK9EGm0h7tpzW5#d z$^qDhi!exHN@|T9!4*Dp7QE~O3J$4y@WM?e!Kq~!PJbsQ5gETGtQ4Y(F%aE|-D!ByPBDz>wT zrL1Q+`#HrAxQyR%!Mttcn1N?Z%9xh1Fk^W}cE+}h9T{~QZ5hH0Rfai3%CIwX8TE{r zR9O}H@oHjaY;J`fs+$gbkPzsUZ0v&eJodO-?R&}31RrCMw$UZ{I@POqs%}VDmy|%M zh~-+D%lIS?KgY?+gw3ibENwD5_@gVOr=Pa?JkQ08Sj_aOJ^xs~TixRKE@4ge<7{k3 zNq~L4A1kASx2-q#{t2aT6%wgOA(Aef68`9!vwVDn-i_uLecw#`a(C%1@Y?WCoS5F# z+z=|4(H2pT!+6)Mw`>!0!*1$aew(1|OqKL)~{SXkK z;I627ZYfA2>YKQs`am-8@p5+Tc`ev^ot9M7k}j(<@2)CpUBEh}osm4D|97=v%kFf_ zU$Pb-)FUFmj(;D&_wm`D(l*7M+N|UKPV@P}M&C-PJ|5ZTptoi2ScD{jWr6hJzA=QE ziYT_wO6<*|GG}51N@2>=SBT3LAe&#bE3iK>I9fy<#X{TD@>^Jo7B|2=~KZ^G@K z?(%qvW+2=lu#lwFhS2mHiyk8Y7CWC4jL239H1L0k|1oLqq^+_2fU_bfIPyXZM0f33 zBA9|Nu11aVVrs&=x<^0kApZR||DEo-+7%`x5L7#*CIrrYc`HXoNY+hl5YP;DA0oe0 zi+JtWf5JbD&)fB9aK79XF_~$2G6>CwW~U%6&65d8dqQ&;ELkyYwdak_r{Xea(s55D zA{sSszbL}YewwZ>LhEuztUmRMH$Gs&Df39W1+r|;6Cqa}&?S^b4H9$N2Qvtf%8T-e z$vVxwqLEJfOn-nXE4CT`fQG8O=_qLmO^TI3V{q__*H`^^KZWk}tnhsn*p!-VaPgmR z7&KR}<9vsy>bhO=I7fQZI(A?d9~}_?P}gdJ962=GWzg1@63R5cF(YE7TZ3UnDbd3= z8zBe{LdM*AOH@sOyDGfwbCA87t{OF9Vy{XNA=)5)GL!wUuYS^5@Kx{~{lH%?6pT%M zJ17hrzzsCbRa84TD&J$G^c&ooQ^tm6LYVBQ9>TC~RrliR=KL9aTXvE??Y|)gflyKg z3DZclYXdf1STa-*oEDYK0_1+%B)uj=KY}Lq>6M?~x1KjVLO71$txPN`+Cx?}Lf$k1 z2jhJTi3wSEG{+7tJB{hM+h*PK%Pe#V2aq zZ(w3I0?w1&Yuiz9<+s_5U^}pQRHUnMiFUH&gLra2Nig6KnVl+%#h2uPE^ctSrGm;i zrDYQcvY=w-K`YKdjwgx12F9$2bb(0mgok04D^<~vy7YVLFP!&u4%b|c$D7g)4%V_MHkR&mpons!j4O1h+_ zyK*|nkD+e{zUBG>^#O$57l*A_R;b12_1aVTa9MA|x_oMCo>BnWsP-%iQzcU4O7P0v zYd1Fb09mowk&N(bBH+5O_8mBaGaXS_6qYMXt6Ra+LQeKg&LKIG9>}TH8g%T2OK}oV zdW!G=O*3~JZz$AOM63YoP;sRQx$NI4P^pZW0OXShQ($h&kLf?e4~wz2jgtNpuh7C;HZC-(Z7pqYY7I zS-;>G%k_Mt`SjX@Q+0qhSG5t+KfF1QTC16!aR+cIZoq&bq&UqG%aAwQ6LD2Y@^PNb zMw8?H!z)&4#Nv&c5_A%(+bdiCn#uU>rU}SwTMgjSVCVL3+u$>HcbgtLpfu~H4RrR@ z&f)r<2iF4W)q<)vdP{*OE2dHO{c{&1U#MMu(KEg@Uz%Rp24NtmRYRdbBX7wD8!nay zi;5`WyuRvrcvY}@I}`MaRo(;wr&0y+)%rS4Xvf$O8n&3*>Gg`P*^(t$Ku-*}Z*h2M~_&XALbtsvP^8YPd(ngt|X z#0qOtK|K;dDj$YAlH``bti*0pn+RjdMDWC@+LhIslXnd81(rzrI&Gkg~jtW}hQv#d+GsIev9qReN6hqLge3SUaL8i*N{so; z@SWje42eZSQ4tWLiWH{5tBuvT>CMXf_dAxtkI`QSd@)8EqGwG9TW6(Su}^BHW;@ zie-1vr$^iK)CW?H8yal@^%PUKqav)YrJ_h0tPgs-lUrAi%*~Mq z5~;^uolDe;6)U;jZ#hDI{U_B$^#a%iS@z5GS9+WBX;z?AK?Jz+2UUp;&r0?~`A|40 z?=0O?j?LTq{pi5ew#!HS??!{-Z{Xuy_Y5Tmb|d2_$T}YUH;-gG4b=dGTE`lyX;VO_ zbpq1>2e#X7ch#{mCN^V`VDao;@1ke;G2$ zE!&qDAHPT;-(mwFA#_EovGb#DVv7{KWuRLE=Q|1OZT3Air&u!vhf=iQlr@loz>AxA zBY(QnF<9+*9UAtVo);L7=Q@;n7R5l$`80sIm8@1cL6V9=Dc1-sQ39?pCAsAa)4jhT{`Y(dRuqPm3o#^hF)85=NM3TKvf7D;LP zb|p?{zh^q}EuZ7D(G)tyvT6fYF)g^zBzS~nb*f@^`Qg>}%t|o<2N=s6SLBqPWjY%d za-AYz#eNBty@H4%X6hPOeuFY~Nvd$vqDm%a#PXs;#gxU<$}APX#4-~Wv+0ixRm=+s ztgyJmsX`eu)h+R@m`jul6IQFtS_$S?B-6EKSt{42CwR=WBGU{sq~7jy`QmQ#rI5}D+-}C0|KrK_I%FZBj zh&Z-+Cetf@Vod-sE3_)}`q~LvW}BSNB>vffap#A_W2z)74xtc(|GIB`Ai%<`7 z2y)6olU;vpOZuu~0GY2|k)YA*wy#`yV&(lUE1$ULmPNn8q9!@m>0IgfY^KnqOB}dw zUm3r#&Sp%-gnE)t2Q3zVsE{_AFy(*k;a@|Hu|%vr1z|Yx>|$v0sA^CTh6xBHj65VS z+O9|#If&m=i@vlp)!WuohNlD%jgJj^GxgL+Y1aRuKDRQ9qtRp6>{l4`)TXfwD-&XM zj4p{NnQU9FPWELnTRE58DlrC;@Ue+y;#602mOWr(QTOdtaJ=oHb{l_K3|1eMwOEUq zNI)>n6dXzhQ8i@dYg;(xf|6&uN;CnadY$FesR;?!*=R~83X$>5!HLR#69iFnvD0N_ zIBUMfxw^-ruQkuT4XEE*s+&`jv!x{s!1Hy?qhnfu^B1Wu!{Ho7IU1AsG*;(GE|(G| zc8_#eKFoLtxoqb;gv3T1mL+eTi9Z|$^?0XtwP1smvONwp!0MqUYp!@Q^^MP_5c zDM=iYVNUO=&DXQ!wbMQI8;iuI=jIDHCvitg={RbjB-M}taM4DPky>LL!G`s6L){BL zs4f?`$0eP9&K~+&;7LAW)VP`&HZWU07gpc~@%jFB8fXAygP2qhZ}KkIfdVb#4I5L8 zjqyV>n9N*?Wk8lyQSVCR`eIwx9dJd<7O$kP3Fs|eX9aDQ$l{i>yqp!5I;v!7s%ieumz9L2*q17h7YZ(y_jxwDdP*XdnpEMxQlcV=IobE>Uis7C z98i}5ll0++yVlmWeLfE=vdT|J=UJQX%s*;HmG6K5bdz0j`I100UH)#WQJ+^G7*;Du z5moDQbxBHzhy`N5{E$h-M{HL%B0oT{LI5e5|K=B+{mY2x%&{*SoJa=iakWjG4Zk;r zbBZ3SVV(rMXn-ar*>#=)^KT@(%{!F8qIB&uw&%CgQ)8rEvu=Xnw($Py+$t=1OWS#c zV)LHjsex~Wga$WEz=Ww#*07qMX@1uuAaK{w>RNuh{_bkW^VQ1MOib?r8J3EDb0@gf z{8}l{@zu8LNkAwURoFGAvE$1PtFatxTN8(Llu%g0?9&_gBP0{SLrAFoopx_$sCL>D z0T=_N(lJVoe*cMkVL--6C>QD$xFuF#;_6ZbkYil~qL^V6)Hg0trq5Y>9sI{L01iAe zfPT^nY4tHGh~;)PSXCj*b<}gf-j~LpBrf?P6z~luL`6=yB9~;+aBrG zDGJjH`boj}{$i*ZZu@OB%TrI6G_0{ziL#(sd3|*P!y}VYp<>4KoNi+{nrv7=&RE2R ziCnGhBSLe=*pd%)BjXqiJtYKZHUjNbOmnDVmJ52f+PJ2I7r+){Cc7L07T0}sxE2Ez}9K^A>+n;9$*fA?~v>x3Da;O*=QLYw#LL*+GGa;jFAalgI5?< z<>e-voHBYovd64c3xEZARWc>~%GLz0a#X^g4-mw8E{1J_|G%0ot);=$pTF^1t6s}Qjx6rYFp0g|srfqMBdo_|=6-~bRAMX8_%=`o(<6$w(e zF~UWJ0X{Az96B7fV_k^FnJ8pfNn$8SYr9+w6#$Jow$T+=4@etz0;j(k7p8n?ZP^%H0hE!x4I@W8WvM!aa9w0q)NBFaQRN{ zEh49&VeFI|HH8krcIA8_1T6hRC4di7Qx$+0%ilqE82dtv?cGFS+nJP6&`VT-4y|k+)O&>!GQm?PSyEY3Mu}B}GM% z(5P;V7q-!mV*FksR3o8SoG>Zn-l`#cmha^pku`ZO#e#Y_QEg=VR2N6iTeLmh-T0m2 zQez!TH~zLM6$tr(X0S%|;CrepY&W)7y1>F$cQq)NMSdPhHJv$L&Tnb+^#YFd${8j} z%gppTd-84hR(LYqX=n`^L+fEPQC*ZH^?`S)Yp}Z8Nc$+W+uV`1il-(k!IXt3)=|5c z646&NmF-FGS>qXNgZaf7$k;dqmFsj~h1*x1JMbJb2Dw2c03z#ad;&k&B=bIsr|vxD z`RrxCzxQ<(6;h$!W~xe8(-2c{kbp(v6kt^K9gB-xDJ)P86N;E7pThF~-r9Y%9^kjo zE>6o7jxP{EN7{oXpP)FSLvHWA01Q6tT-hx))C{x?rxR|9Zur3CiV3KyGJu3&IPty_4Sl9Cr_xq#DUQIf2=9SO`M zcBIYp*S##((cjbacZ$<$R(;%d)r7ZKTp*EUT7rR~EAVLi?$gWeJ?HFiXO0zs4&D(G zXy>UKBA_>94Km%iyhAUpuZ_&t1!OMdAk?|(&I`ohoyX)MyJnmW-ry-Gy1qHt+vM(v z!E^X3T+sH1z@GEP>leD{AkuNT*bj! z5}&Q*l`Rpl{Wnq;ws#Kpt|3mj=!X^=aXoJ|9n0nnQ#ad0Gr67tq2JgyR3}k@ zP+66C(8z)BS>1u{FwuN;+ckTWTV+=751ti7*c1e5dTA?P*?7zvt6}}gZKFGx6#_Si zIRyUw(+&8tr3WrZP2Pb?0mOk?;d~>)hDQ zt_wGNjg!c~q$vsI?&lmoHvyOQB_CQ@s+mYl*EPky_^?mL z8w&eN#68p<%-yil`J8mnRAN6R9lp0kJ0z_SaRrR!(sQRp1r}I)#oW=Dj z;Dj<3rm+npXJ#)`B?D^|#2_q<$tHw*@UkfYSI~RG{5AnnXm(GAIF?35wDwSGewE0# zfwBA%{G#DSf|;rj(5kOAWy|P!fUbUhQ~+P1YF5>#rRkl{VP1ns<9Hj{iO0540jV%~ zL(6G_Gu>&@$^x!{ao6#(4~tF`!l)E<`<>SGpLmA_$wYvvD+F1_DM3~+OvZQIX&_UT z;>7OyuCE_mJcU+T*BmRu6Q5)iYCPpZn{N~szs?#F5UaC_0hnft(_9niazQbKWj9fE zl=X(`bZ0e$Uv|xw$vYX>+D+(|di!T8+Mh7xWY*W9N)u#xRDwhR)W$+gW>O1;xYR5q zrtNuo$;2J0QgW0PiW-y=LiL6m6bI3;HUzkLmppZd0gD<-DjX$rY&?(B+}Bj)EZ!`} zwxURqtxZ$C8ka~wtLvfxHGgptX_S}PsR=BmBRf#_Vm2+~TN20LKgk7;S-UsqAdzLe zi?d92E@RuJ64B}B1ieCA&1Wexg-yj$ywyrDjkyx0;;_uE?Xy$e)EL|g=^3z#A@6py zW%;pAHR8Z^OAWmhCr;Jr|EZl3n&CoEGNjgRY?dEO>51(*jx7Y(6OHMh_6REozx@cO zqr24W(N^CS3SX(Bc!a6iORz)8a474Mp%))v#?lu$U7c4?h}XCzbD(s=Q?bfBB}twp zzeZn*a0fdR`BMko!Fwf|0Hkw#8AR9eX+w6!?ovPPPH$YayuDmX7_CX#o%VG)^p%HU ztA=<6?j4)kU_3C$;5Z)Jz8sN=Q?3B9l6-$fcVC0aCq%%CVd4lqkBotb_z2FIO{c`a zL436B@_?xdZvE4)8f{@^+8jyFTuu<1iK=J<{-cjEL!#gOp~J3{=nM-bNjy5qvSO{zdHU(@4 zwgB6JZ-O0QklA@)KeOk+N^k%iVh#ozX8r@#gQegEb2M-gbTRF~dFG7ZA~**wFipTE zaE-YfxD|dqunrgk!^~~LUFHs;2eg7m%>BS)(69Xj=*I8V47&RX>J{Dsdhh`n!rMS^ zzknm=?*SeD0H=U{^@r1dr$GPSe<%W~6tCSyP1rxPDdjwG2pn;dWMld)zlXH@U5Tu;8_ zTmEAnTjRw&#rF<#IahG|&fvnOPnQ0*53RcE_tgLFe4@Fd>21xl|Jt^8{?)N|bG=)= zlKzkVHN+9T`Ro2=JKnTPh3gMevtFv)MPH? zD%bws1nuANZt$7@-HpGU{qIA0v+3MleI0Wkyv zZD0Mhf_C9Y3-1;#7oA@JJ*rc&??!iHJla^&v1Ie+Hzoh?UMqDk?Ns_|$H|N~n^tbh z{uHO3l9gM_`MHyMc4fQDK9%A5msOvrnpF@ANg-Hxop6iug_>417b=C7f316>`u|fq z*Uzk-q>Zb$>Qdv6n?Bfdsr6R#m#kB5n7t4au)ElPY&@q)>muTbwySLm?MK`DwEsh9 zNtX1qQ^fc_aS)m7#(crAIoctf}?j^s?P!+pp91^)|zhXhgZ1&8oT$QA4R zebJEojQnSkdlW|bH|gi4MQTHpwNJ@vv_F-9Qy$mHT?ud#02HQrJDUkS?D%H>L% znV4(V%a&+ex7yaBbx&1Nt*EXQy(jG5bhS`W3eu=^!%-TP&4%J-wQmu8fHVcrNHf>((|^vRsH<5q_aBQsHV-CilOcYc*z<@w5gy#IYRHTRS1-+lOb z{`d2H3m<&5thP(*&&M-<7A|qn|A70Dqm9O?G^iq*ul|k?sNV z^_QJFcNj6O1+ot^%sDrPV;XOD6~*bLLsqKE8h}>F1+uTh#&$pq&hbYBJApx+wO($ z=}rI=SRpKvnc|f?#*7T1{M*y&BB+Zm4%gUH$Z?x0m>B6(#+b5pl~da0Q)d475lB5T z#29J19q0@-o-K1G;A4xS3JHp6HRR0TyXo3iu&!)NfaX_u$eZ(=o!0IKVb^?;Z?Q1` zvD9uTR(4Nqwmu0oqElrb2VCf8fC{&qd?0jJ3+5a?Q?aC^UQtVH4=xN2P^MphYyj?(|UzD z(f=HG+3)<}ZuJ=u7P!!2hy3BbTLuV2Z_p)&P*%zwt4t*m^bB?;y-l|;*dIKD^GJYt z=}2$~gxKo_BEZ6^f7LE+acon~0uj(}FRLnK`0gkQW}Y)bKk zZ%Pys4~+3LC$aO0X5_omsh#FM?YA~0GD9J%ZH5bmYyC{6qyFNrtjt$O6+fttV>l&1Loc?acsWotm= z4pE34mNSA#6s)A#?~HUQ-nGT<)6!OJcKIt0bvgDn7sC*7tWc03qG0sn2+4}P`YB|L zp^%fQU{<_yq+r+8C|cDLT}kRXjv}qCHo-gEGyGO*-B2UDrYS%gQmKj(x)NYt?=Na< zg9{s~+y=Z%C}tcvR{e@DhlWaAH}#d6Ojaj#QsLW`W)9Fn@lZk^BQ{1H$_8|=TFd|1 zIO$hciz1Muy2AFUWQ))NPhklTNaWm*ktL_3iZr%XjKr>46<2MOcGx0-xO|awn)gk= z*eRN9E&0*E5!`?xkOf+!Qj*Q^IeFcJ+UbUiqGv1j6bq2YePV@%a0Cn4Ff@@=S+(sn z6Cd;cSah%|*QX48b-Nhtrw*%DVHxR_DL+3`MfRZw{bwlH7jN!jjO=ll)CNxhZK@*e z!?Y-g8UpqF@#h&Ayi6_JMmlV%G$(Bw$Uco#Xwa828wRULex2Z$7%jsK?`V7Wt1#ol z^vB$U*;q9Jq^qO~hy0FILdl>m$|7<2ysYQz@pi2QT;zmV^}6zuiuFgIQ#oA)93H8* z>+=IK!hmd?PWRoB3T8k~SgWM8>BWv8sZlLh_6?(=(1g>(GEV^AhO;)*`71*Tx*IX8 zlax@9#8*=hfWk^3zZze|&Zx%CG-^RbUS*eD#UH46OhoN@M!(ZPHOH_w!0EGoT){`0 z_1f#rPw?`U$f$6#NaT9`EG73(*@wG7FFq zKWsT#lA_;k?XT_t0e=8bPc4~g|2Jq>n_6pGnf@JievI$bkFJ*8Q&Fl(iRix=e*Oeo z0w(nTeKff5i+49+TdVI}Mplceq8WryRvH2KiAwdqbgo4G_9jpsHg+p>*7Ky7XinQ3 z$1X6(M*09Q?_K%I2k811ab!AcUEwo%>z``hVpM;)@*SBq*%yz}xdM;Y?ti15eEom0 zro=d}5Eq0r$+B5V7cd*mIFbvsG@&*!vQAqH0T`vH#(NhC2?qzPWI+xwg}O}`)S-pE zDPdNiYdB%4%6#@FTDN8ONeTy)*k0+wiBrC1B8Vxf3^BXPqo(nU3#cQ0++2d8n%+<8 z4{9-D`imo|0jwv)LwcQEI(+{oK&S2t1DGgalA@fj9{b5~5>jvDu|y5{+qQrByy@CS z%{3Fdwi^n8e)Q~sXoF{G$nK2)!{06ko!?1=WvA)pCNKk6hfV0|P2wwPg;(>kUlC-t z4T7LbZc5$Vj-qHr49E-iVXZ-|@jL6h*FZRXSE|mheyQ#bUq29f_0^Fh=5mF^>t!)t z-3AshYJRvhmM?F6V`yh(!9sp~ObGS(5D~fZ@=~CRfIvtDu~Uy7drEJb_#^zmz_prk zIw7W&Sa*GJd@R0B&p#ik9p-ml+T6~MsX(v+S~Kt1;)ghVC<2OoEn*-Q-|gObP9?bpw_9Z_#Og;oGT5Q%U|;~nWEY)hMZ68d&A1_dWSWy zGfz2*ho#dCMY1Y08X#LHTdJ}=|IbuWi=T3fTt`~&K&>ev!s z8E|O%avo}cKAwvt*8TCziY7HB4TFiisi~}LI;q3ZQ>CCk0S&d|j95wTQH_Gs;9-oY z%IcaCzw)MDX;PBJ3k8pKv+D%Usq9=T@KY~+!MC!LRI8E#*dE@2^uY^B6Lxj?hZhiO zEZdGd7k~eTmW=l9-X87hF&D1d3=wL|@|7zSeQnh(E8l7I)e+Yc9?QA_ph8F2ncm<0 zxCZ&zp)*$L_@|yVJH1=cqfF>l|88=Ze03lS-kS8>2Tyk0N-L%RM8>r}Teh%HQ+(6t z`>!h+AS?dBg#WVGLA+6QQ&m#@Z z?wK%zNrAr%1onIZlEW2M5|^jq#`T$m!E{FlTrrknn5n92itdJA$G@BQD|U5wnq)X@0TU1NWZvnjrKfDUNRrUFOPW9U7!oX6x3wJCvei#+VndHOKvR*+swV>d2 zQWnKnS%=)kj}W+V{|mGEyJtVEs9TrWg^lRm?A~LmrD5PUJ$mL+`Za)lwdGb{5l8zCwKKDmJF;~9Ar z4Z7>vkl!jiwx9fagRAniLi&8@bJg+D`I!2E1tfa`?6EdtB_HcM8-qiW~bf^L{ z6H3YEJ)_(;4HAN<2szMxaL%u>aZfePb5-^9K%5jr$5f;I6Z-dC`PNgPBc6Iu-?!>h zXa-6^*syl>>oH;rp%TEw({5`QwZ?YWv}166Qfgk0#Elg%wlU4T{>5(YU`K}6z^P_h zp&(0(nVN5gaQL zxWo+md=E^kFZbJUMf%Fzl(;UfHnRkwb&D>&li4l5s*l85^On2%#jTBW1z76I^Sz;) zujq(7lN?U^03@*rQ);&~#|_P{N<`*BI`wHCXN^wB*I~`MpcERq4ya*RR|B?7n@` z)uSvBbXil_mM2P%1>x+djeQ|liEbIePuZ zlYSlj6L`6#M9DP~>M3d6hn8)#A0cT*NiV^Akt(5)OWz;~ENZRr{T->Y@lD(5?7C4f z02T~BZ=WbRzzaxWY1Ouv0}X93X49y4qbf>bGeCk^y|eYgNoyLhkNoj2Nvz$`Ks$2a zJ=q#JjO*dum$e8mrY%{}=`msz8F4A$S_^N_ohVp4+gzM!@9Ug`H}J$HYU$y(-UJA3 zs&1Oz%B?;%WBI@4%x`bbHTNv&w0fv;KFtTy)Vb?+3MESF4!FXlbe@e{SH6;5?Wfw$gLoXMKlkr zx2+549fp3UBH1VzGfYwDoM)ePd_@sNUVL_Lg5^a?mY=y;ZgpnCABYb|3|(@g^`q zt23!Rb}rMV58T{kh#?5;DXy^gM3yHmjnFo;AZs}^lfm!(;QCSl{b1x^+o+1GqR6qlK{x!@247h#~-Hm?qAwA3mM3z zj5pBnj9oxb4huMEO+cE%JoMs@Qbm=$J^Ivck{T!X_NC6S)OCJm#rjU*X8le~3$Iesd76lIib}G_X>r~OO5aBA$;nOFjj;Ty_qkI~zIQNvOlAznwl!g=(mdp*syYfRm zLT~Al>|tz(;b`Me{p-cX2C-FG9KV3Yj39K}jT4uz#K})S^X0%owndpBRw9bFbmnPm z_r@hAuddL51;NmbO5RSS>srrOCQ275FmeRH1--_x4%OHx1qjMkq{6kr_Y-kfZ@vq4 zei}>Nc@Icfen*F%z9^YPE9mmVObA%Ej%#SQHU=2JE^l*12xBtaLm_i zgpi{aw%?)$Y8_QpC5Ou>oGDo()V+w0KqGl!orD@wBnE8T>-9Nwke@F#jORS}a`zwa zF;#8Q8W%GzkeB$;yLj#APM|uJB5v89yBH1uOkoTshqT9zvvb_~z7|tGHb0wX>eH^uX|}m{}lLJELsMTl?SvW$I4w zE|Z`0AMU@k8IJw1fYM;PGtN zJKyKUZ2Xbce`eHD0`F7RNcYT~62RTq_WY52%eXW2uR~kkt{zFh8xJWziUOYWjl$1B z)gpD~vT~|>t2WYxhA{+X{pOH@HXgrNPD%ep`0WpsZ|JXxU3lVHs_nK22mncxL=pJ| zaZk7o`iHc{WKA)sTNtD*aometiYhBQ0yxbBRudd-9kGT0Qu9xvBM_6k7q+5m zk~<47rw&nd1*2;1sK-IKUUS$!w6aBj3A`8eG?C@1HL>&jR`y=KJ`uAcc029#_Szl) zc6g`sh8QTFB28bPcgmq)xxN8|p(x6t9-QX%Mj93_B=MKe;Y>M0V)XM$y*Rt`!c1%8 zzWuyD1=L_uZo;!?Ok)St_0jr72JJUrgv+Pgb$moal9p+nl(j-6DYQsLJTRk;fEHAHbte>- zmxw+k0ybXml;Q4%SKfFQxqjUgD{FE-op^kb{KPyao#N)ZzKd#{H5%IjXblYynk3w- z)}j-6;ai+-v3tcDA3y-3u;)qV$LPqK0N$2)&WdfQx-Zf23Z3|=Dt@{hW%#r zp^?F`MC`f9RA`wFgoq5r!~{Ehj)hm_2M>QQ}CCcZP1N;cM2}uvw&GsWfj_i)YTC1%bH*VVlDV((WqbR zJgLBpWjKyqj^wT~k(*W~%NVZ@YR3%4M@4Ic_N_;~t=*$0(_SYI-3e(|(sBw45SI9c z6a(R77YtseX)ntm`c+|E@=G?*BiX(TyF0x2DypM5s|8i8)vEq=PLT{p^R?7y?E(30 zqQkuM`?6hD6|aC}?Ps{l{FUs;%7xro+H94>BrgWaW$xP(EfOQMRp$n9Ew6F#f@IeU zG_|P_w-2Nw6I*&i<@e?0OOy6VV}qJdU1~$@3q@{^TBI8+%j3Mj2mtlzsz{`w)Q)^$ zCVAUIi%%TfIX;O<8pX=Dj*%nU5MPX-l`7S@`epa)d@%J->qN?`h>VFH(_*2{onWS zp3;1R7s{QoSOQEUuc$0q{Xy8uV(Oxt?6-vUW(#)+`#EPV zc_xh;6X8#Uw$@U-K7a^b!Q$U7@aWivVCMNN; zw$;{qikA|5b?jR}ikElFu(ww=Fm!k9u=_+hflGN+R>7IX?hYdf2a?aSmCBnU7BA=5 zINrd4R4d_%*s#mM-l2stO_mL%DVTGLL*ZJ)*lHt5Xw&n$b}N|(w~bApf+V^u$Y4l8 z*MaM;!M$X0tFKsxxZc~wIsH}mpcoE9l3w<87$=OU^k}ZnNu)T!^ClJ-#15rY83b#q;6H$ zx?861YH=KjsY+rJNfGZqUhU+z`@6z1cYJeSZ^$M?!3Zw#hDUhzK2#9}~ks>Pf5^xYrS}dY}(V6aNJum5RY^H9G%> zxbCW8skq%yTGu_Gk$}D!26(d#!bwY-x~6^MwJL=?^m^F9j;LC4U_>Qv>uB?J?*gBO zsa80QjX-Jvbz;6PI6$iV+};LUn`0H{&{EM>kXiG={P28nYC8fj>JyTRX}RcRYQc(JR@3r*Ob?rv{4J>&8q^3 z{ZdVl!rBEKT>hNjf3^%bs;1OnK=#bC4Thf{fJx4R@L6N?fRW5dZ|W(Y5#)PD*uSgJ z=!#)lmfmU50nd|($4nx>93{A@Uj1*;+y8o8CMSia>F}B^YT0yT@50cZtCXtC%IV%- z&#qINIg^h&Y8W;p*Kgo)*rR*Y5J-Xe0L0b#JmfN0m(HC0J=*fTW6K%0LR6|$m}8r% znHkm||2)f`Jh;Gr+@pIc;PQ=<4=Xw~C`y!TX}NRpXy-`3hpAZ;dK2bFtS0`^sIxseynn+}SZHB# z-s?Dwi#9Zhgk(i^%2!^n*O?NwP%CULxL!%LXx=uyYS@g&RzAs4%K5Rxfm2dgT+x6l zFZN;huJN3;J8U+>=dZ_=+D}dH&{LGxV_n(t4bCT?Y#5H!PMgDaXVB|RwgXi$2$i>Q zte1B

r|u+A>HRp4ntzzUtRp&RW104QNhJ=%*h|NLpRP>RY!;y6yS0hbfkYUlteU z*4kAqQ*H=AViXCo9Im5-hoz(TW?~7Hgt` zX|hA??hLAJCu6FXZ1auMhD(-TnO((;Yrv(Nrw7j*`J$uLnfE>@_VN|NiLHS;h=+#h zheM}jjg_{RW+OXl&l-@iu%y4YBQD1q31OfF8u5gq4X+G&8;HC>1UQDeNfog|wWP$A zpEFk~S3BdFQy*(TN~L2TcY5)N(>Tz3gzW6FV%EksEDm$(5YsQxy8nOl^&1zRo%gs^<65hp>f_Si(mQh@-n4GDM`fJWe zJL3AeTjagJ_^t9#!5<$lTCBHW8aC|6BBFS^Uu z%8_(UY~Cv9HIQ#IOM{jstD+=w@lDYoKt-4P@TpL#PLm2E+{l;C%R~>PANR2{{d1OJ z;jV1H=Ynan^Qy~M`ck*c*cx>%YfBjsiB9xDXl=N*z0mArHYhVe!YScC69H(O);wr- zR%-U81c@z%OG8Ug7G#iI8Bb*Y3uIT%+n)(gvHSnDF|m*DiXHs#Irf~wf73$>Qp9_& zGigN5e#2A>{gatGB5})U0Liggci^L@MGf$n&oB)n-`hWaCt@<4txA#MnurPM_b^u= zEz(A2ul|Aw(q##~ecaZ;o@+pjWk3yHZRnL*UNHm=Yalh_s}wB`jQveZ{UlwGHfv#KxAS-1T&TXF z0WTtIg;7F*#PfuQ5kizWbet1kib&AMzkGE7Biu3a8+P>fyS{P{DIeT7C9T)iOY5DP z1OTc^DRPEYS?epk`AG46lCOIT7`hpg1ysK#I{qRGBT7|lOKo6Dx8!>}&oj)~n&2Tg zRMO!5)a#CRF|;-GhtdXRnd_{}LnQ(-utM6}8Um+mV+&Cb^lGX8R?HE7sE+^`TPHEBf_L(?nqyzvAWV{VF4zd9%bLJb zTx01Lc2om2swq(x5G;^@`+pYAjL8a2h;3n0op{fi3k6kq}t5~tGXKYvHmr`sc=^;K*#v%)FO&!N(Vj8MbIh-F0Sjv-| zKeWC_4b z9%423I_xqj#TfM>)&#HU!V_c36iL^4nIZPqZYcn(SgP(cwP1L^X>GQ~UKLB=>)=1tBpkM9*;WvOp=r*{fT!3Jl_k<-nc}cj4n`W_$6- zM&W>aM1gYO(FfUapa4)IGGke9eDhOnUEPWiw}ra79Yq%O=k`?Xf_|ge{V^uU-=y&a!3k8!#mj2T0T!JoNF{dZlY2dHC zSsg$-Pc#=z!(YEhCOcela6N2QK@jUj7q8!7U@R8tD`>NqEbNuQAGR{}$#pt%+y%RF zT?c45*vUdxEV$7+AS_5Jwe{?bb%jF^%RTtK1?RB7I}(GM!FdfQat+5oe;mP1}}gkPzR%uU(bVJ>8nW>!rO#UN2gS zQs~^>C#c_jb~r>-7yTy!EYLdQ`93h{gdSvhPc|(~*pb?ZHU`Hw0}t#)6LAU-=lxZk%Y*6?8U{I>iY`iU9tP zC;O^Vrt&^11xxS~B-QB8%4T3eF!k0=4D<3-HMrH2`zGLXiow zX4Gvk`9R~Mg~Wl-@xL?)dX2FO_Ap^qp&?|g1bP7#%RA^Un7b|QvSWBF6k!ACC8<>$ z3aMSS!luJhjA_<=Xj_6qx`*U-iMS*&?KB#ILld-}ur-kWSe8KY-qC5V1*cz54x0$5 z7^|p~nu4jJW~e3?*H89bMTH>-Uv8pOjJxqNeo5l%br=GikJaU;uX8x;&T-dx z2U39CwS&2iK@1QFmYS#wYHeZc)bY6>sIPy&_Wc|}t1t5%SQT~lGi97&R~@U)K=Jc&WLWVo1G7KnyzNK9pcsAXkFb0t!=l1 zfRzz<0{co2=soXjG$YlnUpy#7u}qM~?-TkynvPr~a105rk-ZzNO5Snv49`t|jn6u) z>ZGfTj*~MGh2`9Yf`WY-?_3^Y!E^j9_DfS%zUKHGWyDxq3@*o3 z%6e(202Z9U-{WNS3E_-qJsBT?JKt?t)t%{KP<-Is^7Va^&fM0L0zZ>D=RG>^ODMM{ zlgnep-A=*b^oK7)@7Ap#MK#+S%*Xt-jco>PsCF;Ke|{eAOJl!Ugxv(kW5T2E*w5?_ zB4RX(^J53UQCv8GzTR5ceeWg^Qgtze`9!#BqZNx)_c|MdvKx+_3F4dS{Cnr4ORu^? z{04&o&>xF-vY!DsO!e&vJX9aa^1EoG-nLYS0JQxiC^4+OBAM(in=wCHyO@OBWjO@x zy#Y_fwZpj>o(2ic)zwhCC4`gPZz3q5>sNjgIYsNlWWekb=C0-UKyz3AjXI1(JQ)qI z{a!XvvEUg=<|XFj_7O?4jE%sBntow!>xX_=_+mNn9*Wi)&wHIASv+vqG$@?>Zcc-M zYI&4)IvJqa0mgUkWbE>AU@>ZDe4v^8`?tT{us%9}>_d~sL95V-whRp!0JQMRbq9mH z?=|Le^7-?sTH5UI0uE4uuPexbfZcZ0;$XZ+Xm&$-OM>5IV11MkycTH2~pRO0rkPC z!Q{xn1sCZDeNoD;KvZ9uzXAovYkM@S&hQ>g1EAn&p-gg0kSVS+pfu|Qf}|uO0_fI~ zB!x0p?mpoemj69Feu@LDn~MGyXleM-vht<0;Kpb$wYAulj`P$_;zA=C1L4p9OfSdUq9Ol+y z^tZ^o|4~kWjDs-i)x;}+fn4sA#JF=GkM_PILVF;;=I3b|q3KIEk_0^MRhy@Lo6RdhBwGesE`6*CQf@{EP_foQbt;|vBl>CI6T8jr7RSypIgl`)*|beT=knp9)L=($K|9HlkHNE)G5?J zVaVt~3DiJqBQ(Tsh8U98aq$sKGp{FT+f=J5FSs$QfTY&614ftowD^6m2MFSMtqR%m{3(%lS)PRDHaBv<~RCIQ7XiE3IDF4(#4nXsx zJC)_3Xhl7}pR9}B$J)d@j%Qll>c`F_MrJX~7gD zv_15_4~7duv&X(4*g~?i&$iB-=1k~Cs$$HUPPEkSwFt-*4|Mbz!lW;6dDa(BRS~nL zzaXQF!fO(S+_VWSV4miLEV2j+cZqA(27aNQy!P%p){}82N>|E7#(IgnKuBQB zz60DGT-@C!F)JVz_FQ|cvc6P)8hX}2F>~iNTk~x&#LmX%sO`Uhi#0nH8oxO|LVXX@ z=+4IlTwYrK=I#S4fTllNZ!(wWeh-7@6C(LxcrBhm^s9wZY33szQQUzd$&)F!^WQ>y zet8b@T2u8WDDkOo8x>*n^sjz1wu`CXmqK84MSW(!B}%r&1X?P2h|StE?Cn%|3+3fv z3F;FvWs?nV;ERpRS4M^dYyh(d%VwTqS~st+f?5Kv2FFLftRDoP_LO2dwl2UvY3v=` zeXYK{B%o0rjmp7wWrY4;5yaQp`9uw2cT+1A|er_mE^r-$M3 zDX9s=R@w=G-LB=6W$;+fOCr=G>Bu*ZL9^x^RY80H_}M|X8>5SAQ$1}mWB zNtmh3LODxWJ--dP{?`}nNWvoyFT)5Q7Ro3EA!E$Z2(&f!TIhR{&=DCdrPS>SV~-`y z1_as>QL>So0@_2BK<)Xm_!w5tZTP#_ohjtG*W-u%;;hW5p_jRZOjMGH70R9p?Z+Ra z?9eIIb1oVf*ijsq0BX_iXh?>!YJGQA2iZV&kcAC^ZOMi(%Uu$K>KBL$LE@+r%jTze z1-CHM$egNnBq8_MzPAnav?5!GGYv(Dj=o`3yC5CCyTV>C+~m8-=V$?vRL2qu)@J1w zY8TQJ`65Ylo#{_G@d|6O#??G@9n`Tu0Va84QbJTjmhMJ#bxCH3nVrECmrwfQ+IxMl zwjlqXwFDh5y4}Zr57LDPbf?`LeK-q*Q8(Q{f-;COoCa-%9*$>Zs0qbp0^{IFE20`+ z-s?YF{AFOfc9Y=#gX56zZyU$jWMa%W;?B^dak=rAy$B`zc~dxIv}6aEg6NgtlGMd3 z7h*8B?Rpp8Mg%>HOt_{z$i*-)Qw0xmH6*g`wvOb<>xmd_nHLr}dLZJr2J^`MxlSYw zU1sH&v>mZ)11H8^35;sDvJpI6{LTd1ge&BitySp$(`w$6&o7U_Uo@*&7V1rR+w-jZ z_pWm(&oKnYX97G6m3lmV@W-(&hyc53JKZB-3nezAmG#PRA0y6#4}uv9Z+mcCLjTH? z%5`=TfnAAM8vo-qq!i`Ac6@E#9)VNGAFNE^?6BCr*_U@`zq2v`g6$@OuKsTV{t}w_}8?wC6M56GW_ROm(q77E9px5|dxOjm8a9 zcVLxs?=x!i;jg6}27S?hhBi&HNgtaCbhtm3oY=5-6!CQ$@%>HQWJy;{7EPvsiNuNN7+i%7AMPgH8_Q zz~abn{qym4CWq2oZ^$L(N$?y{QA+ z-Cd{fG$))ih}5f4+bY+k)JE zZ+zP!$=%{3SQzo#7(5zxk3}4}$bJ7BsGPh>n&m)a=3=UA$3z0kEt$@Js2Q4zzhM_{ zC~5`m`TEfE-PUPb@UJnMA#e05=HSbR5{yLxvxxTH+i$}w?mlpX=epf@J2Ziu$1<*7 z8}EbxgLSdcdSeq4=zS9$$V4eS^8?qwj-|_??BBPo>o>JOZ9}H%Eol?3oYVYWgwW$h zPaeOv2Pz{7A#D~KS~cVx9FM{fWrLG}Nr5mH5rX(4c+&kC7wwf)&XWtl1Q-yqNx_Wx zy||q#`q^bn07g6;Ya-kiOI0TQCPw87e z8g88X?Xz3|`IT+E!Mca<KvXy8HR%&tE|-#vb|s6l!BWD9%^T-wAlKKR_)QQ;5! zM7I|=T;SXFHaCW!d{>CUT4HnpLW!>p=V?LA&s8-bSy)(09@f)$QboTA3J(k1Z7=5# zMnRxoshR|t!=f74X@9jaR=}n#_*~m=q@C=ki2k2;WvI11W6r_;W&Repbj9!~;2p{LLqNJfsn;8%-sTJe%?X$j8wtbyw5f;u+p^KyT0ZXVfgEi5 z*BA?tv}IC(!xEc{(1o{qAhJ3`RJ!^?u3^NA+` z*Z#RrO#0{drqE;V2o0!YHQhmaDviBfuQQV!oZ@zHUoy%jd~0KD_`#@l*f#Vw$iqP1 zwln&sTeqrPAkA5;O(YS`4Rwe%=xucGD*IOEpE$DZI?*EZ2EYS5VNgYNfQ&)|g{o%C8QOxmCylhqAsq2CVo3oR(5@KqX6KTYOrAr@soXor4AsIUaJT*6V?hP4* ztzR5cR0O+I01qQ1GCi$?Lj0r4$rZWi|cRm&Dqs z)zg2JPP$_UakH-kjtqcUmsqdP6jUdYo-Rb`vkMoibO`o>Xt0^0-H;h5st!xRzOLo@ z$OkB=_57BO6@f)qb}AZMF(iRnj*+S{9`wMuY%gYVCmY*`7O-9mo*+>9nB34h&7l5x_^^>Bm3_i;4z)?aJ~|ejzOBl_dw1+S?IXHTYgw=!7HCN zW?V>6;t*3DsKZrCNceR|1EGGFAYhiolt<6xj8|(O!NXipCj|9n+LQ>aom4@Ge7Deq z0bH{^CuKTD!|$YnmH{MFCmPg~eAPlILDi5B@}ueJc&*W6-BHP}=v(y$0YWwmIdMg& zS}@$$G-^H?VhpR9g%YY0%X91=IL+Lp3PO`rxrdVu!br@QYE5{43T>smQaMwwqJcl8 z7hA;s>eunIKB=o6_r-8eX`3T+pW7U8U2#kky_aYSv5F1fc*5HCo9@(xnMoL1Oq3Sy z-Z@mf;okV@uGhgWwUu|mKrULQ*g1?8V9HjYS!Aqi;9|K)4ceOpdB&Z(T-@F)G7WbI zuzt<6(1=BX5BxBsjZ-o(l=H)d0b^>0`2guYZ@aUvYQ!@W^#?Z@`oM*LvqXFPr6CPE z##WAy?6rKNX|4jnHnN1>7-^xo6Q#eE1nq)^&NwziZtc4A)=EBH6??t)8;0#d8><0! z%YyHshZ>%rk!=L~Yvn~0&Qx_!R`KRYdrAwsKfQgT*0uT{zUqhf>AZu0^gul}#Mtes z5i);!f0qAJMf7#l3~T3JHCQUcZ(cBlaV;GhY>Zg{YT`$IuvidA{$`)oK5--=ttyN&X4n1;+sv=O$WQ#naBJ! zJncQ0)v4izuMv6OzQtU0N&vE6vEaL#pqt7hfjVbU0ql&ubQ zD6iBpbjJq!=GH$)J$7_+;+p#Zi#j$`{6bHKxTOm={-=PSwkSWI_}c6I;$;4uY7`uL zjPKW3g&)77rdzLMD)@FBs> zr@)Toh6Yq}bONo|WZ%gB8x%}XAuj?8*dzlN9%%gU>>8^^Uc^%Oy$*s;2bZ=jdrG8r zLf?s;IEv!${-KaH(HK3^M#;22VL%A6Hb3#5zWqklxOqyr_<%|OkGKvqDjkx|yLQ8I zY^P! zV7ce4SrTpVX*MrajERW(yle^-X1FNue4=M+f0zgM^ZUpE#e@Dx6uHxKqR)@^+SDZ8 zHvX-_bz?OWXCI_v0D{~}yFWpt!@pC~Jirv_pn;}DN}YWmUa3#B00)?=Xp#2Z!Nj?u z!FN9e8^E=}1A60Y4ikHmD_yPjnMdU=bn|pLM3u9<3M(kXKqqcGUm-Pj^z^tMNUDAr zpTKdYtx~p)ZH6JRV4Cgjf*Kv&^Dj#*v){k8Z8yCf__LPfxiWU zq|`Noslkj9q$!)N$|I|@#NTG5Va@up)jKQuEL*rt-4TtKiYZW3Hj)<|=fX|h8eC*v((eufP~V0hZcey%z4}4V z&^ucGpk;69D{jRsijqX7Ssa4AJh^J|2J7aAq=5H(4AKM;Zmfe&$KRRl+dvY3rP_mc z@3))@1(2Ym5mre+$64AEo7V^p1iA^V39}3#HT39!`dZP)`VE&enu7EKZt!@$8J9qt zazEek|FgT=Y1=VdL9aFJnLrYp=+H><6%`!Rc2Ltu2kEDH$N3hcr*gvClK@=@mQK$X z|D+9ao14guf<62kJkhH(MK;>%T6M@Frxde}4WtC8bO^FjC1^^n>7EM6_2+uHUDAmut`4Tezl;}|Ugfpkngf+jOIm1j<#|M4X3#ZXg53hM6Iaq2W}Oija0sT_GTw* zh|N(++9N*}08639JEuqDpv6jOzOM1h%EutZA zkb4^8u%Z=cAj%L=M{opCjPHvGEkXw5$MP?raVYQm$LpFL0P58aBl6TcTd7lu7X)Bp zXxoZeL*KfK;e?4POSI0>b42b#zD5a0uSswW=vv!B?6bd0RUM!o?4?(^4&ue`&wbTM zV8<;7bqt)Ud!uCVTD;#dTfl;$nR71-NO0uIBl~fl*D;y(u6l3|c6r(_T+W9@RWLyW zxr`>Jo^>w3dZc_KC=|^fPEV+rd>x9wgiI+@ZjbDj;?Rh#xy;9A7~>oJ%z3?afG}_z-fF`8nRfw35H$U<{V^oASwq%$^Gw>kU z*sMkKZ%hVHkH;^6njYgBUXdaFFfCk11Yjshs-gan8y9&_a3XE945YO+E`aiuvri{H zIy-d>;>92S==Wu*VbKi{Xio`G&bYRk;>0fm( z28!PN8Jh&flAvsSN8#V*%~j^Cga?M1%2IcVVt0%@X$u*eek0>FKKN>OsEYgsEQzlY zqwWUPLlqoId7o5_z0AFY_1hL3HJdnhKtEX~G213`H`s&huRsyxW__wx2%?}Ui4=fu z0gTJCWPED_?BLS_OZ~1t=-*q@&Yy!g^2y;2>CVsVe><_H36|O&X5Qj}wCy##sU&v0 zlR}f^@%H+La7l_R$$o{vr!g~iUFfao-x-}jVWB)s=M5zS(bYu^18K_L`a=*I&W1#$ zxaRlHL5vUgQ9Ehk!*Uf7oas)qJ zC-*g7R7i!m=mE$IScG1kxa6V2Mz6|k038(3GUFJ4MLQIN0t(0QWybth_qvH18+QXx zm>nMKh=o^oHmEX1G`nN#= ze+dGbN{$`*YhsEcxj|DZy^xvpMP}fGi2Hn;M!s#AX-9OcC3J*{cKn7PHPn=g79+*X z)o}t?5z7$uCr0P;;sJU7b6f~g?| z!}0nD!8BjC%AoYpEt$UeVlG;EV=@HjwFM7m9%1;s1r-SLi2&(zS1}Z%w6$s=>u%~B zstDG0#a0cpzUHhw+s`s_eqVe|+ zzaij)axZ}5Lz3u1j7on`QESZB%WdYR^_gl(uSxw!pR6UG(H16Ov_z z4wfYE2yN&KY1{p{zeNYw)a4R=Q%;BtUkst#*9!LzM}am7hnC$7k}rEWd=g)dztTf> z>NpLpf9W^NUx-?kzN*a<5RQ6 zBkmo)&Eu2v&X-}b;q#DzHD5<0vW6Ls-hve+iU({3Aoal z%zy5jj?u4<9)^Cc&+)I5@dS^6?!8NPte@TjaBve-);T(*riIXBWTEa=p-(o5nX^@8 zLo?WNw}mMX68%PzCDX3xLL;Nheew20@Xj}5sOD77 zMP{-Y_{~vcCb#f~uDHo!9l4t%MTdnJB z|J?z$sEwwxLLB&{v-;kd;}qSH?DZc$`{|4t`1%B#9%(ms_ynqH-EfeeiBoUX?a#PF zQxJ%H*@UlUg?E2_STk&!S`UI$aI=Mu0hJ;RY4qHz zLnRxE$*u)_8y~@8AhL?Wp~qN6;5gvP?I_(7nUvqMQC!0Y z7e2?9MhIylo~&1RX4Vz&FRf%yM|lk*!ZFe(2%`LzNq$n>);0kc*;5TjWY*}lQsYY` z48uP6gt=4Lbec_o>DOgfO5TJNIHSr{Yqhyb{1Ws4uA8VxLig!eF+h020Rbz-31s(( z$qHfs$4HWhM#EuT6=d?ndf~>ECTBqr0Ifko4PJkk3Ei*8XfPrA5y9CTJHbS@77fEN z5eF&ODO=@aB^6>HgsQJ{ZFudgv?qWRL)SE-J2N1sO|BD4W(tA@WN^UGYplWUo$fW| zrqTpF9M8F4-eujE!>fX<7Y*C-Dz&0utWE@jJeyFULwQsfz`2x{HijKH8mR`Lp<)dx zrpTM9EmMDb>S|WO*ZiNT)q=uWWwR1yV=?qWaPWSu4KI9>@pzD|yH0$^iZ-01oGcV1 zt0_$2G0j%tjFV0R8qoEIU2(mNoA*Q)w-rs2%%bUdRgNnO#>zw_r0^*VI_3NME({fg zQe>wb%Sc363uZZJ#43QghE*u*l3<|nrBE;AdZf0s4#L%fc`y&-INto!a%@uRNDvsA zUs#Rj;VQs))FL>td~RPuPf^E%!H1ZzI1orltncH6oZEutW>ebam~gvihR~*tw!lWa zovjW|6Dztp=pN^SQsOE6h%x_x>}?IUm9H9=GOpFGmf`~34J5IyNXW}^OoDnx5J;R7 z0UgqkASb3X+l6Ea(SXf?NJB%xAg=JTjgiLiKk}#}RcF;{(w%@a#=|uT%mE=VLurz2 z-=7w9{S?d+bcl?m{`ONU>DlD{nXNl>&HzFurkz6>7_mj+eAue7Uz`_du;>21&=Rg z-RoIMsjDSFo?A7UeSChhtVCK<4Khr>{uIH61aavCwB7JVR;(CwGLG1Y=sG?8MAiJQ zhvfCwcrOD@GTAu;f-xmFMxSujr{#uxM;EOsNFoFNSI9VDc=$GHtom)v51TN&EnP#P zXc^RMuN%a0&r1~E(KA$PLBL-i5z=J8>dY@i}D zRRfvPE&Bi}rtbSpKGt1V5uch^22(XQ$benRxTjrNFy$1(ooKYfZfZdtA5*%D!th{! z!3AMWrZP(bC&%P+1;^V+6BpiODGJx%f#R#RmttSdoGRNcMZv)cS420QIq_Kd0xUwI z(17_Mvi~-gO}N-$6=O$-)X6nvmnKbh>!oyMxH+W zO5(mEjDvJvD-1fL)=VJ=9(5adJ8nz6F8hwfxeGSoEvPruEx~R#aOSd`lI%c*@=}9D z3pQIfR{sM!b>iV@Tm`WBZ&ZH?sp&%2{%Ig=X4F zt1iYUPr@Ru7Odv4SQq4Q0L@3ol^im=+l^qjHkc{Koe)&{YO5&s^|^6VKh(o~J*K;) z&j$rNGiu=M<){c)0wOC`YG~dHWn?Mw8SkY%?L0pKUu!bV`7Bg#n`g7?3ZXJ64~Hok z$X4P-#FrCAm0XE(`n+jtWT&=oZ3AAf2Uzc~--@?eZ!U(ycV>La{YeuA@Tjgq$80;* zmes%B5lldXaapPw!Z&*E-L|mlAYEn)B5OA}HZT=ga_oG3XHAX6UH$M%bDRtUSk?v)jY>P%AfO@2=!3Fau z(1Y+j_)%gXr0UU*-nuc+x_WHt_YQ}XtKf2U6Lxvao?MO2UEU$TW6@l>MAwX~6oZi- z1Q=$)u|%u@gHo*==;)^Iy<89MaABgMzEH=uTW?;7?hU%s&A9ii47SXema1a2o4Kg> zd<8%sdsk14&OUp%Ik$Nm&-wsKK(@d9-EP$BHQYb)z+R+Y_$dyG^aXN|=}pE$K5t!9 z%vVnLkGQu{{acAZnFSo;fw>%SGZc$8-+04hwIAOq6V@yS6t*%T)QzZXhLWh*S*TuP z`nTFO|4J$R71y#Ot8-+M5zBBZdK)GWn_Lf0q{{kWo9D7Ji;|*a+84SOe6Ry5bMOrZ$UO1ltd~Ir0YTf--!9X3mU; z$=?K}euDVa+~J=5vZNAcfH$T?>@YEjSR2f#vXX3oJK9pnsj;2lAltZzx`1pgZ z&Xtl5;DK@28X7~M_7*u(uKpKqmK3KWKf%WTCdSgXE7oMW#+O%Lp3u-KZ~9ivhjz}u zDnj(rzZAgGPG0vz%v_J}stIyM^NJ0{=`3h`x_1l_UWFyGqViAnb?ua0>Y~VxFAj|= zM#ev~hdR7 zA_GX0c>D~-OTfiqt&&M%Q_<{o!G)=UPgV3UH}j{aNP_>gwDxhxrOruE^aDp=$EyZWIBv}Wi{i=>4FC_0j5O_L}`(0l`OJ zf7Za(_f4LJdub0OFvU^{fNSngd^4s_D&smj3SkN4@OWrMXQTlLZt`2psdZFEnoOWT zt?+9rXo1>e(XS|YFujbnTwNO<(u5bbq`58C4eDJ>O@{ieB7*G+blVdrAE7U=&eZr! z&m(xlufVy!RF2{2pCtX$D1kVEM)}jXmzF=Bp3`^z-F_~3z}j72grt_rfMH=SZE50B zcHI_Om z1h8ZfEyt~XuXHL|JY;~P1iAp_#^dojTFufLNTS;m;st(OBMim z?CIJ#nt`y7xk4%3RLvE6-*mVfM62{0AnhIZ4pQSxsn(8r+X_}sBOHHV#= z4G^tjBie;vq`$KlE~RS6+Gh{!lm44rE=idVvgP!fpbEKkMX$&p2A&fYrCMGo=kn{r zC2?VkyEjxR&015l4O=&X1cYKi=^_94Tg5SKX0Ym@O=sd<664=|#5qrZpGc$?R3ED} zurhdcE>l$~CTwYJvbGCz)Q-qqE^G6)9D-Bc$@XHu-R!JsQ9RM|R4Tv^i z;@dqbZ&z1TuFIrzZRZ*fR|4CetRwSLog<12%kd$)Pz<~0)r}U>nGHDBA`g0WqqhHu zpf|s7#7Iup*3i&S0~&m3<;F=+sLu5GQTlbOfR-^qc^LZ2PK!COxw}DaQRDstcgJ=v z^0x25y;b8ix+9K1OhOM4gG|d_eKH82r9&OR`jc!sQTG?|$

o#<~uUPluf;eW3^a zA`lI2%dzmt|M!w8=%AE2YgE1L*2es1nB2Z&ORHK zc3rXKqs?#~7gmd0(76%8Y@UNKH4IJAaq6Gs95jC-i1rMyK>=&t(&tzg*!EL>WmPD+ zPa3Z{5B)EtfBxAq+NbZfdH>42dff3t861lYz?Q*l%G=?!Nq35sneiur)^16Y;i~hX z-;tZk#p8YQW99p`eS6#y-w^e>{P0sqX@ZJ&tt zAZB4~$+rD+Fd3WQJ&W$_o%?!G0M~eC!!Q=`zWd-4rq_rOV*fs2KdtC#&Pj$|b&nuZk$qeo*9pV>3g7^m39_~X#5p%e z<~z9`sMGG`BCy-V#_nQIZ?jS9Nsk0AWzwML8@Uj07=`7r07qzk!9$g$aZy+ViG+X! zR*Z6lYXJL=Dvw5pG~i2>^`3pPoypNB2Xsc3P4Zq^h-d1?*V7~a zxsmA+AsjxLX)6)0$Yjj;TP@ zf?;*opij&*_drN_z1&OrRgW?1yzpYnWU-3oErVwJ!+bAy3zqTOuGNgXonb$@-jqQM zX!^UP&?7I}cCel}$V;(b8Q$hh*9PLgN7Xh=_NZovF6=iCZ#@u(;`G29Ut6V^h+U)9 z_6`Qa6YU$x@yU?>My-$c+n!)xyz&BN2w1z($>DCk1&a(PZMkO4?=^;PE6E<{*?fG( zM(rrt(c+#xbJAL=PYcy@40cmFJv=>|hV8I}`$xDzDh&b0OVxbuzzZ7VqMVeUSc6-W zT_gz&Ua$`-=wlc*$2-GvgybgDQk-$o)ri+KzPozSB+y{M#h|H00-w~9xRAVPv61-m z(GYZ(66ZUho4sX-`S_Xov>HxhsB`~I@>x*BbrBebyWY!=Qv~^q` z>XUi)!s>CcT(L;r54uyXY&^P zQ`5fjn*Y0Fy!$|r?54&}4R_Wy2H*1x9JhAx*^%9m_R_Nt*`mJ=1uS8om-Dmb31

  • _{zDz&p>3TDQc)6hETbC-{f_tzLHj$*#VYkB| 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 literal 0 HcmV?d00001 diff --git a/vercel.json b/vercel.json index b8edf9f3b..dc077e41b 100644 --- a/vercel.json +++ b/vercel.json @@ -28,18 +28,6 @@ "source": "/webex/:match*", "destination": "https://for-webex.excalidraw.com" }, - { - "source": "/Virgil.woff2", - "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Virgil.woff2" - }, - { - "source": "/Cascadia.woff2", - "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Cascadia.woff2" - }, - { - "source": "/Assistant-Regular.woff2", - "destination": "https://excalidraw.nyc3.cdn.digitaloceanspaces.com/fonts/Assistant-Regular.woff2" - }, { "source": "/:path*", "has": [ From 0fa5f5de4cbafcf9405b5983b3e2419f6f6c4d4b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 13 Jan 2024 21:28:54 +0100 Subject: [PATCH 032/112] fix: translating frames containing grouped text containers (#7557) --- packages/excalidraw/element/dragElements.ts | 30 ++++++--------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index c91ad64c6..ecec4d083 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -5,14 +5,9 @@ import { getPerfectElementSize } from "./sizeHelpers"; import { NonDeletedExcalidrawElement } from "./types"; import { AppState, PointerDownState } from "../types"; import { getBoundTextElement } from "./textElement"; -import { isSelectedViaGroup } from "../groups"; import { getGridPoint } from "../math"; import Scene from "../scene/Scene"; -import { - isArrowElement, - isBoundToContainer, - isFrameLikeElement, -} from "./typeChecks"; +import { isArrowElement, isFrameLikeElement } from "./typeChecks"; export const dragSelectedElements = ( pointerDownState: PointerDownState, @@ -37,13 +32,11 @@ export const dragSelectedElements = ( .map((f) => f.id); if (frames.length > 0) { - const elementsInFrames = scene - .getNonDeletedElements() - .filter((e) => !isBoundToContainer(e)) - .filter((e) => e.frameId !== null) - .filter((e) => frames.includes(e.frameId!)); - - elementsInFrames.forEach((element) => elementsToUpdate.add(element)); + for (const element of scene.getNonDeletedElements()) { + if (element.frameId !== null && frames.includes(element.frameId)) { + elementsToUpdate.add(element); + } + } } const commonBounds = getCommonBounds( @@ -60,16 +53,9 @@ export const dragSelectedElements = ( elementsToUpdate.forEach((element) => { updateElementCoords(pointerDownState, element, adjustedOffset); - // update coords of bound text only if we're dragging the container directly - // (we don't drag the group that it's part of) if ( - // Don't update coords of arrow label since we calculate its position during render - !isArrowElement(element) && - // container isn't part of any group - // (perf optim so we don't check `isSelectedViaGroup()` in every case) - (!element.groupIds.length || - // container is part of a group, but we're dragging the container directly - (appState.editingGroupId && !isSelectedViaGroup(appState, element))) + // skip arrow labels since we calculate its position during render + !isArrowElement(element) ) { const textElement = getBoundTextElement(element); if (textElement) { From a4e5e46dd17e854fbb891d45edcb57405171c861 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 15 Jan 2024 14:52:04 +0530 Subject: [PATCH 033/112] fix: move default to last so its compatible with nextjs (#7561) --- packages/excalidraw/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 7ec828cc1..5e5c52b21 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -7,8 +7,8 @@ "exports": { ".": { "development": "./dist/dev/index.js", - "default": "./dist/prod/index.js", - "types": "./dist/excalidraw/index.d.ts" + "types": "./dist/excalidraw/index.d.ts", + "default": "./dist/prod/index.js" }, "./index.css": { "development": "./dist/dev/index.css", From dd530737a2b3fd8c6c0ae645b552115e72d126c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=91CAT?= Date: Wed, 17 Jan 2024 19:49:42 +0900 Subject: [PATCH 034/112] docs: fix "canvas actions" link in Props page (#7536) fix "canvas actions" link in Props page --- dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx index 40773a1a2..766c723e4 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/props.mdx @@ -23,7 +23,7 @@ All `props` are _optional_. | [`libraryReturnUrl`](#libraryreturnurl) | `string` | _ | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`theme`](#theme) | `"light"` | `"dark"` | `"light"` | The theme of the Excalidraw component | | [`name`](#name) | `string` | | Name of the drawing | -| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](#canvasactions) | +| [`UIOptions`](/docs/@excalidraw/excalidraw/api/props/ui-options) | `object` | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/constants.ts#L151) | To customise UI options. Currently we support customising [`canvas actions`](/docs/@excalidraw/excalidraw/api/props/ui-options#canvasactions) | | [`detectScroll`](#detectscroll) | `boolean` | `true` | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. | | [`autoFocus`](#autofocus) | `boolean` | `false` | indicates whether to focus the Excalidraw component on page load | From 3b0593baa79f49a8439831e6ea27f04c2db7acae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=BF=E3=81=91CAT?= Date: Fri, 19 Jan 2024 22:41:08 +0900 Subject: [PATCH 035/112] fix: Prevent the library label from being collapsed (#7579) --- packages/excalidraw/components/Sidebar/SidebarTrigger.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 7197af7f2..5b003cdc5 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss @@ -29,6 +29,7 @@ .default-sidebar-trigger .sidebar-trigger__label { display: block; + white-space: nowrap; } &.excalidraw--mobile .default-sidebar-trigger .sidebar-trigger__label { From 46da032626c36df89949683691930fca846609c0 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 19 Jan 2024 14:41:22 +0100 Subject: [PATCH 036/112] fix: exporting frame-overlapping elements belonging to other frames (#7584) --- packages/excalidraw/components/App.tsx | 12 ++-- packages/excalidraw/data/index.ts | 8 +-- packages/excalidraw/frame.ts | 21 ++++++- packages/excalidraw/scene/export.ts | 8 +-- .../excalidraw/tests/scene/export.test.ts | 62 +++++++++++++++++++ 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index acbe56741..d9471d657 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -348,6 +348,7 @@ import { updateFrameMembershipOfSelectedElements, isElementInFrame, getFrameLikeTitle, + getElementsOverlappingFrame, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -395,7 +396,7 @@ import { import { Emitter } from "../emitter"; import { ElementCanvasButtons } from "../element/ElementCanvasButtons"; import { MagicCacheData, diagramToHTML } from "../data/magic"; -import { elementsOverlappingBBox, exportToBlob } from "../../utils/export"; +import { exportToBlob } from "../../utils/export"; import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; @@ -1803,11 +1804,10 @@ class App extends React.Component { return; } - const magicFrameChildren = elementsOverlappingBBox({ - elements: this.scene.getNonDeletedElements(), - bounds: magicFrame, - type: "overlap", - }).filter((el) => !isMagicFrameElement(el)); + const magicFrameChildren = getElementsOverlappingFrame( + this.scene.getNonDeletedElements(), + magicFrame, + ).filter((el) => !isMagicFrameElement(el)); if (!magicFrameChildren.length) { if (source === "button") { diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 7e93542ac..0c63053a9 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -11,7 +11,6 @@ import { NonDeletedExcalidrawElement, } from "../element/types"; import { t } from "../i18n"; -import { elementsOverlappingBBox } from "../../utils/export"; import { isSomeElementSelected, getSelectedElements } from "../scene"; import { exportToCanvas, exportToSvg } from "../scene/export"; import { ExportType } from "../scene/types"; @@ -20,6 +19,7 @@ import { cloneJSON } from "../utils"; import { canvasToBlob } from "./blob"; import { fileSave, FileSystemHandle } from "./filesystem"; import { serializeAsJSON } from "./json"; +import { getElementsOverlappingFrame } from "../frame"; export { loadFromBlob } from "./blob"; export { loadFromJSON, saveAsJSON } from "./json"; @@ -56,11 +56,7 @@ export const prepareElementsForExport = ( isFrameLikeElement(exportedElements[0]) ) { exportingFrame = exportedElements[0]; - exportedElements = elementsOverlappingBBox({ - elements, - bounds: exportingFrame, - type: "overlap", - }); + exportedElements = getElementsOverlappingFrame(elements, exportingFrame); } else if (exportedElements.length > 1) { exportedElements = getSelectedElements( elements, diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 3818e6684..db58bb626 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -21,7 +21,10 @@ import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; -import { doLineSegmentsIntersect } from "../utils/export"; +import { + doLineSegmentsIntersect, + elementsOverlappingBBox, +} from "../utils/export"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; // --------------------------- Frame State ------------------------------------ @@ -664,3 +667,19 @@ export const getFrameLikeTitle = ( // TODO name frames AI only is specific to AI frames return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`; }; + +export const getElementsOverlappingFrame = ( + elements: readonly ExcalidrawElement[], + frame: ExcalidrawFrameLikeElement, +) => { + return ( + elementsOverlappingBBox({ + elements, + bounds: frame, + type: "overlap", + }) + // removes elements who are overlapping, but are in a different frame, + // and thus invisible in target frame + .filter((el) => !el.frameId || el.frameId === frame.id) + ); +}; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index cc84569a6..9c357a21f 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -26,8 +26,8 @@ import { getInitializedImageElements, updateImageCache, } from "../element/image"; -import { elementsOverlappingBBox } from "../../utils/export"; import { + getElementsOverlappingFrame, getFrameLikeElements, getFrameLikeTitle, getRootElements, @@ -168,11 +168,7 @@ const prepareElementsForRender = ({ let nextElements: readonly ExcalidrawElement[]; if (exportingFrame) { - nextElements = elementsOverlappingBBox({ - elements, - bounds: exportingFrame, - type: "overlap", - }); + nextElements = getElementsOverlappingFrame(elements, exportingFrame); } else if (frameRendering.enabled && frameRendering.name) { nextElements = addFrameLabelsAsTextElements(elements, { exportWithDarkMode, diff --git a/packages/excalidraw/tests/scene/export.test.ts b/packages/excalidraw/tests/scene/export.test.ts index 5287aa8cf..ec9a0e6bf 100644 --- a/packages/excalidraw/tests/scene/export.test.ts +++ b/packages/excalidraw/tests/scene/export.test.ts @@ -406,5 +406,67 @@ describe("exporting frames", () => { (frame.height + getFrameNameHeight("svg")).toString(), ); }); + + it("should not export frame-overlapping elements belonging to different frame", async () => { + const frame1 = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const frame2 = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 200, + y: 0, + }); + + const frame1Child = API.createElement({ + type: "rectangle", + width: 150, + height: 100, + x: 0, + y: 50, + frameId: frame1.id, + }); + const frame2Child = API.createElement({ + type: "rectangle", + width: 150, + height: 100, + x: 50, + y: 0, + frameId: frame2.id, + }); + + // low-level exportToSvg api expects elements to be pre-filtered, so let's + // use the filter we use in the editor + const { exportedElements, exportingFrame } = prepareElementsForExport( + [frame1Child, frame1, frame2Child, frame2], + { + selectedElementIds: { [frame1.id]: true }, + }, + true, + ); + + const svg = await exportToSvg({ + elements: exportedElements, + files: null, + exportPadding: 0, + exportingFrame, + }); + + // frame shouldn't be exported + expect(svg.querySelector(`[data-id="${frame1.id}"]`)).toBeNull(); + // frame1 child should be epxorted + expect(svg.querySelector(`[data-id="${frame1Child.id}"]`)).not.toBeNull(); + // frame2 child should not be exported even if it physically overlaps with + // frame1 + expect(svg.querySelector(`[data-id="${frame2Child.id}"]`)).toBeNull(); + + expect(svg.getAttribute("width")).toBe(frame1.width.toString()); + expect(svg.getAttribute("height")).toBe(frame1.height.toString()); + }); }); }); From 1e7df58b5b83fc2aba4c71ccc2478a0003db650f Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 21 Jan 2024 14:01:43 +0100 Subject: [PATCH 037/112] feat: add pasted elements to frame under cursor (#7590) --- packages/excalidraw/components/App.tsx | 10 +++++-- packages/excalidraw/frame.ts | 16 +++++++++++ packages/excalidraw/tests/clipboard.test.tsx | 30 ++++++++++++++++++++ packages/excalidraw/tests/helpers/ui.ts | 2 ++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index d9471d657..9e8a26eed 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3099,12 +3099,18 @@ class App extends React.Component { }, ); - const nextElements = [ + const allElements = [ ...this.scene.getElementsIncludingDeleted(), ...newElements, ]; - this.scene.replaceAllElements(nextElements); + const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); + + if (topLayerFrame) { + addElementsToFrame(allElements, newElements, topLayerFrame); + } + + this.scene.replaceAllElements(allElements); newElements.forEach((newElement) => { if (isTextElement(newElement) && isBoundToContainer(newElement)) { diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index db58bb626..7056574f4 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -398,12 +398,28 @@ export const addElementsToFrame = ( const finalElementsToAdd: ExcalidrawElement[] = []; + const otherFrames = new Set(); + + for (const element of elementsToAdd) { + if (isFrameLikeElement(element) && element.id !== frame.id) { + otherFrames.add(element.id); + } + } + // - add bound text elements if not already in the array // - filter out elements that are already in the frame for (const element of omitGroupsContainingFrameLikes( allElements, elementsToAdd, )) { + // don't add frames or their children + if ( + isFrameLikeElement(element) || + (element.frameId && otherFrames.has(element.frameId)) + ) { + continue; + } + if (!currTargetFrameChildrenMap.has(element.id)) { finalElementsToAdd.push(element); } diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 38d7b49d5..ce00e2da5 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -263,3 +263,33 @@ describe("Paste bound text container", () => { }); }); }); + +describe("pasting & frames", () => { + it("should add pasted elements to frame under cursor", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ type: "rectangle" }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect], + files: null, + }); + + mouse.moveTo(50, 50); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(2); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + }); + }); +}); diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index f37ac0019..58579fe93 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -206,6 +206,8 @@ export class Pointer { moveTo(x: number = this.clientX, y: number = this.clientY) { this.clientX = x; this.clientY = y; + // fire "mousemove" to update editor cursor position + fireEvent.mouseMove(document, this.getEvent()); fireEvent.pointerMove(GlobalTestState.interactiveCanvas, this.getEvent()); } From b66daae1f3552b88e9b4a2c81fcb1d9f1a43c717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Moln=C3=A1r?= <38168628+barnabasmolnar@users.noreply.github.com> Date: Sun, 21 Jan 2024 20:36:09 +0100 Subject: [PATCH 038/112] fix: Truncate collaborator name in dropdown. (#7576) --- packages/excalidraw/actions/actionNavigate.tsx | 4 +++- packages/excalidraw/components/UserList.scss | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 4ce79b96f..ea65584fe 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -57,7 +57,9 @@ export const actionGoToCollaborator = register({ isBeingFollowed={isBeingFollowed} isCurrentUser={collaborator.isCurrentUser === true} /> - {collaborator.username} +

    + {collaborator.username} +
    Date: Mon, 22 Jan 2024 03:55:28 +0800 Subject: [PATCH 039/112] fix: frame name editing inconvenience (#7437) --- packages/excalidraw/frame.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 7056574f4..115cfef06 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -676,12 +676,12 @@ export const getFrameLikeTitle = ( element: ExcalidrawFrameLikeElement, frameIdx: number, ) => { - const existingName = element.name?.trim(); - if (existingName) { - return existingName; - } // TODO name frames AI only is specific to AI frames - return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`; + return element.name === null + ? isFrameElement(element) + ? `Frame ${frameIdx}` + : `AI Frame $${frameIdx}` + : element.name; }; export const getElementsOverlappingFrame = ( From 740a1654529cb24ea6e17770f53c056f4a7269b0 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sun, 21 Jan 2024 20:55:57 +0100 Subject: [PATCH 040/112] fix: filter out elements not overlapping frame on paste (#7591) --- packages/excalidraw/components/App.tsx | 7 +- packages/excalidraw/frame.ts | 61 ++++++++- packages/excalidraw/tests/clipboard.test.tsx | 137 +++++++++++++++++++ 3 files changed, 198 insertions(+), 7 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9e8a26eed..77c972882 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -349,6 +349,7 @@ import { isElementInFrame, getFrameLikeTitle, getElementsOverlappingFrame, + filterElementsEligibleAsFrameChildren, } from "../frame"; import { excludeElementsInFramesFromSelection, @@ -3107,7 +3108,11 @@ class App extends React.Component { const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y }); if (topLayerFrame) { - addElementsToFrame(allElements, newElements, topLayerFrame); + const eligibleElements = filterElementsEligibleAsFrameChildren( + newElements, + topLayerFrame, + ); + addElementsToFrame(allElements, eligibleElements, topLayerFrame); } this.scene.replaceAllElements(allElements); diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 115cfef06..a5e743711 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -107,17 +107,16 @@ export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, ) => { - const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(frame); + const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords(frame); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(elements); return ( - selectionX1 <= elementX1 && - selectionY1 <= elementY1 && - selectionX2 >= elementX2 && - selectionY2 >= elementY2 + frameX1 <= elementX1 && + frameY1 <= elementY1 && + frameX2 >= elementX2 && + frameY2 >= elementY2 ); }; @@ -372,6 +371,56 @@ export const getContainingFrame = ( // --------------------------- Frame Operations ------------------------------- +/** */ +export const filterElementsEligibleAsFrameChildren = ( + elements: readonly ExcalidrawElement[], + frame: ExcalidrawFrameLikeElement, +) => { + const otherFrames = new Set(); + + elements = omitGroupsContainingFrameLikes(elements); + + for (const element of elements) { + if (isFrameLikeElement(element) && element.id !== frame.id) { + otherFrames.add(element.id); + } + } + + const processedGroups = new Set(); + + const eligibleElements: ExcalidrawElement[] = []; + + for (const element of elements) { + // don't add frames or their children + if ( + isFrameLikeElement(element) || + (element.frameId && otherFrames.has(element.frameId)) + ) { + continue; + } + + if (element.groupIds.length) { + const shallowestGroupId = element.groupIds.at(-1)!; + if (!processedGroups.has(shallowestGroupId)) { + processedGroups.add(shallowestGroupId); + const groupElements = getElementsInGroup(elements, shallowestGroupId); + if (groupElements.some((el) => elementOverlapsWithFrame(el, frame))) { + for (const child of groupElements) { + eligibleElements.push(child); + } + } + } + } else { + const overlaps = elementOverlapsWithFrame(element, frame); + if (overlaps) { + eligibleElements.push(element); + } + } + } + + return eligibleElements; +}; + /** * Retains (or repairs for target frame) the ordering invriant where children * elements come right before the parent frame: diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index ce00e2da5..149ebcd1e 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -292,4 +292,141 @@ describe("pasting & frames", () => { expect(h.elements[1].frameId).toBe(frame.id); }); }); + + it("should filter out elements not overlapping frame", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 100, + y: 100, + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(3); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(null); + }); + }); + + it("should not filter out elements not overlapping frame if part of group", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + groupIds: ["g1"], + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 100, + y: 100, + groupIds: ["g1"], + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(3); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(frame.id); + }); + }); + + it("should not filter out other frames and their children", async () => { + const frame = API.createElement({ + type: "frame", + width: 100, + height: 100, + x: 0, + y: 0, + }); + const rect = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + groupIds: ["g1"], + }); + + const frame2 = API.createElement({ + type: "frame", + width: 75, + height: 75, + x: 0, + y: 0, + }); + const rect2 = API.createElement({ + type: "rectangle", + width: 50, + height: 50, + x: 55, + y: 55, + frameId: frame2.id, + }); + + h.elements = [frame]; + + const clipboardJSON = await serializeAsClipboardJSON({ + elements: [rect, rect2, frame2], + files: null, + }); + + mouse.moveTo(90, 90); + + pasteWithCtrlCmdV(clipboardJSON); + + await waitFor(() => { + expect(h.elements.length).toBe(4); + expect(h.elements[1].type).toBe(rect.type); + expect(h.elements[1].frameId).toBe(frame.id); + expect(h.elements[2].type).toBe(rect2.type); + expect(h.elements[2].frameId).toBe(h.elements[3].id); + expect(h.elements[3].type).toBe(frame2.type); + expect(h.elements[3].frameId).toBe(null); + }); + }); }); From 0415c616b1ec9ec003ae53049b4f98df844dfb5f Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 22 Jan 2024 00:23:02 +0100 Subject: [PATCH 041/112] refactor: decoupling global Scene state part-1 (#7577) --- packages/excalidraw/actions/actionFlip.ts | 38 +++- packages/excalidraw/actions/actionFrame.ts | 6 +- packages/excalidraw/actions/actionGroup.tsx | 7 +- .../excalidraw/actions/actionProperties.tsx | 42 ++-- packages/excalidraw/components/Actions.tsx | 18 +- packages/excalidraw/components/App.tsx | 52 ++--- packages/excalidraw/components/LayerUI.tsx | 2 +- packages/excalidraw/components/MobileMenu.tsx | 2 +- .../components/canvases/InteractiveCanvas.tsx | 9 +- .../components/canvases/StaticCanvas.tsx | 13 +- packages/excalidraw/data/restore.ts | 21 +- packages/excalidraw/element/bounds.ts | 13 +- packages/excalidraw/element/embeddable.ts | 24 +-- packages/excalidraw/element/newElement.ts | 6 +- packages/excalidraw/element/resizeElements.ts | 53 +++-- packages/excalidraw/element/textElement.ts | 49 ++--- packages/excalidraw/element/textWysiwyg.tsx | 17 +- packages/excalidraw/element/types.ts | 30 ++- packages/excalidraw/frame.ts | 112 ++++++----- packages/excalidraw/groups.ts | 13 +- packages/excalidraw/renderer/renderElement.ts | 13 +- packages/excalidraw/renderer/renderScene.ts | 190 ++++++++++-------- packages/excalidraw/scene/Fonts.ts | 9 +- packages/excalidraw/scene/Renderer.ts | 62 +++--- packages/excalidraw/scene/Scene.ts | 72 +++++-- packages/excalidraw/scene/export.ts | 56 ++++-- packages/excalidraw/scene/scrollbars.ts | 7 +- packages/excalidraw/scene/selection.ts | 17 +- packages/excalidraw/scene/types.ts | 11 +- packages/excalidraw/utility-types.ts | 8 + packages/excalidraw/utils.ts | 42 +++- 31 files changed, 630 insertions(+), 384 deletions(-) diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 12d5e2e48..81476e241 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,9 +1,13 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { + ExcalidrawElement, + NonDeleted, + NonDeletedElementsMap, +} from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; -import { AppState, PointerDownState } from "../types"; +import { AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; @@ -20,7 +24,12 @@ export const actionFlipHorizontal = register({ perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "horizontal"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "horizontal", + ), appState, app, ), @@ -38,7 +47,12 @@ export const actionFlipVertical = register({ perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "vertical"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "vertical", + ), appState, app, ), @@ -53,6 +67,7 @@ export const actionFlipVertical = register({ const flipSelectedElements = ( elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", ) => { @@ -67,6 +82,7 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, + elementsMap, appState, flipDirection, ); @@ -79,15 +95,17 @@ const flipSelectedElements = ( }; const flipElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + elementsMap: NonDeletedElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { - const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); + const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); resizeMultipleElements( - { originalElements: arrayToMap(elements) } as PointerDownState, - elements, + elementsMap, + selectedElements, + elementsMap, "nw", true, flipDirection === "horizontal" ? maxX : minX, @@ -96,7 +114,7 @@ const flipElements = ( (isBindingEnabled(appState) ? bindOrUnbindSelectedElements - : unbindLinearElements)(elements); + : unbindLinearElements)(selectedElements); - return elements; + return selectedElements; }; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 4cddb2ac0..8232db3cd 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -63,11 +63,7 @@ export const actionRemoveAllElementsFromFrame = register({ if (isFrameLikeElement(selectedElement)) { return { - elements: removeAllElementsFromFrame( - elements, - selectedElement, - appState, - ), + elements: removeAllElementsFromFrame(elements, selectedElement), appState: { ...appState, selectedElementIds: { diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index e6cb05840..42bd26efe 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -105,11 +105,7 @@ export const actionGroup = register({ const frameElementsMap = groupByFrameLikes(selectedElements); frameElementsMap.forEach((elementsInFrame, frameId) => { - nextElements = removeElementsFromFrame( - nextElements, - elementsInFrame, - appState, - ); + removeElementsFromFrame(elementsInFrame); }); } @@ -229,7 +225,6 @@ export const actionUngroup = register({ nextElements, getElementsInResizingFrame(nextElements, frame, appState), frame, - appState, ); } }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 9489970ae..c2a47802f 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,4 @@ -import { AppState, Primitive } from "../types"; +import { AppClassProperties, AppState, Primitive } from "../types"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -66,7 +66,6 @@ import { import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement, - getContainerElement, getDefaultLineHeight, } from "../element/textElement"; import { @@ -189,6 +188,7 @@ const offsetElementAfterFontResize = ( const changeFontSize = ( elements: readonly ExcalidrawElement[], appState: AppState, + app: AppClassProperties, getNewFontSize: (element: ExcalidrawTextElement) => number, fallbackValue?: ExcalidrawTextElement["fontSize"], ) => { @@ -206,7 +206,10 @@ const changeFontSize = ( let newElement: ExcalidrawTextElement = newElementWith(oldElement, { fontSize: newFontSize, }); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -600,8 +603,8 @@ export const actionChangeOpacity = register({ export const actionChangeFontSize = register({ name: "changeFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, () => value, value); + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, () => value, value); }, PanelComponent: ({ elements, appState, updateData }) => (
    @@ -663,8 +666,8 @@ export const actionChangeFontSize = register({ export const actionDecreaseFontSize = register({ name: "decreaseFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round( // get previous value before relative increase (doesn't work fully // due to rounding and float precision issues) @@ -685,8 +688,8 @@ export const actionDecreaseFontSize = register({ export const actionIncreaseFontSize = register({ name: "increaseFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), ); }, @@ -703,7 +706,7 @@ export const actionIncreaseFontSize = register({ export const actionChangeFontFamily = register({ name: "changeFontFamily", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -717,7 +720,10 @@ export const actionChangeFontFamily = register({ lineHeight: getDefaultLineHeight(value), }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } @@ -795,7 +801,7 @@ export const actionChangeFontFamily = register({ export const actionChangeTextAlign = register({ name: "changeTextAlign", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -806,7 +812,10 @@ export const actionChangeTextAlign = register({ oldElement, { textAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } @@ -875,7 +884,7 @@ export const actionChangeTextAlign = register({ export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", trackEvent: { category: "element" }, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -887,7 +896,10 @@ export const actionChangeVerticalAlign = register({ { verticalAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f07664f1a..d67c8893d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,7 +1,6 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { ActionManager } from "../actions/manager"; -import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, ExcalidrawElementType } from "../element/types"; +import { ExcalidrawElementType, NonDeletedElementsMap } from "../element/types"; import { t } from "../i18n"; import { useDevice } from "./App"; import { @@ -44,17 +43,14 @@ import { useTunnels } from "../context/tunnels"; export const SelectedShapeActions = ({ appState, - elements, + elementsMap, renderAction, }: { appState: UIAppState; - elements: readonly ExcalidrawElement[]; + elementsMap: NonDeletedElementsMap; renderAction: ActionManager["renderAction"]; }) => { - const targetElements = getTargetElements( - getNonDeletedElements(elements), - appState, - ); + const targetElements = getTargetElements(elementsMap, appState); let isSingleElementBoundContainer = false; if ( @@ -137,12 +133,12 @@ export const SelectedShapeActions = ({ {renderAction("changeFontFamily")} {(appState.activeTool.type === "text" || - suppportsHorizontalAlign(targetElements)) && + suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} )} - {shouldAllowVerticalAlign(targetElements) && + {shouldAllowVerticalAlign(targetElements, elementsMap) && renderAction("changeVerticalAlign")} {(canHaveArrowheads(appState.activeTool.type) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 77c972882..9e3ff5dac 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1417,7 +1417,7 @@ class App extends React.Component { const { renderTopRightUI, renderCustomStats } = this.props; const versionNonce = this.scene.getVersionNonce(); - const { canvasElements, visibleElements } = + const { elementsMap, visibleElements } = this.renderer.getRenderableElements({ versionNonce, zoom: this.state.zoom, @@ -1627,7 +1627,7 @@ class App extends React.Component { { { private renderInteractiveSceneCallback = ({ atLeastOneVisibleElement, scrollBars, - elements, + elementsMap, }: RenderInteractiveSceneCallback) => { if (scrollBars) { currentScrollBars = scrollBars; @@ -2789,7 +2789,7 @@ class App extends React.Component { // hide when editing text isTextElement(this.state.editingElement) ? false - : !atLeastOneVisibleElement && elements.length > 0; + : !atLeastOneVisibleElement && elementsMap.size > 0; if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside }); } @@ -3119,7 +3119,10 @@ class App extends React.Component { newElements.forEach((newElement) => { if (isTextElement(newElement) && isBoundToContainer(newElement)) { - const container = getContainerElement(newElement); + const container = getContainerElement( + newElement, + this.scene.getElementsMapIncludingDeleted(), + ); redrawTextBoundingBox(newElement, container); } }); @@ -4183,11 +4186,18 @@ class App extends React.Component { this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement(_element, { - text, - isDeleted, - originalText, - }); + return updateTextElement( + _element, + getContainerElement( + _element, + this.scene.getElementsMapIncludingDeleted(), + ), + { + text, + isDeleted, + originalText, + }, + ); } return _element; }), @@ -7700,13 +7710,9 @@ class App extends React.Component { groupIds: [], }); - this.scene.replaceAllElements( - removeElementsFromFrame( - this.scene.getElementsIncludingDeleted(), - [linearElement], - this.state, - ), - ); + removeElementsFromFrame([linearElement]); + + this.scene.informMutation(); } } } @@ -7716,7 +7722,7 @@ class App extends React.Component { this.getTopLayerFrameAtSceneCoords(sceneCoords); const selectedElements = this.scene.getSelectedElements(this.state); - let nextElements = this.scene.getElementsIncludingDeleted(); + let nextElements = this.scene.getElementsMapIncludingDeleted(); const updateGroupIdsAfterEditingGroup = ( elements: ExcalidrawElement[], @@ -7809,7 +7815,7 @@ class App extends React.Component { this.scene.replaceAllElements( addElementsToFrame( - this.scene.getElementsIncludingDeleted(), + this.scene.getElementsMapIncludingDeleted(), elementsInsideFrame, draggingElement, ), @@ -7857,7 +7863,6 @@ class App extends React.Component { this.state, ), frame, - this.state, ); } @@ -9137,10 +9142,10 @@ class App extends React.Component { if ( transformElements( - pointerDownState, + pointerDownState.originalElements, transformHandleType, selectedElements, - pointerDownState.resize.arrowDirection, + this.scene.getElementsMapIncludingDeleted(), shouldRotateWithDiscreteAngle(event), shouldResizeFromCenter(event), selectedElements.length === 1 && isImageElement(selectedElements[0]) @@ -9150,7 +9155,6 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, - this.state, ) ) { this.maybeSuggestBindingForAll(selectedElements); diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 8cedf689d..7247e8bf1 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -226,7 +226,7 @@ const LayerUI = ({ > diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 91d0c518c..98f85a9ac 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -183,7 +183,7 @@ export const MobileMenu = ({
    diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 0aaa52c7c..0782b92b9 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -7,6 +7,7 @@ import type { DOMAttributes } from "react"; import type { AppState, InteractiveCanvasAppState } from "../../types"; import type { InteractiveCanvasRenderConfig, + RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; @@ -15,7 +16,7 @@ import { isRenderThrottlingEnabled } from "../../reactUtils"; type InteractiveCanvasProps = { containerRef: React.RefObject; canvas: HTMLCanvasElement | null; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; versionNonce: number | undefined; @@ -113,7 +114,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { renderInteractiveScene( { canvas: props.canvas, - elements: props.elements, + elementsMap: props.elementsMap, visibleElements: props.visibleElements, selectedElements: props.selectedElements, scale: window.devicePixelRatio, @@ -201,10 +202,10 @@ const areEqual = ( prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.versionNonce !== nextProps.versionNonce || prevProps.scale !== nextProps.scale || - // we need to memoize on element arrays because they may have renewed + // we need to memoize on elementsMap because they may have renewed // even if versionNonce didn't change (e.g. we filter elements out based // on appState) - prevProps.elements !== nextProps.elements || + prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements || prevProps.selectedElements !== nextProps.selectedElements ) { diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index c8174566b..3dc5b9175 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -3,14 +3,17 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { renderStaticScene } from "../../renderer/renderScene"; import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; -import type { StaticCanvasRenderConfig } from "../../scene/types"; +import type { + RenderableElementsMap, + StaticCanvasRenderConfig, +} from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; type StaticCanvasProps = { canvas: HTMLCanvasElement; rc: RoughCanvas; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; versionNonce: number | undefined; selectionNonce: number | undefined; @@ -63,7 +66,7 @@ const StaticCanvas = (props: StaticCanvasProps) => { canvas, rc: props.rc, scale: props.scale, - elements: props.elements, + elementsMap: props.elementsMap, visibleElements: props.visibleElements, appState: props.appState, renderConfig: props.renderConfig, @@ -106,10 +109,10 @@ const areEqual = ( if ( prevProps.versionNonce !== nextProps.versionNonce || prevProps.scale !== nextProps.scale || - // we need to memoize on element arrays because they may have renewed + // we need to memoize on elementsMap because they may have renewed // even if versionNonce didn't change (e.g. we filter elements out based // on appState) - prevProps.elements !== nextProps.elements || + prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements ) { return false; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index ff4063593..12e7f1af1 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -40,6 +40,7 @@ import { arrayToMap } from "../utils"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, + getContainerElement, getDefaultLineHeight, measureBaseline, } from "../element/textElement"; @@ -179,7 +180,6 @@ const restoreElementWithProperties = < const restoreElement = ( element: Exclude, - refreshDimensions = false, ): typeof element | null => { switch (element.type) { case "text": @@ -232,10 +232,6 @@ const restoreElement = ( element = bumpVersion(element); } - if (refreshDimensions) { - element = { ...element, ...refreshTextDimensions(element) }; - } - return element; case "freedraw": { return restoreElementWithProperties(element, { @@ -426,10 +422,7 @@ export const restoreElements = ( // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained if (element.type !== "selection" && !isInvisiblySmallElement(element)) { - let migratedElement: ExcalidrawElement | null = restoreElement( - element, - opts?.refreshDimensions, - ); + let migratedElement: ExcalidrawElement | null = restoreElement(element); if (migratedElement) { const localElement = localElementsMap?.get(element.id); if (localElement && localElement.version > migratedElement.version) { @@ -462,6 +455,16 @@ export const restoreElements = ( } else if (element.boundElements) { repairContainerElement(element, restoredElementsMap); } + + if (opts.refreshDimensions && isTextElement(element)) { + Object.assign( + element, + refreshTextDimensions( + element, + getContainerElement(element, restoredElementsMap), + ), + ); + } } return restoredElements; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 292fc995d..673649e5f 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -5,6 +5,7 @@ import { ExcalidrawFreeDrawElement, NonDeleted, ExcalidrawTextElementWithContainer, + ElementsMapOrArray, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; @@ -161,7 +162,11 @@ export const getElementAbsoluteCoords = ( includeBoundText, ); } else if (isTextElement(element)) { - const container = getContainerElement(element); + const elementsMap = + Scene.getScene(element)?.getElementsMapIncludingDeleted(); + const container = elementsMap + ? getContainerElement(element, elementsMap) + : null; if (isArrowElement(container)) { const coords = LinearElementEditor.getBoundTextElementPosition( container, @@ -729,10 +734,8 @@ const getLinearElementRotatedBounds = ( export const getElementBounds = (element: ExcalidrawElement): Bounds => { return ElementBounds.getBounds(element); }; -export const getCommonBounds = ( - elements: readonly ExcalidrawElement[], -): Bounds => { - if (!elements.length) { +export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { + if ("size" in elements ? !elements.size : !elements.length) { return [0, 0, 0, 0]; } diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index 025ed4901..f62b0f95f 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -5,17 +5,12 @@ import { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; -import { getContainerElement, wrapText } from "./textElement"; -import { - isFrameLikeElement, - isIframeElement, - isIframeLikeElement, -} from "./typeChecks"; +import { wrapText } from "./textElement"; +import { isIframeElement } from "./typeChecks"; import { ExcalidrawElement, ExcalidrawIframeLikeElement, IframeData, - NonDeletedExcalidrawElement, } from "./types"; const embeddedLinkCache = new Map(); @@ -217,21 +212,6 @@ export const getEmbedLink = ( return { link, intrinsicSize: aspectRatio, type }; }; -export const isIframeLikeOrItsLabel = ( - element: NonDeletedExcalidrawElement, -): Boolean => { - if (isIframeLikeElement(element)) { - return true; - } - if (element.type === "text") { - const container = getContainerElement(element); - if (container && isFrameLikeElement(container)) { - return true; - } - } - return false; -}; - export const createPlaceholderEmbeddableLabel = ( element: ExcalidrawIframeLikeElement, ): ExcalidrawElement => { diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 00cae296c..3158c064c 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -31,7 +31,6 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { - getContainerElement, measureText, normalizeText, wrapText, @@ -333,12 +332,12 @@ const getAdjustedDimensions = ( export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, text = textElement.text, ) => { if (textElement.isDeleted) { return; } - const container = getContainerElement(textElement); if (container) { text = wrapText( text, @@ -352,6 +351,7 @@ export const refreshTextDimensions = ( export const updateTextElement = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, { text, isDeleted, @@ -365,7 +365,7 @@ export const updateTextElement = ( return newElementWith(textElement, { originalText, isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, originalText), + ...refreshTextDimensions(textElement, container, originalText), }); }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 883382933..46b891aca 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -15,6 +15,7 @@ import { ExcalidrawElement, ExcalidrawTextElementWithContainer, ExcalidrawImageElement, + ElementsMap, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -41,7 +42,7 @@ import { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import { AppState, Point, PointerDownState } from "../types"; +import { Point, PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -68,10 +69,10 @@ export const normalizeAngle = (angle: number): number => { // Returns true when transform (resizing/rotation) happened export const transformElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], transformHandleType: MaybeTransformHandleType, selectedElements: readonly NonDeletedExcalidrawElement[], - resizeArrowDirection: "origin" | "end", + elementsMap: ElementsMap, shouldRotateWithDiscreteAngle: boolean, shouldResizeFromCenter: boolean, shouldMaintainAspectRatio: boolean, @@ -79,7 +80,6 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, - appState: AppState, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -89,7 +89,6 @@ export const transformElements = ( pointerX, pointerY, shouldRotateWithDiscreteAngle, - pointerDownState.originalElements, ); updateBoundElements(element); } else if ( @@ -101,6 +100,7 @@ export const transformElements = ( ) { resizeSingleTextElement( element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -109,9 +109,10 @@ export const transformElements = ( updateBoundElements(element); } else if (transformHandleType) { resizeSingleElement( - pointerDownState.originalElements, + originalElements, shouldMaintainAspectRatio, element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -123,7 +124,7 @@ export const transformElements = ( } else if (selectedElements.length > 1) { if (transformHandleType === "rotation") { rotateMultipleElements( - pointerDownState, + originalElements, selectedElements, pointerX, pointerY, @@ -139,8 +140,9 @@ export const transformElements = ( transformHandleType === "se" ) { resizeMultipleElements( - pointerDownState, + originalElements, selectedElements, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -157,7 +159,6 @@ const rotateSingleElement = ( pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, - originalElements: Map>, ) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; @@ -207,6 +208,7 @@ const rescalePointsInElement = ( const measureFontSizeFromWidth = ( element: NonDeleted, + elementsMap: ElementsMap, nextWidth: number, nextHeight: number, ): { size: number; baseline: number } | null => { @@ -215,7 +217,7 @@ const measureFontSizeFromWidth = ( const hasContainer = isBoundToContainer(element); if (hasContainer) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (container) { width = getBoundTextMaxWidth(container); } @@ -257,6 +259,7 @@ const getSidesForTransformHandle = ( const resizeSingleTextElement = ( element: NonDeleted, + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, @@ -303,7 +306,12 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight); + const metrics = measureFontSizeFromWidth( + element, + elementsMap, + nextWidth, + nextHeight, + ); if (metrics === null) { return; } @@ -342,6 +350,7 @@ export const resizeSingleElement = ( originalElements: PointerDownState["originalElements"], shouldMaintainAspectRatio: boolean, element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, transformHandleDirection: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, @@ -448,6 +457,7 @@ export const resizeSingleElement = ( const nextFont = measureFontSizeFromWidth( boundTextElement, + elementsMap, getBoundTextMaxWidth(updatedElement), getBoundTextMaxHeight(updatedElement, boundTextElement), ); @@ -637,8 +647,9 @@ export const resizeSingleElement = ( }; export const resizeMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], selectedElements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, @@ -658,7 +669,7 @@ export const resizeMultipleElements = ( }[], element, ) => { - const origElement = pointerDownState.originalElements.get(element.id); + const origElement = originalElements.get(element.id); if (origElement) { acc.push({ orig: origElement, latest: element }); } @@ -679,7 +690,7 @@ export const resizeMultipleElements = ( if (!textId) { return acc; } - const text = pointerDownState.originalElements.get(textId) ?? null; + const text = originalElements.get(textId) ?? null; if (!isBoundToContainer(text)) { return acc; } @@ -825,7 +836,12 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth(orig, width, height); + const metrics = measureFontSizeFromWidth( + orig, + elementsMap, + width, + height, + ); if (!metrics) { return; } @@ -833,7 +849,7 @@ export const resizeMultipleElements = ( update.baseline = metrics.baseline; } - const boundTextElement = pointerDownState.originalElements.get( + const boundTextElement = originalElements.get( getBoundTextElementId(orig) ?? "", ) as ExcalidrawTextElementWithContainer | undefined; @@ -884,7 +900,7 @@ export const resizeMultipleElements = ( }; const rotateMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], elements: readonly NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, @@ -906,8 +922,7 @@ const rotateMultipleElements = ( const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const origAngle = - pointerDownState.originalElements.get(element.id)?.angle ?? - element.angle; + originalElements.get(element.id)?.angle ?? element.angle; const [rotatedCX, rotatedCY] = rotate( cx, cy, diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index e084dfba3..da1348ec2 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,5 +1,6 @@ import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { + ElementsMap, ExcalidrawElement, ExcalidrawElementType, ExcalidrawTextContainer, @@ -682,17 +683,15 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => { }; export const getContainerElement = ( - element: - | (ExcalidrawElement & { - containerId: ExcalidrawElement["id"] | null; - }) - | null, -) => { + element: ExcalidrawTextElement | null, + elementsMap: ElementsMap, +): ExcalidrawTextContainer | null => { if (!element) { return null; } if (element.containerId) { - return Scene.getScene(element)?.getElement(element.containerId) || null; + return (elementsMap.get(element.containerId) || + null) as ExcalidrawTextContainer | null; } return null; }; @@ -752,28 +751,16 @@ export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { }; }; -export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { - const container = getContainerElement(textElement); +export const getTextElementAngle = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, +) => { if (!container || isArrowElement(container)) { return textElement.angle; } return container.angle; }; -export const getBoundTextElementOffset = ( - boundTextElement: ExcalidrawTextElement | null, -) => { - const container = getContainerElement(boundTextElement); - if (!container || !boundTextElement) { - return 0; - } - if (isArrowElement(container)) { - return BOUND_TEXT_PADDING * 8; - } - - return BOUND_TEXT_PADDING; -}; - export const getBoundTextElementPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, @@ -788,12 +775,12 @@ export const getBoundTextElementPosition = ( export const shouldAllowVerticalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; @@ -804,12 +791,12 @@ export const shouldAllowVerticalAlign = ( export const suppportsHorizontalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 52f89e0b9..801f0c440 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -153,7 +153,10 @@ export const textWysiwyg = ({ if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; - const container = getContainerElement(updatedTextElement); + const container = getContainerElement( + updatedTextElement, + app.scene.getElementsMapIncludingDeleted(), + ); let maxWidth = updatedTextElement.width; let maxHeight = updatedTextElement.height; @@ -277,7 +280,7 @@ export const textWysiwyg = ({ transform: getTransform( textElementWidth, textElementHeight, - getTextElementAngle(updatedTextElement), + getTextElementAngle(updatedTextElement, container), appState, maxWidth, editorMaxHeight, @@ -348,7 +351,10 @@ export const textWysiwyg = ({ if (!data) { return; } - const container = getContainerElement(element); + const container = getContainerElement( + element, + app.scene.getElementsMapIncludingDeleted(), + ); const font = getFontString({ fontSize: app.state.currentItemFontSize, @@ -528,7 +534,10 @@ export const textWysiwyg = ({ return; } let text = editable.value; - const container = getContainerElement(updateElement); + const container = getContainerElement( + updateElement, + app.scene.getElementsMapIncludingDeleted(), + ); if (container) { text = updateElement.text; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index c468eac82..7659ad1e9 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -6,7 +6,7 @@ import { THEME, VERTICAL_ALIGN, } from "../constants"; -import { MarkNonNullable, ValueOf } from "../utility-types"; +import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types"; import { MagicCacheData } from "../data/magic"; export type ChartType = "bar" | "line"; @@ -254,3 +254,31 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type FileId = string & { _brand: "FileId" }; export type ExcalidrawElementType = ExcalidrawElement["type"]; + +/** + * Map of excalidraw elements. + * Unspecified whether deleted or non-deleted. + * Can be a subset of Scene elements. + */ +export type ElementsMap = Map; + +/** + * Map of non-deleted elements. + * Can be a subset of Scene elements. + */ +export type NonDeletedElementsMap = Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement +> & + MakeBrand<"NonDeletedElementsMap">; + +/** + * Map of all excalidraw Scene elements, including deleted. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type SceneElementsMap = Map & + MakeBrand<"SceneElementsMap">; + +export type ElementsMapOrArray = + | readonly ExcalidrawElement[] + | Readonly; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index a5e743711..ecb70ef1e 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -4,6 +4,8 @@ import { isTextElement, } from "./element"; import { + ElementsMap, + ElementsMapOrArray, ExcalidrawElement, ExcalidrawFrameLikeElement, NonDeleted, @@ -26,6 +28,7 @@ import { elementsOverlappingBBox, } from "../utils/export"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; +import { ReadonlySetLike } from "./utility-types"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( @@ -211,9 +214,17 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => { }; export const getFrameChildren = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, frameId: string, -) => allElements.filter((element) => element.frameId === frameId); +) => { + const frameChildren: ExcalidrawElement[] = []; + for (const element of allElements.values()) { + if (element.frameId === frameId) { + frameChildren.push(element); + } + } + return frameChildren; +}; export const getFrameLikeElements = ( allElements: ExcalidrawElementsIncludingDeleted, @@ -425,23 +436,20 @@ export const filterElementsEligibleAsFrameChildren = ( * Retains (or repairs for target frame) the ordering invriant where children * elements come right before the parent frame: * [el, el, child, child, frame, el] + * + * @returns mutated allElements (same data structure) */ -export const addElementsToFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const addElementsToFrame = ( + allElements: T, elementsToAdd: NonDeletedExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => { - const { currTargetFrameChildrenMap } = allElements.reduce( - (acc, element, index) => { - if (element.frameId === frame.id) { - acc.currTargetFrameChildrenMap.set(element.id, true); - } - return acc; - }, - { - currTargetFrameChildrenMap: new Map(), - }, - ); +): T => { + const currTargetFrameChildrenMap = new Map(); + for (const element of allElements.values()) { + if (element.frameId === frame.id) { + currTargetFrameChildrenMap.set(element.id, true); + } + } const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); @@ -492,13 +500,12 @@ export const addElementsToFrame = ( false, ); } - return allElements.slice(); + + return allElements; }; export const removeElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, - elementsToRemove: NonDeletedExcalidrawElement[], - appState: AppState, + elementsToRemove: ReadonlySetLike, ) => { const _elementsToRemove = new Map< ExcalidrawElement["id"], @@ -536,35 +543,34 @@ export const removeElementsFromFrame = ( false, ); } - - return allElements.slice(); }; -export const removeAllElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const removeAllElementsFromFrame = ( + allElements: readonly T[], frame: ExcalidrawFrameLikeElement, - appState: AppState, ) => { const elementsInFrame = getFrameChildren(allElements, frame.id); - return removeElementsFromFrame(allElements, elementsInFrame, appState); + removeElementsFromFrame(elementsInFrame); + return allElements; }; -export const replaceAllElementsInFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const replaceAllElementsInFrame = ( + allElements: readonly T[], nextElementsInFrame: ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, - appState: AppState, -) => { +): T[] => { return addElementsToFrame( - removeAllElementsFromFrame(allElements, frame, appState), + removeAllElementsFromFrame(allElements, frame), nextElementsInFrame, frame, - ); + ).slice(); }; /** does not mutate elements, but returns new ones */ -export const updateFrameMembershipOfSelectedElements = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const updateFrameMembershipOfSelectedElements = < + T extends ElementsMapOrArray, +>( + allElements: T, appState: AppState, app: AppClassProperties, ) => { @@ -589,19 +595,22 @@ export const updateFrameMembershipOfSelectedElements = ( const elementsToRemove = new Set(); + const elementsMap = arrayToMap(allElements); + elementsToFilter.forEach((element) => { if ( element.frameId && !isFrameLikeElement(element) && - !isElementInFrame(element, allElements, appState) + !isElementInFrame(element, elementsMap, appState) ) { elementsToRemove.add(element); } }); - return elementsToRemove.size > 0 - ? removeElementsFromFrame(allElements, [...elementsToRemove], appState) - : allElements; + if (elementsToRemove.size > 0) { + removeElementsFromFrame(elementsToRemove); + } + return allElements; }; /** @@ -609,14 +618,16 @@ export const updateFrameMembershipOfSelectedElements = ( * anywhere in the group tree */ export const omitGroupsContainingFrameLikes = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, /** subset of elements you want to filter. Optional perf optimization so we * don't have to filter all elements unnecessarily */ selectedElements?: readonly ExcalidrawElement[], ) => { const uniqueGroupIds = new Set(); - for (const el of selectedElements || allElements) { + const elements = selectedElements || allElements; + + for (const el of elements.values()) { const topMostGroupId = el.groupIds[el.groupIds.length - 1]; if (topMostGroupId) { uniqueGroupIds.add(topMostGroupId); @@ -634,9 +645,15 @@ export const omitGroupsContainingFrameLikes = ( } } - return (selectedElements || allElements).filter( - (el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]), - ); + const ret: ExcalidrawElement[] = []; + + for (const element of elements.values()) { + if (!rejectedGroupIds.has(element.groupIds[element.groupIds.length - 1])) { + ret.push(element); + } + } + + return ret; }; /** @@ -645,10 +662,11 @@ export const omitGroupsContainingFrameLikes = ( */ export const getTargetFrame = ( element: ExcalidrawElement, + elementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, elementsMap) || element : element; return appState.selectedElementIds[_element.id] && @@ -661,12 +679,12 @@ export const getTargetFrame = ( // given an element, return if the element is in some frame export const isElementInFrame = ( element: ExcalidrawElement, - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMap, appState: StaticCanvasAppState, ) => { - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, allElements, appState); const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, allElements) || element : element; if (frame) { diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index dd5512ba1..b0bedc4f9 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -3,6 +3,7 @@ import { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement, + ElementsMapOrArray, } from "./element/types"; import { AppClassProperties, @@ -270,9 +271,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) => element.groupIds.includes(groupId); export const getElementsInGroup = ( - elements: readonly ExcalidrawElement[], + elements: ElementsMapOrArray, groupId: string, -) => elements.filter((element) => isElementInGroup(element, groupId)); +) => { + const elementsInGroup: ExcalidrawElement[] = []; + for (const element of elements.values()) { + if (isElementInGroup(element, groupId)) { + elementsInGroup.push(element); + } + } + return elementsInGroup; +}; export const getSelectedGroupIdForElement = ( element: ExcalidrawElement, diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 94eda49f9..39e6c4974 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -21,7 +21,11 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Drawable } from "roughjs/bin/core"; import type { RoughSVG } from "roughjs/bin/svg"; -import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types"; +import { + SVGRenderConfig, + StaticCanvasRenderConfig, + RenderableElementsMap, +} from "../scene/types"; import { distance, getFontString, @@ -611,6 +615,7 @@ export const renderSelectionElement = ( export const renderElement = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, @@ -715,7 +720,7 @@ export const renderElement = ( let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftY = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( @@ -900,6 +905,7 @@ const maybeWrapNodesInFrameClipPath = ( export const renderElementToSvg = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, @@ -912,7 +918,7 @@ export const renderElementToSvg = ( let cx = (x2 - x1) / 2 - (element.x - x1); let cy = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); @@ -1013,6 +1019,7 @@ export const renderElementToSvg = ( createPlaceholderEmbeddableLabel(element); renderElementToSvg( label, + elementsMap, rsvg, root, files, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index a5b78d3b8..0a066bfd4 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -33,6 +33,7 @@ import { SVGRenderConfig, StaticCanvasRenderConfig, StaticSceneRenderConfig, + RenderableElementsMap, } from "../scene/types"; import { getScrollBars, @@ -61,7 +62,7 @@ import { TransformHandles, TransformHandleType, } from "../element/transformHandles"; -import { throttleRAF } from "../utils"; +import { arrayToMap, throttleRAF } from "../utils"; import { UserIdleState } from "../types"; import { FRAME_STYLE, THEME_FILTER } from "../constants"; import { @@ -75,10 +76,7 @@ import { isIframeLikeElement, isLinearElement, } from "../element/typeChecks"; -import { - isIframeLikeOrItsLabel, - createPlaceholderEmbeddableLabel, -} from "../element/embeddable"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; import { elementOverlapsWithFrame, getTargetFrame, @@ -446,7 +444,7 @@ const bootstrapCanvas = ({ const _renderInteractiveScene = ({ canvas, - elements, + elementsMap, visibleElements, selectedElements, scale, @@ -454,7 +452,7 @@ const _renderInteractiveScene = ({ renderConfig, }: InteractiveSceneRenderConfig) => { if (canvas === null) { - return { atLeastOneVisibleElement: false, elements }; + return { atLeastOneVisibleElement: false, elementsMap }; } const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( @@ -562,75 +560,64 @@ const _renderInteractiveScene = ({ if (showBoundingBox) { // Optimisation for finding quickly relevant element ids - const locallySelectedIds = selectedElements.reduce( - (acc: Record, element) => { - acc[element.id] = true; - return acc; - }, - {}, - ); + const locallySelectedIds = arrayToMap(selectedElements); - const selections = elements.reduce( - ( - acc: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }[], - element, - ) => { - const selectionColors = []; - // local user - if ( - locallySelectedIds[element.id] && - !isSelectedViaGroup(appState, element) - ) { - selectionColors.push(selectionColor); - } - // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { - selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), - ); - } + const selections: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }[] = []; - if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, true); - acc.push({ - angle: element.angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], - cx, - cy, - activeEmbeddable: - appState.activeEmbeddable?.element === element && - appState.activeEmbeddable.state === "active", - }); - } - return acc; - }, - [], - ); + for (const element of elementsMap.values()) { + const selectionColors = []; + // local user + if ( + locallySelectedIds.has(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(selectionColor); + } + // remote users + if (renderConfig.remoteSelectedElementIds[element.id]) { + selectionColors.push( + ...renderConfig.remoteSelectedElementIds[element.id].map( + (socketId: string) => { + const background = getClientColor(socketId); + return background; + }, + ), + ); + } + + if (selectionColors.length) { + const [elementX1, elementY1, elementX2, elementY2, cx, cy] = + getElementAbsoluteCoords(element, true); + selections.push({ + angle: element.angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + dashed: !!renderConfig.remoteSelectedElementIds[element.id], + cx, + cy, + activeEmbeddable: + appState.activeEmbeddable?.element === element && + appState.activeEmbeddable.state === "active", + }); + } + } const addSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); + const groupElements = getElementsInGroup(elementsMap, groupId); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(groupElements); selections.push({ @@ -870,7 +857,7 @@ const _renderInteractiveScene = ({ let scrollBars; if (renderConfig.renderScrollbars) { scrollBars = getScrollBars( - elements, + elementsMap, normalizedWidth, normalizedHeight, appState, @@ -897,14 +884,14 @@ const _renderInteractiveScene = ({ return { scrollBars, atLeastOneVisibleElement: visibleElements.length > 0, - elements, + elementsMap, }; }; const _renderStaticScene = ({ canvas, rc, - elements, + elementsMap, visibleElements, scale, appState, @@ -965,7 +952,7 @@ const _renderStaticScene = ({ // Paint visible elements visibleElements - .filter((el) => !isIframeLikeOrItsLabel(el)) + .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { try { const frameId = element.frameId || appState.frameToHighlight?.id; @@ -977,16 +964,30 @@ const _renderStaticScene = ({ ) { context.save(); - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, elementsMap, appState); // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elements, appState)) { + if (frame && isElementInFrame(element, elementsMap, appState)) { frameClip(frame, context, renderConfig, appState); } - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); context.restore(); } else { - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); } if (!isExporting) { renderLinkIcon(element, context, appState); @@ -998,11 +999,18 @@ const _renderStaticScene = ({ // render embeddables on top visibleElements - .filter((el) => isIframeLikeOrItsLabel(el)) + .filter((el) => isIframeLikeElement(el)) .forEach((element) => { try { const render = () => { - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); if ( isIframeLikeElement(element) && @@ -1014,7 +1022,14 @@ const _renderStaticScene = ({ element.height ) { const label = createPlaceholderEmbeddableLabel(element); - renderElement(label, rc, context, renderConfig, appState); + renderElement( + label, + elementsMap, + rc, + context, + renderConfig, + appState, + ); } if (!isExporting) { renderLinkIcon(element, context, appState); @@ -1032,9 +1047,9 @@ const _renderStaticScene = ({ ) { context.save(); - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, elementsMap, appState); - if (frame && isElementInFrame(element, elements, appState)) { + if (frame && isElementInFrame(element, elementsMap, appState)) { frameClip(frame, context, renderConfig, appState); } render(); @@ -1448,6 +1463,7 @@ const renderLinkIcon = ( // This should be only called for exporting purposes export const renderSceneToSvg = ( elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, @@ -1459,12 +1475,13 @@ export const renderSceneToSvg = ( // render elements elements - .filter((el) => !isIframeLikeOrItsLabel(el)) + .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { if (!element.isDeleted) { try { renderElementToSvg( element, + elementsMap, rsvg, svgRoot, files, @@ -1486,6 +1503,7 @@ export const renderSceneToSvg = ( try { renderElementToSvg( element, + elementsMap, rsvg, svgRoot, files, diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index 05dddadc4..1a97c06e0 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -1,5 +1,6 @@ import { isTextElement, refreshTextDimensions } from "../element"; import { newElementWith } from "../element/mutateElement"; +import { getContainerElement } from "../element/textElement"; import { isBoundToContainer } from "../element/typeChecks"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { getFontString } from "../utils"; @@ -57,7 +58,13 @@ export class Fonts { ShapeCache.delete(element); didUpdate = true; return newElementWith(element, { - ...refreshTextDimensions(element), + ...refreshTextDimensions( + element, + getContainerElement( + element, + this.scene.getElementsMapIncludingDeleted(), + ), + ), }); } return element; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 152224951..1593d6d2e 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -1,10 +1,14 @@ import { isElementInViewport } from "../element/sizeHelpers"; import { isImageElement } from "../element/typeChecks"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { + NonDeletedElementsMap, + NonDeletedExcalidrawElement, +} from "../element/types"; import { cancelRender } from "../renderer/renderScene"; import { AppState } from "../types"; -import { memoize } from "../utils"; +import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; +import { RenderableElementsMap } from "./types"; export class Renderer { private scene: Scene; @@ -15,7 +19,7 @@ export class Renderer { public getRenderableElements = (() => { const getVisibleCanvasElements = ({ - elements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -24,7 +28,7 @@ export class Renderer { height, width, }: { - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: NonDeletedElementsMap; zoom: AppState["zoom"]; offsetLeft: AppState["offsetLeft"]; offsetTop: AppState["offsetTop"]; @@ -33,43 +37,55 @@ export class Renderer { height: AppState["height"]; width: AppState["width"]; }): readonly NonDeletedExcalidrawElement[] => { - return elements.filter((element) => - isElementInViewport(element, width, height, { - zoom, - offsetLeft, - offsetTop, - scrollX, - scrollY, - }), - ); + const visibleElements: NonDeletedExcalidrawElement[] = []; + for (const element of elementsMap.values()) { + if ( + isElementInViewport(element, width, height, { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }) + ) { + visibleElements.push(element); + } + } + return visibleElements; }; - const getCanvasElements = ({ - editingElement, + const getRenderableElements = ({ elements, + editingElement, pendingImageElementId, }: { elements: readonly NonDeletedExcalidrawElement[]; editingElement: AppState["editingElement"]; pendingImageElementId: AppState["pendingImageElementId"]; }) => { - return elements.filter((element) => { + const elementsMap = toBrandedType(new Map()); + + for (const element of elements) { if (isImageElement(element)) { if ( // => not placed on canvas yet (but in elements array) pendingImageElementId === element.id ) { - return false; + continue; } } + // we don't want to render text element that's being currently edited // (it's rendered on remote only) - return ( + if ( !editingElement || editingElement.type !== "text" || element.id !== editingElement.id - ); - }); + ) { + elementsMap.set(element.id, element); + } + } + return elementsMap; }; return memoize( @@ -100,14 +116,14 @@ export class Renderer { }) => { const elements = this.scene.getNonDeletedElements(); - const canvasElements = getCanvasElements({ + const elementsMap = getRenderableElements({ elements, editingElement, pendingImageElementId, }); const visibleElements = getVisibleCanvasElements({ - elements: canvasElements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -117,7 +133,7 @@ export class Renderer { width, }); - return { canvasElements, visibleElements }; + return { elementsMap, visibleElements }; }, ); })(); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 814638e7e..326f98c7f 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -3,14 +3,18 @@ import { NonDeletedExcalidrawElement, NonDeleted, ExcalidrawFrameLikeElement, + ElementsMapOrArray, + NonDeletedElementsMap, + SceneElementsMap, } from "../element/types"; -import { getNonDeletedElements, isNonDeletedElement } from "../element"; +import { isNonDeletedElement } from "../element"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isFrameLikeElement } from "../element/typeChecks"; import { getSelectedElements } from "./selection"; import { AppState } from "../types"; import { Assert, SameType } from "../utility-types"; import { randomInteger } from "../random"; +import { toBrandedType } from "../utils"; type ElementIdKey = InstanceType["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; @@ -20,6 +24,20 @@ type SceneStateCallbackRemover = () => void; type SelectionHash = string & { __brand: "selectionHash" }; +const getNonDeletedElements = ( + allElements: readonly T[], +) => { + const elementsMap = new Map() as NonDeletedElementsMap; + const elements: T[] = []; + for (const element of allElements) { + if (!element.isDeleted) { + elements.push(element as NonDeleted); + elementsMap.set(element.id, element as NonDeletedExcalidrawElement); + } + } + return { elementsMap, elements }; +}; + const hashSelectionOpts = ( opts: Parameters["getSelectedElements"]>[0], ) => { @@ -102,11 +120,13 @@ class Scene { private callbacks: Set = new Set(); private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; + private nonDeletedElementsMap: NonDeletedElementsMap = + new Map() as NonDeletedElementsMap; private elements: readonly ExcalidrawElement[] = []; private nonDeletedFramesLikes: readonly NonDeleted[] = []; private frames: readonly ExcalidrawFrameLikeElement[] = []; - private elementsMap = new Map(); + private elementsMap = toBrandedType(new Map()); private selectedElementsCache: { selectedElementIds: AppState["selectedElementIds"] | null; elements: readonly NonDeletedExcalidrawElement[] | null; @@ -118,6 +138,14 @@ class Scene { }; private versionNonce: number | undefined; + getElementsMapIncludingDeleted() { + return this.elementsMap; + } + + getNonDeletedElementsMap() { + return this.nonDeletedElementsMap; + } + getElementsIncludingDeleted() { return this.elements; } @@ -138,7 +166,7 @@ class Scene { * scene state. This in effect will likely result in cache-miss, and * the cache won't be updated in this case. */ - elements?: readonly ExcalidrawElement[]; + elements?: ElementsMapOrArray; // selection-related options includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; @@ -227,23 +255,27 @@ class Scene { return didChange; } - replaceAllElements( - nextElements: readonly ExcalidrawElement[], - mapElementIds = true, - ) { - this.elements = nextElements; + replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) { + this.elements = + // ts doesn't like `Array.isArray` of `instanceof Map` + nextElements instanceof Array + ? nextElements + : Array.from(nextElements.values()); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; this.elementsMap.clear(); - nextElements.forEach((element) => { + this.elements.forEach((element) => { if (isFrameLikeElement(element)) { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); - Scene.mapElementToScene(element, this); + Scene.mapElementToScene(element, this, mapElementIds); }); - this.nonDeletedElements = getNonDeletedElements(this.elements); + const nonDeletedElements = getNonDeletedElements(this.elements); + this.nonDeletedElements = nonDeletedElements.elements; + this.nonDeletedElementsMap = nonDeletedElements.elementsMap; + this.frames = nextFrameLikes; - this.nonDeletedFramesLikes = getNonDeletedElements(this.frames); + this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; this.informMutation(); } @@ -332,6 +364,22 @@ class Scene { getElementIndex(elementId: string) { return this.elements.findIndex((element) => element.id === elementId); } + + getContainerElement = ( + element: + | (ExcalidrawElement & { + containerId: ExcalidrawElement["id"] | null; + }) + | null, + ) => { + if (!element) { + return null; + } + if (element.containerId) { + return this.getElement(element.containerId) || null; + } + return null; + }; } export default Scene; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9c357a21f..61ba8580d 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -11,7 +11,13 @@ import { getElementAbsoluteCoords, } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { cloneJSON, distance, getFontString } from "../utils"; +import { + arrayToMap, + cloneJSON, + distance, + getFontString, + toBrandedType, +} from "../utils"; import { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -37,6 +43,7 @@ import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; import Scene from "./Scene"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; +import { RenderableElementsMap } from "./types"; const SVG_EXPORT_TAG = ``; @@ -59,7 +66,7 @@ const __createSceneForElementsHack__ = ( // ids to Scene instances so that we don't override the editor elements // mapping. // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements), false); + scene.replaceAllElements(cloneJSON(elements)); return scene; }; @@ -241,10 +248,14 @@ export const exportToCanvas = async ( files, }); + const elementsMap = toBrandedType( + arrayToMap(elementsForRender), + ); + renderStaticScene({ canvas, rc: rough.canvas(canvas), - elements: elementsForRender, + elementsMap, visibleElements: elementsForRender, scale, appState: { @@ -432,22 +443,29 @@ export const exportToSvg = async ( const renderEmbeddables = opts?.renderEmbeddables ?? false; - renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, { - offsetX, - offsetY, - isExporting: true, - exportWithDarkMode, - renderEmbeddables, - frameRendering, - canvasBackgroundColor: viewBackgroundColor, - embedsValidationStatus: renderEmbeddables - ? new Map( - elementsForRender - .filter((element) => isFrameLikeElement(element)) - .map((element) => [element.id, true]), - ) - : new Map(), - }); + renderSceneToSvg( + elementsForRender, + toBrandedType(arrayToMap(elementsForRender)), + rsvg, + svgRoot, + files || {}, + { + offsetX, + offsetY, + isExporting: true, + exportWithDarkMode, + renderEmbeddables, + frameRendering, + canvasBackgroundColor: viewBackgroundColor, + embedsValidationStatus: renderEmbeddables + ? new Map( + elementsForRender + .filter((element) => isFrameLikeElement(element)) + .map((element) => [element.id, true]), + ) + : new Map(), + }, + ); tempScene.destroy(); diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts index 1d93f688f..14009588b 100644 --- a/packages/excalidraw/scene/scrollbars.ts +++ b/packages/excalidraw/scene/scrollbars.ts @@ -1,7 +1,6 @@ -import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element"; import { InteractiveCanvasAppState } from "../types"; -import { ScrollBars } from "./types"; +import { RenderableElementsMap, ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; @@ -10,12 +9,12 @@ export const SCROLLBAR_WIDTH = 6; export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; export const getScrollBars = ( - elements: readonly ExcalidrawElement[], + elements: RenderableElementsMap, viewportWidth: number, viewportHeight: number, appState: InteractiveCanvasAppState, ): ScrollBars => { - if (elements.length === 0) { + if (!elements.size) { return { horizontal: null, vertical: null, diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 7a620155f..ae021f6aa 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -1,4 +1,5 @@ import { + ElementsMapOrArray, ExcalidrawElement, NonDeletedExcalidrawElement, } from "../element/types"; @@ -166,26 +167,28 @@ export const getCommonAttributeOfSelectedElements = ( }; export const getSelectedElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, opts?: { includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; }, ) => { - const selectedElements = elements.filter((element) => { + const selectedElements: ExcalidrawElement[] = []; + for (const element of elements.values()) { if (appState.selectedElementIds[element.id]) { - return element; + selectedElements.push(element); + continue; } if ( opts?.includeBoundTextElement && isBoundToContainer(element) && appState.selectedElementIds[element?.containerId] ) { - return element; + selectedElements.push(element); + continue; } - return null; - }); + } if (opts?.includeElementsInFrames) { const elementsToInclude: ExcalidrawElement[] = []; @@ -205,7 +208,7 @@ export const getSelectedElements = ( }; export const getTargetElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, ) => appState.editingElement diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 57a52fbd4..957b080b3 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -2,6 +2,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { ExcalidrawTextElement, + NonDeletedElementsMap, NonDeletedExcalidrawElement, } from "../element/types"; import { @@ -12,6 +13,10 @@ import { InteractiveCanvasAppState, StaticCanvasAppState, } from "../types"; +import { MakeBrand } from "../utility-types"; + +export type RenderableElementsMap = NonDeletedElementsMap & + MakeBrand<"RenderableElementsMap">; export type StaticCanvasRenderConfig = { canvasBackgroundColor: AppState["viewBackgroundColor"]; @@ -53,14 +58,14 @@ export type InteractiveCanvasRenderConfig = { export type RenderInteractiveSceneCallback = { atLeastOneVisibleElement: boolean; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; scrollBars?: ScrollBars; }; export type StaticSceneRenderConfig = { canvas: HTMLCanvasElement; rc: RoughCanvas; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; scale: number; appState: StaticCanvasAppState; @@ -69,7 +74,7 @@ export type StaticSceneRenderConfig = { export type InteractiveSceneRenderConfig = { canvas: HTMLCanvasElement | null; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; scale: number; diff --git a/packages/excalidraw/utility-types.ts b/packages/excalidraw/utility-types.ts index 860d818ef..576769634 100644 --- a/packages/excalidraw/utility-types.ts +++ b/packages/excalidraw/utility-types.ts @@ -54,3 +54,11 @@ export type Assert = T; export type NestedKeyOf = K extends keyof T & (string | number) ? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf}` : never) : never; + +export type SetLike = Set | T[]; +export type ReadonlySetLike = ReadonlySet | readonly T[]; + +export type MakeBrand = { + /** @private using ~ to sort last in intellisense */ + [K in `~brand~${T}`]: T; +}; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 4630c5bce..47fa52311 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -650,8 +650,11 @@ export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now()); * or array of ids (strings), into a Map, keyd by `id`. */ export const arrayToMap = ( - items: readonly T[], + items: readonly T[] | Map, ) => { + if (items instanceof Map) { + return items; + } return items.reduce((acc: Map, element) => { acc.set(typeof element === "string" ? element : element.id, element); return acc; @@ -1050,3 +1053,40 @@ export function getSvgPathFromStroke(points: number[][], closed = true) { export const normalizeEOL = (str: string) => { return str.replace(/\r?\n|\r/g, "\n"); }; + +// ----------------------------------------------------------------------------- +type HasBrand = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T]: K extends `~brand${infer _}` ? true : never; +}[keyof T]; + +type RemoveAllBrands = HasBrand extends true + ? { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K]; + } + : never; + +// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940 +// currently does not cover all types (e.g. tuples, promises...) +type Unbrand = T extends Map + ? Map + : T extends Set + ? Set + : T extends Array + ? Array + : RemoveAllBrands; + +/** + * Makes type into a branded type, ensuring that value is assignable to + * the base ubranded type. Optionally you can explicitly supply current value + * type to combine both (useful for composite branded types. Make sure you + * compose branded types which are not composite themselves.) + */ +export const toBrandedType = ( + value: Unbrand, +) => { + return value as CurrentType & BrandedType; +}; + +// ----------------------------------------------------------------------------- From c6fdac131b06c2d542a7068d1798f8ac83a41cfd Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 22 Jan 2024 17:01:00 +0530 Subject: [PATCH 042/112] ci: add the workspace ignore check to install actions as dependency for auto release (#7593) --- .github/workflows/autorelease-excalidraw.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index 4eaeb11f1..5ff5690eb 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -23,5 +23,5 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Auto release run: | - yarn add @actions/core + yarn add @actions/core -W yarn autorelease From 89bd6181f29c783a59ad7943bb2a85f829dd9d49 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 22 Jan 2024 17:23:00 +0100 Subject: [PATCH 043/112] fix: revert `mapElementIds` flag removal (#7594) --- packages/excalidraw/scene/export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 61ba8580d..9f1f12a22 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -66,7 +66,7 @@ const __createSceneForElementsHack__ = ( // ids to Scene instances so that we don't override the editor elements // mapping. // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements)); + scene.replaceAllElements(cloneJSON(elements), false); return scene; }; From f3f82171252af7407fe1e8ab6790a72bbbd14477 Mon Sep 17 00:00:00 2001 From: halocean96 <146062795+halocean96@users.noreply.github.com> Date: Tue, 23 Jan 2024 23:50:51 +0900 Subject: [PATCH 044/112] docs: toggleSidebar api fix (#7575) --- .../docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index c27e96146..ffff19fb0 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -37,7 +37,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | [setActiveTool](#setactivetool) | `function` | This API can be used to set the active tool | | [setCursor](#setcursor) | `function` | This API can be used to set customise the mouse cursor on the canvas | | [resetCursor](#resetcursor) | `function` | This API can be used to reset to default mouse cursor on the canvas | -| [toggleMenu](#togglemenu) | `function` | Toggles specific menus on/off | +| [toggleSidebar](#toggleSidebar) | `function` | Toggles specific sidebar on/off | | [onChange](#onChange) | `function` | Subscribes to change events | | [onPointerDown](#onPointerDown) | `function` | Subscribes to `pointerdown` events | | [onPointerUp](#onPointerUp) | `function` | Subscribes to `pointerup` events | From 4f0a2a9593520111614bf84d95c4f35b97e82453 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 24 Jan 2024 17:07:54 +0530 Subject: [PATCH 045/112] docs: add next js with app router example (#7552) * move the existing example to with-script-in-browser * Add example with next js app router * disable ssr for excalidraw client comp * typo * update output dir * don't include nextjs example in tsconfig * remove meta.json * lint * remove example.ts * port * move the examples outside packages and use the deps as workspaces in examples * update gitignore * fix example * update path of build dir * fix * fix scripts * try local path * fix * update commands * fix * fix * fix script * skip ts * disable ts * add vercel.json * install * update tsconfig * fix lint * remove console.log * lets see if this works * revert * remove ts nocheck * add types and some utils in nextjs example * fix types * updatw example and remove nextjs dynamic syntax so we don't import excal twice * move both examples to workspaces and create generic example to be used by browser and next js both * copy the static assets to nextjs * fix ts config * render custom menu items * fix custom footer * fix types in browser example * use regular imports for importing excal and import it using dynamic next js in app router instead * Add example for pages router * fix css discrepancies * fix css * configure output dir * fix * fix css * rename to with-nextjs * move components to examples/excalidraw/components --- .gitignore | 1 + .../excalidraw/components}/App.scss | 21 +- .../excalidraw/components}/App.tsx | 301 +++++++++-------- .../excalidraw/components}/CustomFooter.tsx | 27 +- .../excalidraw/components/MobileFooter.tsx | 27 ++ .../components}/sidebar/ExampleSidebar.scss | 0 .../components}/sidebar/ExampleSidebar.tsx | 5 +- .../excalidraw}/initialData.tsx | 4 +- examples/excalidraw/package.json | 13 + examples/excalidraw/tsconfig.json | 3 + examples/excalidraw/utils.ts | 146 ++++++++ examples/excalidraw/with-nextjs/.gitignore | 36 ++ examples/excalidraw/with-nextjs/README.md | 36 ++ .../excalidraw/with-nextjs/next.config.js | 12 + examples/excalidraw/with-nextjs/package.json | 25 ++ .../with-nextjs}/public/images/doremon.png | Bin .../with-nextjs}/public/images/excalibot.png | Bin .../with-nextjs}/public/images/pika.jpeg | Bin .../with-nextjs}/public/images/rocket.jpeg | Bin .../with-nextjs/src/app/favicon.ico | Bin 0 -> 25931 bytes .../excalidraw/with-nextjs/src/app/layout.tsx | 11 + .../excalidraw/with-nextjs/src/app/page.tsx | 23 ++ .../excalidraw/with-nextjs/src/common.scss | 15 + .../with-nextjs/src/excalidrawWrapper.tsx | 22 ++ .../src/pages/excalidraw-in-pages.tsx | 22 ++ examples/excalidraw/with-nextjs/tsconfig.json | 28 ++ examples/excalidraw/with-nextjs/vercel.json | 3 + examples/excalidraw/with-nextjs/yarn.lock | 252 ++++++++++++++ .../with-script-in-browser}/index.html | 10 +- .../with-script-in-browser/index.tsx | 28 ++ .../with-script-in-browser/package.json | 19 ++ .../public/images/doremon.png | Bin 0 -> 201946 bytes .../public/images/excalibot.png | Bin 0 -> 30330 bytes .../public/images/pika.jpeg | Bin 0 -> 6250 bytes .../public/images/rocket.jpeg | Bin 0 -> 40368 bytes .../with-script-in-browser}/vercel.json | 2 +- .../with-script-in-browser/vite.config.mts | 11 + examples/excalidraw/yarn.lock | 313 ++++++++++++++++++ package.json | 4 +- packages/excalidraw/.gitignore | 2 - packages/excalidraw/components/App.tsx | 5 +- packages/excalidraw/constants.ts | 1 - packages/excalidraw/example/MobileFooter.tsx | 20 -- packages/excalidraw/example/index.tsx | 17 - packages/excalidraw/index.tsx | 9 +- packages/excalidraw/renderer/renderScene.ts | 1 - packages/excalidraw/tsconfig.json | 2 +- packages/excalidraw/vite.config.mts | 15 - scripts/buildExample.mjs | 7 +- tsconfig.json | 2 +- yarn.lock | 159 ++++++++- 51 files changed, 1431 insertions(+), 229 deletions(-) rename {packages/excalidraw/example => examples/excalidraw/components}/App.scss (83%) rename {packages/excalidraw/example => examples/excalidraw/components}/App.tsx (83%) rename {packages/excalidraw/example => examples/excalidraw/components}/CustomFooter.tsx (79%) create mode 100644 examples/excalidraw/components/MobileFooter.tsx rename {packages/excalidraw/example => examples/excalidraw/components}/sidebar/ExampleSidebar.scss (100%) rename {packages/excalidraw/example => examples/excalidraw/components}/sidebar/ExampleSidebar.tsx (90%) rename {packages/excalidraw/example => examples/excalidraw}/initialData.tsx (99%) create mode 100644 examples/excalidraw/package.json create mode 100644 examples/excalidraw/tsconfig.json create mode 100644 examples/excalidraw/utils.ts create mode 100644 examples/excalidraw/with-nextjs/.gitignore create mode 100644 examples/excalidraw/with-nextjs/README.md create mode 100644 examples/excalidraw/with-nextjs/next.config.js create mode 100644 examples/excalidraw/with-nextjs/package.json rename {packages/excalidraw/example => examples/excalidraw/with-nextjs}/public/images/doremon.png (100%) rename {packages/excalidraw/example => examples/excalidraw/with-nextjs}/public/images/excalibot.png (100%) rename {packages/excalidraw/example => examples/excalidraw/with-nextjs}/public/images/pika.jpeg (100%) rename {packages/excalidraw/example => examples/excalidraw/with-nextjs}/public/images/rocket.jpeg (100%) create mode 100644 examples/excalidraw/with-nextjs/src/app/favicon.ico create mode 100644 examples/excalidraw/with-nextjs/src/app/layout.tsx create mode 100644 examples/excalidraw/with-nextjs/src/app/page.tsx create mode 100644 examples/excalidraw/with-nextjs/src/common.scss create mode 100644 examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx create mode 100644 examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx create mode 100644 examples/excalidraw/with-nextjs/tsconfig.json create mode 100644 examples/excalidraw/with-nextjs/vercel.json create mode 100644 examples/excalidraw/with-nextjs/yarn.lock rename {packages/excalidraw/example/public => examples/excalidraw/with-script-in-browser}/index.html (67%) create mode 100644 examples/excalidraw/with-script-in-browser/index.tsx create mode 100644 examples/excalidraw/with-script-in-browser/package.json create mode 100644 examples/excalidraw/with-script-in-browser/public/images/doremon.png create mode 100644 examples/excalidraw/with-script-in-browser/public/images/excalibot.png create mode 100644 examples/excalidraw/with-script-in-browser/public/images/pika.jpeg create mode 100644 examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg rename {packages/excalidraw => examples/excalidraw/with-script-in-browser}/vercel.json (50%) create mode 100644 examples/excalidraw/with-script-in-browser/vite.config.mts create mode 100644 examples/excalidraw/yarn.lock delete mode 100644 packages/excalidraw/example/MobileFooter.tsx delete mode 100644 packages/excalidraw/example/index.tsx delete mode 100644 packages/excalidraw/vite.config.mts diff --git a/.gitignore b/.gitignore index 17e3e7dcf..21d2730a2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ packages/excalidraw/types coverage dev-dist html +examples/**/bundle.* \ No newline at end of file diff --git a/packages/excalidraw/example/App.scss b/examples/excalidraw/components/App.scss similarity index 83% rename from packages/excalidraw/example/App.scss rename to examples/excalidraw/components/App.scss index 7f37540d8..e41a77ccc 100644 --- a/packages/excalidraw/example/App.scss +++ b/examples/excalidraw/components/App.scss @@ -15,14 +15,23 @@ border-radius: 50%; } } + .app-title { + margin-block-start: 0.83em; + margin-block-end: 0.83em; + } } -.button-wrapper button { - z-index: 1; - height: 40px; - max-width: 200px; - margin: 10px; - padding: 5px; +.button-wrapper { + input[type="checkbox"] { + margin: 5px; + } + button { + z-index: 1; + height: 40px; + max-width: 200px; + margin: 10px; + padding: 5px; + } } .excalidraw .App-menu_top .buttonList { diff --git a/packages/excalidraw/example/App.tsx b/examples/excalidraw/components/App.tsx similarity index 83% rename from packages/excalidraw/example/App.tsx rename to examples/excalidraw/components/App.tsx index 50dc5b9a3..eea0da6ca 100644 --- a/packages/excalidraw/example/App.tsx +++ b/examples/excalidraw/components/App.tsx @@ -1,15 +1,30 @@ +import React, { + useEffect, + useState, + useRef, + useCallback, + Children, + cloneElement, +} from "react"; import ExampleSidebar from "./sidebar/ExampleSidebar"; -import type * as TExcalidraw from "../index"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; -import "./App.scss"; -import initialData from "./initialData"; import { nanoid } from "nanoid"; -import { resolvablePromise, ResolvablePromise } from "../utils"; -import { EVENT, ROUNDNESS } from "../constants"; -import { distance2d } from "../math"; -import { fileOpen } from "../data/filesystem"; -import { loadSceneOrLibraryFromBlob } from "../../utils"; + +import { + resolvablePromise, + ResolvablePromise, + distance2d, + fileOpen, + withBatchedUpdates, + withBatchedUpdatesThrottled, +} from "../utils"; + +import CustomFooter from "./CustomFooter"; +import MobileFooter from "./MobileFooter"; +import initialData from "../initialData"; + import type { AppState, BinaryFileData, @@ -18,19 +33,14 @@ import type { Gesture, LibraryItems, PointerDownState as ExcalidrawPointerDownState, -} from "../types"; -import type { NonDeletedExcalidrawElement, Theme } from "../element/types"; -import { ImportedLibraryData } from "../data/types"; -import CustomFooter from "./CustomFooter"; -import MobileFooter from "./MobileFooter"; -import { KEYS } from "../keys"; -import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +} from "@excalidraw/excalidraw/dist/excalidraw/types"; +import type { + NonDeletedExcalidrawElement, + Theme, +} from "@excalidraw/excalidraw/dist/excalidraw/element/types"; +import type { ImportedLibraryData } from "@excalidraw/excalidraw/dist/excalidraw/data/types"; -declare global { - interface Window { - ExcalidrawLib: typeof TExcalidraw; - } -} +import "./App.scss"; type Comment = { x: number; @@ -51,31 +61,6 @@ type PointerDownState = { }; }; -const { useEffect, useState, useRef, useCallback } = window.React; - -// This is so that we use the bundled excalidraw.development.js file instead -// of the actual source code -const { - exportToCanvas, - exportToSvg, - exportToBlob, - exportToClipboard, - Excalidraw, - useHandleLibrary, - MIME_TYPES, - sceneCoordsToViewportCoords, - viewportCoordsToSceneCoords, - restoreElements, - Sidebar, - Footer, - WelcomeScreen, - MainMenu, - LiveCollaborationTrigger, - convertToExcalidrawElements, - TTDDialog, - TTDDialogTrigger, -} = window.ExcalidrawLib; - const COMMENT_ICON_DIMENSION = 32; const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_WIDTH = 150; @@ -84,8 +69,38 @@ export interface AppProps { appTitle: string; useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; customArgs?: any[]; + children: React.ReactNode; + excalidrawLib: typeof TExcalidraw; } -export default function App({ appTitle, useCustom, customArgs }: AppProps) { + +export default function App({ + appTitle, + useCustom, + customArgs, + children, + excalidrawLib, +}: AppProps) { + const { + exportToCanvas, + exportToSvg, + exportToBlob, + exportToClipboard, + useHandleLibrary, + MIME_TYPES, + sceneCoordsToViewportCoords, + viewportCoordsToSceneCoords, + restoreElements, + Sidebar, + Footer, + WelcomeScreen, + MainMenu, + LiveCollaborationTrigger, + convertToExcalidrawElements, + TTDDialog, + TTDDialogTrigger, + ROUNDNESS, + loadSceneOrLibraryFromBlob, + } = excalidrawLib; const appRef = useRef(null); const [viewModeEnabled, setViewModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false); @@ -147,8 +162,105 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { }; }; fetchData(); - }, [excalidrawAPI]); + }, [excalidrawAPI, convertToExcalidrawElements, MIME_TYPES]); + const renderExcalidraw = (children: React.ReactNode) => { + const Excalidraw: any = Children.toArray(children).find( + (child) => + React.isValidElement(child) && + typeof child.type !== "string" && + //@ts-ignore + child.type.displayName === "Excalidraw", + ); + if (!Excalidraw) { + return; + } + const newElement = cloneElement( + Excalidraw, + { + excalidrawAPI: (api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api), + initialData: initialStatePromiseRef.current.promise, + onChange: ( + elements: NonDeletedExcalidrawElement[], + state: AppState, + ) => { + console.info("Elements :", elements, "State : ", state); + }, + onPointerUpdate: (payload: { + pointer: { x: number; y: number }; + button: "down" | "up"; + pointersMap: Gesture["pointers"]; + }) => setPointerData(payload), + viewModeEnabled, + zenModeEnabled, + gridModeEnabled, + theme, + name: "Custom name of drawing", + UIOptions: { + canvasActions: { + loadScene: false, + }, + tools: { image: !disableImageTool }, + }, + renderTopRightUI, + onLinkOpen, + onPointerDown, + onScrollChange: rerenderCommentIcons, + validateEmbeddable: true, + }, + <> + {excalidrawAPI && ( +
    + +
    + )} + + + + + Tab one! + Tab two! + + One + Two + + + + + Toggle Custom Sidebar + + {renderMenu()} + {excalidrawAPI && ( + 😀}> + Text to diagram + + )} + { + console.info("submit"); + // sleep for 2s + await new Promise((resolve) => setTimeout(resolve, 2000)); + throw new Error("error, go away now"); + // return "dummy"; + }} + /> + , + ); + return newElement; + }; const renderTopRightUI = (isMobile: boolean) => { return ( <> @@ -332,8 +444,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { pointerDownState: PointerDownState, ) => { return withBatchedUpdates((event) => { - window.removeEventListener(EVENT.POINTER_MOVE, pointerDownState.onMove); - window.removeEventListener(EVENT.POINTER_UP, pointerDownState.onUp); + window.removeEventListener("pointermove", pointerDownState.onMove); + window.removeEventListener("pointerup", pointerDownState.onUp); excalidrawAPI?.setActiveTool({ type: "selection" }); const distance = distance2d( pointerDownState.x, @@ -397,8 +509,8 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { onPointerMoveFromPointerDownHandler(pointerDownState); const onPointerUp = onPointerUpFromPointerDownHandler(pointerDownState); - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.addEventListener(EVENT.POINTER_UP, onPointerUp); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); pointerDownState.onMove = onPointerMove; pointerDownState.onUp = onPointerUp; @@ -490,7 +602,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { }} onBlur={saveComment} onKeyDown={(event) => { - if (!event.shiftKey && event.key === KEYS.ENTER) { + if (!event.shiftKey && event.key === "Enter") { event.preventDefault(); saveComment(); } @@ -523,7 +635,12 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { - {excalidrawAPI && } + {excalidrawAPI && ( + + )} ); }; @@ -672,83 +789,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
  • - - setExcalidrawAPI(api) - } - initialData={initialStatePromiseRef.current.promise} - onChange={(elements, state) => { - // console.info("Elements :", elements, "State : ", state); - }} - onPointerUpdate={(payload: { - pointer: { x: number; y: number }; - button: "down" | "up"; - pointersMap: Gesture["pointers"]; - }) => setPointerData(payload)} - viewModeEnabled={viewModeEnabled} - zenModeEnabled={zenModeEnabled} - gridModeEnabled={gridModeEnabled} - theme={theme} - name="Custom name of drawing" - UIOptions={{ - canvasActions: { - loadScene: false, - }, - tools: { image: !disableImageTool }, - }} - renderTopRightUI={renderTopRightUI} - onLinkOpen={onLinkOpen} - onPointerDown={onPointerDown} - onScrollChange={rerenderCommentIcons} - // allow all urls - validateEmbeddable={true} - > - {excalidrawAPI && ( -
    - -
    - )} - - - - - Tab one! - Tab two! - - One - Two - - - - - Toggle Custom Sidebar - - {renderMenu()} - {excalidrawAPI && ( - 😀}> - Text to diagram - - )} - { - console.info("submit"); - // sleep for 2s - await new Promise((resolve) => setTimeout(resolve, 2000)); - throw new Error("error, go away now"); - // return "dummy"; - }} - /> -
    + {renderExcalidraw(children)} {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {comment && renderComment()}
    diff --git a/packages/excalidraw/example/CustomFooter.tsx b/examples/excalidraw/components/CustomFooter.tsx similarity index 79% rename from packages/excalidraw/example/CustomFooter.tsx rename to examples/excalidraw/components/CustomFooter.tsx index c4ff5b642..30d51ecf0 100644 --- a/packages/excalidraw/example/CustomFooter.tsx +++ b/examples/excalidraw/components/CustomFooter.tsx @@ -1,6 +1,6 @@ -import type { ExcalidrawImperativeAPI } from "../types"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; -const { Button, MIME_TYPES } = window.ExcalidrawLib; const COMMENT_SVG = ( ); + const CustomFooter = ({ excalidrawAPI, + excalidrawLib, }: { excalidrawAPI: ExcalidrawImperativeAPI; + excalidrawLib: typeof TExcalidraw; }) => { + const { Button, MIME_TYPES } = excalidrawLib; + return ( <> - - + ); }; diff --git a/examples/excalidraw/components/MobileFooter.tsx b/examples/excalidraw/components/MobileFooter.tsx new file mode 100644 index 000000000..7ab62b918 --- /dev/null +++ b/examples/excalidraw/components/MobileFooter.tsx @@ -0,0 +1,27 @@ +import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types"; +import CustomFooter from "./CustomFooter"; +import type * as TExcalidraw from "@excalidraw/excalidraw"; + +const MobileFooter = ({ + excalidrawAPI, + excalidrawLib, +}: { + excalidrawAPI: ExcalidrawImperativeAPI; + excalidrawLib: typeof TExcalidraw; +}) => { + const { useDevice, Footer } = excalidrawLib; + + const device = useDevice(); + if (device.editor.isMobile) { + return ( +
    + +
    + ); + } + return null; +}; +export default MobileFooter; diff --git a/packages/excalidraw/example/sidebar/ExampleSidebar.scss b/examples/excalidraw/components/sidebar/ExampleSidebar.scss similarity index 100% rename from packages/excalidraw/example/sidebar/ExampleSidebar.scss rename to examples/excalidraw/components/sidebar/ExampleSidebar.scss diff --git a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx b/examples/excalidraw/components/sidebar/ExampleSidebar.tsx similarity index 90% rename from packages/excalidraw/example/sidebar/ExampleSidebar.tsx rename to examples/excalidraw/components/sidebar/ExampleSidebar.tsx index a6e1b6475..8b475f16f 100644 --- a/packages/excalidraw/example/sidebar/ExampleSidebar.tsx +++ b/examples/excalidraw/components/sidebar/ExampleSidebar.tsx @@ -1,9 +1,8 @@ +import { useState } from "react"; import "./ExampleSidebar.scss"; -const React = window.React; - export default function Sidebar({ children }: { children: React.ReactNode }) { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = useState(false); return ( <> diff --git a/packages/excalidraw/example/initialData.tsx b/examples/excalidraw/initialData.tsx similarity index 99% rename from packages/excalidraw/example/initialData.tsx rename to examples/excalidraw/initialData.tsx index 0299e4959..3cb5e7af4 100644 --- a/packages/excalidraw/example/initialData.tsx +++ b/examples/excalidraw/initialData.tsx @@ -1,5 +1,5 @@ -import type { ExcalidrawElementSkeleton } from "../data/transform"; -import type { FileId } from "../element/types"; +import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform"; +import type { FileId } from "@excalidraw/excalidraw/element/types"; const elements: ExcalidrawElementSkeleton[] = [ { diff --git a/examples/excalidraw/package.json b/examples/excalidraw/package.json new file mode 100644 index 000000000..fe48d5532 --- /dev/null +++ b/examples/excalidraw/package.json @@ -0,0 +1,13 @@ +{ + "name": "examples", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "@excalidraw/excalidraw": "*" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/examples/excalidraw/tsconfig.json b/examples/excalidraw/tsconfig.json new file mode 100644 index 000000000..41716a7dd --- /dev/null +++ b/examples/excalidraw/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig" +} diff --git a/examples/excalidraw/utils.ts b/examples/excalidraw/utils.ts new file mode 100644 index 000000000..822be29b7 --- /dev/null +++ b/examples/excalidraw/utils.ts @@ -0,0 +1,146 @@ +import { unstable_batchedUpdates } from "react-dom"; +import { fileOpen as _fileOpen } from "browser-fs-access"; +import type { MIME_TYPES } from "@excalidraw/excalidraw"; +import { AbortError } from "../../packages/excalidraw/errors"; + +type FILE_EXTENSION = Exclude; + +const INPUT_CHANGE_INTERVAL_MS = 500; + +export type ResolvablePromise = Promise & { + resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; + reject: (error: Error) => void; +}; +export const resolvablePromise = () => { + let resolve!: any; + let reject!: any; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + (promise as any).resolve = resolve; + (promise as any).reject = reject; + return promise as ResolvablePromise; +}; + +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 fileOpen = (opts: { + extensions?: FILE_EXTENSION[]; + description: string; + multiple?: M; +}): Promise => { + // an unsafe TS hack, alas not much we can do AFAIK + type RetType = M extends false | undefined ? File : File[]; + + const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => { + mimeTypes.push(MIME_TYPES[type]); + + return mimeTypes; + }, [] as string[]); + + const extensions = opts.extensions?.reduce((acc, ext) => { + if (ext === "jpg") { + return acc.concat(".jpg", ".jpeg"); + } + return acc.concat(`.${ext}`); + }, [] as string[]); + + return _fileOpen({ + description: opts.description, + extensions, + mimeTypes, + multiple: opts.multiple ?? false, + legacySetup: (resolve, reject, input) => { + const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS); + const focusHandler = () => { + checkForFile(); + document.addEventListener("keyup", scheduleRejection); + document.addEventListener("pointerup", scheduleRejection); + scheduleRejection(); + }; + const checkForFile = () => { + // this hack might not work when expecting multiple files + if (input.files?.length) { + const ret = opts.multiple ? [...input.files] : input.files[0]; + resolve(ret as RetType); + } + }; + requestAnimationFrame(() => { + window.addEventListener("focus", focusHandler); + }); + const interval = window.setInterval(() => { + checkForFile(); + }, INPUT_CHANGE_INTERVAL_MS); + return (rejectPromise) => { + clearInterval(interval); + scheduleRejection.cancel(); + window.removeEventListener("focus", focusHandler); + document.removeEventListener("keyup", scheduleRejection); + document.removeEventListener("pointerup", scheduleRejection); + if (rejectPromise) { + // so that something is shown in console if we need to debug this + console.warn("Opening the file was canceled (legacy-fs)."); + rejectPromise(new AbortError()); + } + }; + }, + }) as Promise; +}; + +export const debounce = ( + fn: (...args: T) => void, + timeout: number, +) => { + let handle = 0; + let lastArgs: T | null = null; + const ret = (...args: T) => { + lastArgs = args; + clearTimeout(handle); + handle = window.setTimeout(() => { + lastArgs = null; + fn(...args); + }, timeout); + }; + ret.flush = () => { + clearTimeout(handle); + if (lastArgs) { + const _lastArgs = lastArgs; + lastArgs = null; + fn(..._lastArgs); + } + }; + ret.cancel = () => { + lastArgs = null; + clearTimeout(handle); + }; + return ret; +}; + +export const withBatchedUpdates = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => + ((event) => { + unstable_batchedUpdates(func as TFunction, event); + }) as TFunction; + +/** + * barches React state updates and throttles the calls to a single call per + * animation frame + */ +export const withBatchedUpdatesThrottled = < + TFunction extends ((event: any) => void) | (() => void), +>( + func: Parameters["length"] extends 0 | 1 ? TFunction : never, +) => { + // @ts-ignore + return throttleRAF>(((event) => { + unstable_batchedUpdates(func, event); + }) as TFunction); +}; diff --git a/examples/excalidraw/with-nextjs/.gitignore b/examples/excalidraw/with-nextjs/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/examples/excalidraw/with-nextjs/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/excalidraw/with-nextjs/README.md b/examples/excalidraw/with-nextjs/README.md new file mode 100644 index 000000000..9e8d9b96d --- /dev/null +++ b/examples/excalidraw/with-nextjs/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3005) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/examples/excalidraw/with-nextjs/next.config.js b/examples/excalidraw/with-nextjs/next.config.js new file mode 100644 index 000000000..701438ebf --- /dev/null +++ b/examples/excalidraw/with-nextjs/next.config.js @@ -0,0 +1,12 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + distDir: "build", + typescript: { + // The ts config doesn't work with `jsx: preserve" and if updated to `react-jsx` it gets ovewritten by next js throwing ts errors hence I am ignoring build errors until this is fixed. + ignoreBuildErrors: true, + }, + // This is needed as in pages router the code for importing types throws error as its outside next js app + transpilePackages: ["../"], +}; + +module.exports = nextConfig; diff --git a/examples/excalidraw/with-nextjs/package.json b/examples/excalidraw/with-nextjs/package.json new file mode 100644 index 000000000..177952407 --- /dev/null +++ b/examples/excalidraw/with-nextjs/package.json @@ -0,0 +1,25 @@ +{ + "name": "with-nextjs", + "version": "0.1.0", + "private": true, + "scripts": { + "build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm", + "dev": "yarn build:workspace && next dev -p 3005", + "build": "yarn build:workspace && next build", + "start": "next start -p 3006", + "lint": "next lint" + }, + "dependencies": { + "@excalidraw/excalidraw": "*", + "next": "14.1", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "path2d-polyfill": "2.0.1", + "typescript": "^5" + } +} diff --git a/packages/excalidraw/example/public/images/doremon.png b/examples/excalidraw/with-nextjs/public/images/doremon.png similarity index 100% rename from packages/excalidraw/example/public/images/doremon.png rename to examples/excalidraw/with-nextjs/public/images/doremon.png diff --git a/packages/excalidraw/example/public/images/excalibot.png b/examples/excalidraw/with-nextjs/public/images/excalibot.png similarity index 100% rename from packages/excalidraw/example/public/images/excalibot.png rename to examples/excalidraw/with-nextjs/public/images/excalibot.png diff --git a/packages/excalidraw/example/public/images/pika.jpeg b/examples/excalidraw/with-nextjs/public/images/pika.jpeg similarity index 100% rename from packages/excalidraw/example/public/images/pika.jpeg rename to examples/excalidraw/with-nextjs/public/images/pika.jpeg diff --git a/packages/excalidraw/example/public/images/rocket.jpeg b/examples/excalidraw/with-nextjs/public/images/rocket.jpeg similarity index 100% rename from packages/excalidraw/example/public/images/rocket.jpeg rename to examples/excalidraw/with-nextjs/public/images/rocket.jpeg diff --git a/examples/excalidraw/with-nextjs/src/app/favicon.ico b/examples/excalidraw/with-nextjs/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/examples/excalidraw/with-nextjs/src/app/layout.tsx b/examples/excalidraw/with-nextjs/src/app/layout.tsx new file mode 100644 index 000000000..225b6038d --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/app/layout.tsx @@ -0,0 +1,11 @@ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/examples/excalidraw/with-nextjs/src/app/page.tsx b/examples/excalidraw/with-nextjs/src/app/page.tsx new file mode 100644 index 000000000..bc8c98fcf --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/app/page.tsx @@ -0,0 +1,23 @@ +import dynamic from "next/dynamic"; +import "../common.scss"; + +// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically +// with ssr false +const ExcalidrawWithClientOnly = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, +); + +export default function Page() { + return ( + <> + Switch to Pages router +

    App Router

    + + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} + + + ); +} diff --git a/examples/excalidraw/with-nextjs/src/common.scss b/examples/excalidraw/with-nextjs/src/common.scss new file mode 100644 index 000000000..1a77600a9 --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/common.scss @@ -0,0 +1,15 @@ +* { + box-sizing: border-box; + font-family: sans-serif; +} + +a { + color: #1c7ed6; + font-size: 20px; + text-decoration: none; + font-weight: 550; +} + +.page-title { + text-align: center; +} diff --git a/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx b/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx new file mode 100644 index 000000000..40af9f0cc --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/excalidrawWrapper.tsx @@ -0,0 +1,22 @@ +"use client"; +import * as excalidrawLib from "@excalidraw/excalidraw"; +import { Excalidraw } from "@excalidraw/excalidraw"; +import App from "../../components/App"; + +import "@excalidraw/excalidraw/index.css"; + +const ExcalidrawWrapper: React.FC = () => { + return ( + <> + {}} + excalidrawLib={excalidrawLib} + > + + + + ); +}; + +export default ExcalidrawWrapper; diff --git a/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx b/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx new file mode 100644 index 000000000..527a346b9 --- /dev/null +++ b/examples/excalidraw/with-nextjs/src/pages/excalidraw-in-pages.tsx @@ -0,0 +1,22 @@ +import dynamic from "next/dynamic"; +import "../common.scss"; + +// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically +// with ssr false +const Excalidraw = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, +); + +export default function Page() { + return ( + <> + Switch to App router +

    Pages Router

    + {/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */} + + + ); +} diff --git a/examples/excalidraw/with-nextjs/tsconfig.json b/examples/excalidraw/with-nextjs/tsconfig.json new file mode 100644 index 000000000..09ae73d2e --- /dev/null +++ b/examples/excalidraw/with-nextjs/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "forceConsistentCasingInFileNames": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "build/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/excalidraw/with-nextjs/vercel.json b/examples/excalidraw/with-nextjs/vercel.json new file mode 100644 index 000000000..bd885f4a5 --- /dev/null +++ b/examples/excalidraw/with-nextjs/vercel.json @@ -0,0 +1,3 @@ +{ + "outputDirectory": "build" +} diff --git a/examples/excalidraw/with-nextjs/yarn.lock b/examples/excalidraw/with-nextjs/yarn.lock new file mode 100644 index 000000000..0072235c0 --- /dev/null +++ b/examples/excalidraw/with-nextjs/yarn.lock @@ -0,0 +1,252 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@excalidraw/excalidraw@workspace:^": + version "0.17.2" + resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.17.2.tgz#9a636a1e6bb3c88c5883347d3a7e75e9cce8ab96" + integrity sha512-7pqUWD8+mPjDhF4XxG3gw4rvE2JGaLW3Vss5UZfTbITPxAtFaGEc1K081bncitnaYhUwN9ENJE0i87QB3poDwQ== + +"@next/env@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.0.4.tgz#d5cda0c4a862d70ae760e58c0cd96a8899a2e49a" + integrity sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ== + +"@next/swc-darwin-arm64@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz#27b1854c2cd04eb1d5e75081a1a792ad91526618" + integrity sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg== + +"@next/swc-darwin-x64@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz#9940c449e757d0ee50bb9e792d2600cc08a3eb3b" + integrity sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw== + +"@next/swc-linux-arm64-gnu@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz#0eafd27c8587f68ace7b4fa80695711a8434de21" + integrity sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w== + +"@next/swc-linux-arm64-musl@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz#2b0072adb213f36dada5394ea67d6e82069ae7dd" + integrity sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ== + +"@next/swc-linux-x64-gnu@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz#68c67d20ebc8e3f6ced6ff23a4ba2a679dbcec32" + integrity sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A== + +"@next/swc-linux-x64-musl@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz#67cd81b42fb2caf313f7992fcf6d978af55a1247" + integrity sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw== + +"@next/swc-win32-arm64-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz#be06585906b195d755ceda28f33c633e1443f1a3" + integrity sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w== + +"@next/swc-win32-ia32-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz#e76cabefa9f2d891599c3d85928475bd8d3f6600" + integrity sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg== + +"@next/swc-win32-x64-msvc@14.0.4": + version "14.0.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz#e74892f1a9ccf41d3bf5979ad6d3d77c07b9cba1" + integrity sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A== + +"@swc/helpers@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" + integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== + dependencies: + tslib "^2.4.0" + +"@types/node@^20": + version "20.11.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f" + integrity sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ== + dependencies: + undici-types "~5.26.4" + +"@types/prop-types@*": + version "15.7.11" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.11.tgz#2596fb352ee96a1379c657734d4b913a613ad563" + integrity sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng== + +"@types/react-dom@^18": + version "18.2.18" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" + integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^18": + version "18.2.47" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.47.tgz#85074b27ab563df01fbc3f68dc64bf7050b0af40" + integrity sha512-xquNkkOirwyCgoClNk85BjP+aqnIS+ckAJ8i37gAbDs14jfW/J23f2GItAf33oiUPQnqNMALiFeoM9Y5mbjpVQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" + integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== + +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +caniuse-lite@^1.0.30001406: + version "1.0.30001576" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001576.tgz#893be772cf8ee6056d6c1e2d07df365b9ec0a5c4" + integrity sha512-ff5BdakGe2P3SQsMsiqmt1Lc8221NR1VzHj5jXN5vBny9A6fpze94HiVV/n7XRosOlsShJcvMv5mdnpjOGCEgg== + +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +glob-to-regexp@^0.4.1: + version "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== + +graceful-fs@^4.1.2, graceful-fs@^4.2.11: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +nanoid@^3.3.6: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +next@14.0.4: + version "14.0.4" + resolved "https://registry.yarnpkg.com/next/-/next-14.0.4.tgz#bf00b6f835b20d10a5057838fa2dfced1d0d84dc" + integrity sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA== + dependencies: + "@next/env" "14.0.4" + "@swc/helpers" "0.5.2" + busboy "1.6.0" + caniuse-lite "^1.0.30001406" + graceful-fs "^4.2.11" + postcss "8.4.31" + styled-jsx "5.1.1" + watchpack "2.4.0" + optionalDependencies: + "@next/swc-darwin-arm64" "14.0.4" + "@next/swc-darwin-x64" "14.0.4" + "@next/swc-linux-arm64-gnu" "14.0.4" + "@next/swc-linux-arm64-musl" "14.0.4" + "@next/swc-linux-x64-gnu" "14.0.4" + "@next/swc-linux-x64-musl" "14.0.4" + "@next/swc-win32-arm64-msvc" "14.0.4" + "@next/swc-win32-ia32-msvc" "14.0.4" + "@next/swc-win32-x64-msvc" "14.0.4" + +path2d-polyfill@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz#24c554a738f42700d6961992bf5f1049672f2391" + integrity sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +react-dom@^18: + 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" + scheduler "^0.23.0" + +react@^18: + 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" + +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +styled-jsx@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" + integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw== + dependencies: + client-only "0.0.1" + +tslib@^2.4.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +typescript@^5: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +watchpack@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" diff --git a/packages/excalidraw/example/public/index.html b/examples/excalidraw/with-script-in-browser/index.html similarity index 67% rename from packages/excalidraw/example/public/index.html rename to examples/excalidraw/with-script-in-browser/index.html index 0fbf45e9e..a56d7f421 100644 --- a/packages/excalidraw/example/public/index.html +++ b/examples/excalidraw/with-script-in-browser/index.html @@ -13,20 +13,20 @@ window.name = "codesandbox"; -
    - - + - + diff --git a/examples/excalidraw/with-script-in-browser/index.tsx b/examples/excalidraw/with-script-in-browser/index.tsx new file mode 100644 index 000000000..e8584d7ca --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/index.tsx @@ -0,0 +1,28 @@ +import App from "../components/App"; +import React, { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import type * as TExcalidraw from "@excalidraw/excalidraw"; + +import "@excalidraw/excalidraw/index.css"; + +declare global { + interface Window { + ExcalidrawLib: typeof TExcalidraw; + } +} + +const rootElement = document.getElementById("root")!; +const root = createRoot(rootElement); +const { Excalidraw } = window.ExcalidrawLib; +root.render( + + {}} + excalidrawLib={window.ExcalidrawLib} + > + + + , +); diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json new file mode 100644 index 000000000..490b0f796 --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -0,0 +1,19 @@ +{ + "name": "with-script-in-browser", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "@excalidraw/excalidraw": "*" + }, + "devDependencies": { + "vite": "5.0.6", + "typescript": "^5" + }, + "scripts": { + "start": "yarn workspace @excalidraw/excalidraw run build:esm && vite", + "build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build", + "build:preview": "yarn build && vite preview --port 5002" + } +} diff --git a/examples/excalidraw/with-script-in-browser/public/images/doremon.png b/examples/excalidraw/with-script-in-browser/public/images/doremon.png new file mode 100644 index 0000000000000000000000000000000000000000..36208a4665fb89e44247292e92a1143a52cdf38c GIT binary patch literal 201946 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

    >rqP?iYp>X2@Ky7x^}8x-)S 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

    O1!W$4;+)P9SasnI-`V8^ZFleat5n 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­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 zk308F

    hW@ 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 N3HSdn;j;Xm_;1tNja&c# literal 0 HcmV?d00001 diff --git a/packages/excalidraw/vercel.json b/examples/excalidraw/with-script-in-browser/vercel.json similarity index 50% rename from packages/excalidraw/vercel.json rename to examples/excalidraw/with-script-in-browser/vercel.json index a262682b8..139f31ef0 100644 --- a/packages/excalidraw/vercel.json +++ b/examples/excalidraw/with-script-in-browser/vercel.json @@ -1,4 +1,4 @@ { - "outputDirectory": "example/public", + "outputDirectory": "dist", "installCommand": "yarn install" } diff --git a/examples/excalidraw/with-script-in-browser/vite.config.mts b/examples/excalidraw/with-script-in-browser/vite.config.mts new file mode 100644 index 000000000..e2e5e19ac --- /dev/null +++ b/examples/excalidraw/with-script-in-browser/vite.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + port: 3001, + // open the browser + open: true, + }, + publicDir: "public", +}); diff --git a/examples/excalidraw/yarn.lock b/examples/excalidraw/yarn.lock new file mode 100644 index 000000000..1eb584205 --- /dev/null +++ b/examples/excalidraw/yarn.lock @@ -0,0 +1,313 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz#2acd20be6d4f0458bc8c784103495ff24f13b1d3" + integrity sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g== + +"@esbuild/android-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz#b45d000017385c9051a4f03e17078abb935be220" + integrity sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q== + +"@esbuild/android-arm@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.11.tgz#f46f55414e1c3614ac682b29977792131238164c" + integrity sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw== + +"@esbuild/android-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.11.tgz#bfc01e91740b82011ef503c48f548950824922b2" + integrity sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg== + +"@esbuild/darwin-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz#533fb7f5a08c37121d82c66198263dcc1bed29bf" + integrity sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ== + +"@esbuild/darwin-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz#62f3819eff7e4ddc656b7c6815a31cf9a1e7d98e" + integrity sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g== + +"@esbuild/freebsd-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz#d478b4195aa3ca44160272dab85ef8baf4175b4a" + integrity sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA== + +"@esbuild/freebsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz#7bdcc1917409178257ca6a1a27fe06e797ec18a2" + integrity sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw== + +"@esbuild/linux-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz#58ad4ff11685fcc735d7ff4ca759ab18fcfe4545" + integrity sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg== + +"@esbuild/linux-arm@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz#ce82246d873b5534d34de1e5c1b33026f35e60e3" + integrity sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q== + +"@esbuild/linux-ia32@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz#cbae1f313209affc74b80f4390c4c35c6ab83fa4" + integrity sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA== + +"@esbuild/linux-loong64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz#5f32aead1c3ec8f4cccdb7ed08b166224d4e9121" + integrity sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg== + +"@esbuild/linux-mips64el@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz#38eecf1cbb8c36a616261de858b3c10d03419af9" + integrity sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg== + +"@esbuild/linux-ppc64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz#9c5725a94e6ec15b93195e5a6afb821628afd912" + integrity sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA== + +"@esbuild/linux-riscv64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz#2dc4486d474a2a62bbe5870522a9a600e2acb916" + integrity sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ== + +"@esbuild/linux-s390x@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz#4ad8567df48f7dd4c71ec5b1753b6f37561a65a8" + integrity sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q== + +"@esbuild/linux-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz#b7390c4d5184f203ebe7ddaedf073df82a658766" + integrity sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA== + +"@esbuild/netbsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz#d633c09492a1721377f3bccedb2d821b911e813d" + integrity sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ== + +"@esbuild/openbsd-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz#17388c76e2f01125bf831a68c03a7ffccb65d1a2" + integrity sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw== + +"@esbuild/sunos-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz#e320636f00bb9f4fdf3a80e548cb743370d41767" + integrity sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ== + +"@esbuild/win32-arm64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz#c778b45a496e90b6fc373e2a2bb072f1441fe0ee" + integrity sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ== + +"@esbuild/win32-ia32@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz#481a65fee2e5cce74ec44823e6b09ecedcc5194c" + integrity sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg== + +"@esbuild/win32-x64@0.19.11": + version "0.19.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz#a5d300008960bb39677c46bf16f53ec70d8dee04" + integrity sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw== + +"@rollup/rollup-android-arm-eabi@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.5.tgz#b752b6c88a14ccfcbdf3f48c577ccc3a7f0e66b9" + integrity sha512-idWaG8xeSRCfRq9KpRysDHJ/rEHBEXcHuJ82XY0yYFIWnLMjZv9vF/7DOq8djQ2n3Lk6+3qfSH8AqlmHlmi1MA== + +"@rollup/rollup-android-arm64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.5.tgz#33757c3a448b9ef77b6f6292d8b0ec45c87e9c1a" + integrity sha512-f14d7uhAMtsCGjAYwZGv6TwuS3IFaM4ZnGMUn3aCBgkcHAYErhV1Ad97WzBvS2o0aaDv4mVz+syiN0ElMyfBPg== + +"@rollup/rollup-darwin-arm64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.5.tgz#5234ba62665a3f443143bc8bcea9df2cc58f55fb" + integrity sha512-ndoXeLx455FffL68OIUrVr89Xu1WLzAG4n65R8roDlCoYiQcGGg6MALvs2Ap9zs7AHg8mpHtMpwC8jBBjZrT/w== + +"@rollup/rollup-darwin-x64@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.5.tgz#981256c054d3247b83313724938d606798a919d1" + integrity sha512-UmElV1OY2m/1KEEqTlIjieKfVwRg0Zwg4PLgNf0s3glAHXBN99KLpw5A5lrSYCa1Kp63czTpVll2MAqbZYIHoA== + +"@rollup/rollup-linux-arm-gnueabihf@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.5.tgz#120678a5a2b3a283a548dbb4d337f9187a793560" + integrity sha512-Q0LcU61v92tQB6ae+udZvOyZ0wfpGojtAKrrpAaIqmJ7+psq4cMIhT/9lfV6UQIpeItnq/2QDROhNLo00lOD1g== + +"@rollup/rollup-linux-arm64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.5.tgz#c99d857e2372ece544b6f60b85058ad259f64114" + integrity sha512-dkRscpM+RrR2Ee3eOQmRWFjmV/payHEOrjyq1VZegRUa5OrZJ2MAxBNs05bZuY0YCtpqETDy1Ix4i/hRqX98cA== + +"@rollup/rollup-linux-arm64-musl@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.5.tgz#3064060f568a5718c2a06858cd6e6d24f2ff8632" + integrity sha512-QaKFVOzzST2xzY4MAmiDmURagWLFh+zZtttuEnuNn19AiZ0T3fhPyjPPGwLNdiDT82ZE91hnfJsUiDwF9DClIQ== + +"@rollup/rollup-linux-riscv64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.5.tgz#987d30b5d2b992fff07d055015991a57ff55fbad" + integrity sha512-HeGqmRJuyVg6/X6MpE2ur7GbymBPS8Np0S/vQFHDmocfORT+Zt76qu+69NUoxXzGqVP1pzaY6QIi0FJWLC3OPA== + +"@rollup/rollup-linux-x64-gnu@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz#85946ee4d068bd12197aeeec2c6f679c94978a49" + integrity sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA== + +"@rollup/rollup-linux-x64-musl@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.5.tgz#fe0b20f9749a60eb1df43d20effa96c756ddcbd4" + integrity sha512-ezyFUOwldYpj7AbkwyW9AJ203peub81CaAIVvckdkyH8EvhEIoKzaMFJj0G4qYJ5sw3BpqhFrsCc30t54HV8vg== + +"@rollup/rollup-win32-arm64-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.5.tgz#422661ef0e16699a234465d15b2c1089ef963b2a" + integrity sha512-aHSsMnUw+0UETB0Hlv7B/ZHOGY5bQdwMKJSzGfDfvyhnpmVxLMGnQPGNE9wgqkLUs3+gbG1Qx02S2LLfJ5GaRQ== + +"@rollup/rollup-win32-ia32-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.5.tgz#7b73a145891c202fbcc08759248983667a035d85" + integrity sha512-AiqiLkb9KSf7Lj/o1U3SEP9Zn+5NuVKgFdRIZkvd4N0+bYrTOovVd0+LmYCPQGbocT4kvFyK+LXCDiXPBF3fyA== + +"@rollup/rollup-win32-x64-msvc@4.9.5": + version "4.9.5" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.5.tgz#10491ccf4f63c814d4149e0316541476ea603602" + integrity sha512-1q+mykKE3Vot1kaFJIDoUFv5TuW+QQVaf2FmTT9krg86pQrGStOSJJ0Zil7CFagyxDuouTepzt5Y5TVzyajOdQ== + +"@types/estree@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +esbuild@^0.19.3: + version "0.19.11" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.11.tgz#4a02dca031e768b5556606e1b468fe72e3325d96" + integrity sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.11" + "@esbuild/android-arm" "0.19.11" + "@esbuild/android-arm64" "0.19.11" + "@esbuild/android-x64" "0.19.11" + "@esbuild/darwin-arm64" "0.19.11" + "@esbuild/darwin-x64" "0.19.11" + "@esbuild/freebsd-arm64" "0.19.11" + "@esbuild/freebsd-x64" "0.19.11" + "@esbuild/linux-arm" "0.19.11" + "@esbuild/linux-arm64" "0.19.11" + "@esbuild/linux-ia32" "0.19.11" + "@esbuild/linux-loong64" "0.19.11" + "@esbuild/linux-mips64el" "0.19.11" + "@esbuild/linux-ppc64" "0.19.11" + "@esbuild/linux-riscv64" "0.19.11" + "@esbuild/linux-s390x" "0.19.11" + "@esbuild/linux-x64" "0.19.11" + "@esbuild/netbsd-x64" "0.19.11" + "@esbuild/openbsd-x64" "0.19.11" + "@esbuild/sunos-x64" "0.19.11" + "@esbuild/win32-arm64" "0.19.11" + "@esbuild/win32-ia32" "0.19.11" + "@esbuild/win32-x64" "0.19.11" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +postcss@^8.4.32: + version "8.4.33" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.33.tgz#1378e859c9f69bf6f638b990a0212f43e2aaa742" + integrity sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +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" + scheduler "^0.23.0" + +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" + +rollup@^4.2.0: + version "4.9.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.5.tgz#62999462c90f4c8b5d7c38fc7161e63b29101b05" + integrity sha512-E4vQW0H/mbNMw2yLSqJyjtkHY9dslf/p0zuT1xehNRqUTBOFMqEjguDvqhXr7N7r/4ttb2jr4T41d3dncmIgbQ== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.9.5" + "@rollup/rollup-android-arm64" "4.9.5" + "@rollup/rollup-darwin-arm64" "4.9.5" + "@rollup/rollup-darwin-x64" "4.9.5" + "@rollup/rollup-linux-arm-gnueabihf" "4.9.5" + "@rollup/rollup-linux-arm64-gnu" "4.9.5" + "@rollup/rollup-linux-arm64-musl" "4.9.5" + "@rollup/rollup-linux-riscv64-gnu" "4.9.5" + "@rollup/rollup-linux-x64-gnu" "4.9.5" + "@rollup/rollup-linux-x64-musl" "4.9.5" + "@rollup/rollup-win32-arm64-msvc" "4.9.5" + "@rollup/rollup-win32-ia32-msvc" "4.9.5" + "@rollup/rollup-win32-x64-msvc" "4.9.5" + fsevents "~2.3.2" + +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +vite@5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.6.tgz#f9e13503a4c5ccd67312c67803dec921f3bdea7c" + integrity sha512-MD3joyAEBtV7QZPl2JVVUai6zHms3YOmLR+BpMzLlX2Yzjfcc4gTgNi09d/Rua3F4EtC8zdwPU8eQYyib4vVMQ== + dependencies: + esbuild "^0.19.3" + postcss "^8.4.32" + rollup "^4.2.0" + optionalDependencies: + fsevents "~2.3.3" diff --git a/package.json b/package.json index 3154e69f2..a440c97b7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "workspaces": [ "excalidraw-app", "packages/excalidraw", - "packages/utils" + "packages/utils", + "examples/excalidraw", + "examples/excalidraw/*" ], "dependencies": { "@excalidraw/random-username": "1.0.0", diff --git a/packages/excalidraw/.gitignore b/packages/excalidraw/.gitignore index f714ecd1d..971fcb7d3 100644 --- a/packages/excalidraw/.gitignore +++ b/packages/excalidraw/.gitignore @@ -1,4 +1,2 @@ node_modules types -bundle.js -bundle.css diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9e3ff5dac..1618cd2ae 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -5780,7 +5780,10 @@ class App extends React.Component { event.preventDefault(); let nextPastePrevented = false; - const isLinux = /Linux/.test(window.navigator.platform); + const isLinux = + typeof window === undefined + ? false + : /Linux/.test(window.navigator.platform); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING); let { clientX: lastX, clientY: lastY } = event; diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 72286e698..c4df44797 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -2,7 +2,6 @@ import cssVariables from "./css/variables.module.scss"; import { AppProps } from "./types"; import { ExcalidrawElement, FontFamilyValues } from "./element/types"; import { COLOR_PALETTE } from "./colors"; - export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform); export const isWindows = /^Win/.test(navigator.platform); export const isAndroid = /\b(android)\b/i.test(navigator.userAgent); diff --git a/packages/excalidraw/example/MobileFooter.tsx b/packages/excalidraw/example/MobileFooter.tsx deleted file mode 100644 index e7e0f8d69..000000000 --- a/packages/excalidraw/example/MobileFooter.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ExcalidrawImperativeAPI } from "../types"; -import CustomFooter from "./CustomFooter"; -const { useDevice, Footer } = window.ExcalidrawLib; - -const MobileFooter = ({ - excalidrawAPI, -}: { - excalidrawAPI: ExcalidrawImperativeAPI; -}) => { - const device = useDevice(); - if (device.editor.isMobile) { - return ( -

    - ); - } - return null; -}; -export default MobileFooter; diff --git a/packages/excalidraw/example/index.tsx b/packages/excalidraw/example/index.tsx deleted file mode 100644 index fcc781289..000000000 --- a/packages/excalidraw/example/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import App from "./App"; - -const { StrictMode } = window.React; -//@ts-ignore -const { createRoot } = window.ReactDOM; - -const rootElement = document.getElementById("root")!; -const root = createRoot(rootElement); - -root.render( - - {}} - /> - , -); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 6524873a2..b45084693 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -80,6 +80,13 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { } useEffect(() => { + const importPolyfill = async () => { + //@ts-ignore + await import("canvas-roundrect-polyfill"); + }; + + importPolyfill(); + // Block pinch-zooming on iOS outside of the content area const handleTouchMove = (event: TouchEvent) => { // @ts-ignore @@ -223,7 +230,7 @@ export { } from "../utils/export"; export { isLinearElement } from "./element/typeChecks"; -export { FONT_FAMILY, THEME, MIME_TYPES } from "./constants"; +export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; export { mutateElement, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 0a066bfd4..6c358591b 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -82,7 +82,6 @@ import { getTargetFrame, isElementInFrame, } from "../frame"; -import "canvas-roundrect-polyfill"; export const DEFAULT_SPACING = 2; diff --git a/packages/excalidraw/tsconfig.json b/packages/excalidraw/tsconfig.json index 28e276c35..4d7d4b3c1 100644 --- a/packages/excalidraw/tsconfig.json +++ b/packages/excalidraw/tsconfig.json @@ -1,5 +1,5 @@ { - "exclude": ["**/*.test.*", "tests", "types", "example", "dist"], + "exclude": ["**/*.test.*", "tests", "types", "examples", "dist"], "compilerOptions": { "target": "ESNext", "strict": true, diff --git a/packages/excalidraw/vite.config.mts b/packages/excalidraw/vite.config.mts deleted file mode 100644 index 9639966b2..000000000 --- a/packages/excalidraw/vite.config.mts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig, loadEnv } from "vite"; -import react from "@vitejs/plugin-react"; - -// To load .env.local variables -const envVars = loadEnv("", `../../`); -// https://vitejs.dev/config/ -export default defineConfig({ - root: "example/public", - server: { - port: 3001, - // open the browser - open: true, - }, - publicDir: "public", -}); diff --git a/scripts/buildExample.mjs b/scripts/buildExample.mjs index cfcbe8420..5cc50c6c6 100644 --- a/scripts/buildExample.mjs +++ b/scripts/buildExample.mjs @@ -4,8 +4,9 @@ import { execSync } from "child_process"; const createDevBuild = async () => { return await esbuild.build({ - entryPoints: ["example/index.tsx"], - outfile: "example/public/bundle.js", + entryPoints: ["../../examples/excalidraw/with-script-in-browser/index.tsx"], + outfile: + "../../examples/excalidraw/with-script-in-browser/public/bundle.js", define: { "import.meta.env": "{}", }, @@ -26,7 +27,7 @@ const startServer = async (ctx) => { }); }; execSync( - `rm -rf example/public/dist && yarn build:esm && cp -r dist example/public`, + `rm -rf ../../examples/excalidraw/with-script-in-browser/public/dist && yarn build:esm && cp -r dist ../../examples/excalidraw/with-script-in-browser/public`, ); const ctx = await createDevBuild(); diff --git a/tsconfig.json b/tsconfig.json index 10ac4b9a8..585fa4cdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "jsx": "react-jsx" }, "include": ["packages", "excalidraw-app"], - "exclude": ["packages/excalidraw/types"] + "exclude": ["packages/excalidraw/types", "examples"] } diff --git a/yarn.lock b/yarn.lock index f857f7fb4..83b861b95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2649,6 +2649,56 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@next/env@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.1.0.tgz#43d92ebb53bc0ae43dcc64fb4d418f8f17d7a341" + integrity sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw== + +"@next/swc-darwin-arm64@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz#70a57c87ab1ae5aa963a3ba0f4e59e18f4ecea39" + integrity sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ== + +"@next/swc-darwin-x64@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz#0863a22feae1540e83c249384b539069fef054e9" + integrity sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g== + +"@next/swc-linux-arm64-gnu@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz#893da533d3fce4aec7116fe772d4f9b95232423c" + integrity sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ== + +"@next/swc-linux-arm64-musl@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz#d81ddcf95916310b8b0e4ad32b637406564244c0" + integrity sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g== + +"@next/swc-linux-x64-gnu@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz#18967f100ec19938354332dcb0268393cbacf581" + integrity sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ== + +"@next/swc-linux-x64-musl@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz#77077cd4ba8dda8f349dc7ceb6230e68ee3293cf" + integrity sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg== + +"@next/swc-win32-arm64-msvc@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz#5f0b8cf955644104621e6d7cc923cad3a4c5365a" + integrity sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ== + +"@next/swc-win32-ia32-msvc@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz#21f4de1293ac5e5a168a412b139db5d3420a89d0" + integrity sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw== + +"@next/swc-win32-x64-msvc@14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz#e561fb330466d41807123d932b365cf3d33ceba2" + integrity sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg== + "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" @@ -3290,6 +3340,13 @@ "@svgr/hast-util-to-babel-ast" "^6.5.1" svg-parser "^2.0.4" +"@swc/helpers@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" + integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== + dependencies: + tslib "^2.4.0" + "@testing-library/dom@^8.0.0": version "8.20.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" @@ -3487,6 +3544,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^20": + version "20.11.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.1.tgz#6a93f94abeda166f688d3d2aca18012afbe5f850" + integrity sha512-DsXojJUES2M+FE8CpptJTKpg+r54moV9ZEncPstni1WHFmTcCzeFLnMFfyhCVS8XNOy/OQG+8lVxRLRrVHmV5A== + dependencies: + undici-types "~5.26.4" + "@types/pako@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.3.tgz#2e61c2b02020b5f44e2e5e946dfac74f4ec33c58" @@ -3521,6 +3585,13 @@ dependencies: "@types/react" "^17" +"@types/react-dom@^18": + version "18.2.18" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" + integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@18.0.15": version "18.0.15" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" @@ -3539,6 +3610,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^18": + version "18.2.48" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.48.tgz#11df5664642d0bd879c1f58bc1d37205b064e8f1" + integrity sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resize-observer-browser@0.1.7": version "0.1.7" resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3" @@ -4669,6 +4749,13 @@ builtin-modules@^3.1.0: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes-iec@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/bytes-iec/-/bytes-iec-3.1.1.tgz#94cd36bf95c2c22a82002c247df8772d1d591083" @@ -4707,6 +4794,11 @@ caniuse-lite@^1.0.30001449: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001478.tgz#0ef8a1cf8b16be47a0f9fc4ecfc952232724b32a" integrity sha512-gMhDyXGItTHipJj2ApIvR+iVB5hd0KP3svMWWXDvZOmjzJJassGLMfxRkQCSYgGd2gtdL/ReeiyvMSFD1Ss6Mw== +caniuse-lite@^1.0.30001579: + version "1.0.30001579" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz#45c065216110f46d6274311a4b3fcf6278e0852a" + integrity sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA== + 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" @@ -4849,6 +4941,11 @@ cli-truncate@^3.1.0: slice-ansi "^5.0.0" string-width "^5.0.0" +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -6617,7 +6714,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -8143,6 +8240,29 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next@14.1: + version "14.1.0" + resolved "https://registry.yarnpkg.com/next/-/next-14.1.0.tgz#b31c0261ff9caa6b4a17c5af019ed77387174b69" + integrity sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q== + dependencies: + "@next/env" "14.1.0" + "@swc/helpers" "0.5.2" + busboy "1.6.0" + caniuse-lite "^1.0.30001579" + graceful-fs "^4.2.11" + postcss "8.4.31" + styled-jsx "5.1.1" + optionalDependencies: + "@next/swc-darwin-arm64" "14.1.0" + "@next/swc-darwin-x64" "14.1.0" + "@next/swc-linux-arm64-gnu" "14.1.0" + "@next/swc-linux-arm64-musl" "14.1.0" + "@next/swc-linux-x64-gnu" "14.1.0" + "@next/swc-linux-x64-musl" "14.1.0" + "@next/swc-win32-arm64-msvc" "14.1.0" + "@next/swc-win32-ia32-msvc" "14.1.0" + "@next/swc-win32-x64-msvc" "14.1.0" + node-fetch@2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -8407,6 +8527,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path2d-polyfill@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz#24c554a738f42700d6961992bf5f1049672f2391" + integrity sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA== + pathe@^1.1.0, pathe@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a" @@ -8571,6 +8696,15 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + postcss@^8.4.32, postcss@^8.4.7: version "8.4.32" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.32.tgz#1dac6ac51ab19adb21b8b34fd2d93a86440ef6c9" @@ -8751,7 +8885,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-dom@18.2.0: +react-dom@18.2.0, react-dom@^18: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== @@ -8807,7 +8941,7 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react@18.2.0: +react@18.2.0, react@^18: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== @@ -9441,6 +9575,11 @@ stop-iteration-iterator@^1.0.0: dependencies: internal-slot "^1.0.4" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" @@ -9591,6 +9730,13 @@ style-loader@3.3.3: resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff" integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw== +styled-jsx@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.1.tgz#839a1c3aaacc4e735fed0781b8619ea5d0009d1f" + integrity sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw== + dependencies: + client-only "0.0.1" + stylis@^4.1.3: version "4.3.0" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.0.tgz#abe305a669fc3d8777e10eefcfc73ad861c5588c" @@ -9857,7 +10003,7 @@ tslib@^1.8.1, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0: +tslib@^2.0.0, tslib@^2.4.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== @@ -9922,6 +10068,11 @@ typescript@4.9.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.4.tgz#a2a3d2756c079abda241d75f149df9d561091e78" integrity sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg== +typescript@^5: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" From 966f9aead912615a8ea518911b2b51688012fcea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jan 2024 19:28:11 +0530 Subject: [PATCH 046/112] build(deps-dev): bump vite from 5.0.6 to 5.0.12 in /examples/excalidraw/with-script-in-browser (#7603) build(deps-dev): bump vite Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.6 to 5.0.12. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/excalidraw/with-script-in-browser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/excalidraw/with-script-in-browser/package.json b/examples/excalidraw/with-script-in-browser/package.json index 490b0f796..d721ac162 100644 --- a/examples/excalidraw/with-script-in-browser/package.json +++ b/examples/excalidraw/with-script-in-browser/package.json @@ -8,7 +8,7 @@ "@excalidraw/excalidraw": "*" }, "devDependencies": { - "vite": "5.0.6", + "vite": "5.0.12", "typescript": "^5" }, "scripts": { From 678bb2b8192975c935cf313f9cf9a7837dd03d37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jan 2024 19:29:50 +0530 Subject: [PATCH 047/112] build(deps-dev): bump vite from 5.0.6 to 5.0.12 (#7586) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.6 to 5.0.12. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 148 ++------------------------------------------------- 2 files changed, 6 insertions(+), 144 deletions(-) diff --git a/package.json b/package.json index a440c97b7..350f1469f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "prettier": "2.6.2", "rewire": "6.0.0", "typescript": "4.9.4", - "vite": "5.0.6", + "vite": "5.0.12", "vite-plugin-checker": "0.6.1", "vite-plugin-ejs": "1.7.0", "vite-plugin-pwa": "0.17.4", diff --git a/yarn.lock b/yarn.lock index 83b861b95..61def89e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2000,221 +2000,111 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.10.tgz#ef31015416dd79398082409b77aaaa2ade4d531a" integrity sha512-1X4CClKhDgC3by7k8aOWZeBXQX8dHT5QAMCAQDArCLaYfkppoARvh0fit3X2Qs+MXDngKcHv6XXyQCpY0hkK1Q== -"@esbuild/android-arm64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.8.tgz#fb7130103835b6d43ea499c3f30cfb2b2ed58456" - integrity sha512-B8JbS61bEunhfx8kasogFENgQfr/dIp+ggYXwTqdbMAgGDhRa3AaPpQMuQU0rNxDLECj6FhDzk1cF9WHMVwrtA== - "@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" integrity sha512-7W0bK7qfkw1fc2viBfrtAEkDKHatYfHzr/jKAHNr9BvkYDXPcC6bodtm8AyLJNNuqClLNaeTLuwURt4PRT9d7w== -"@esbuild/android-arm@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.8.tgz#b46e4d9e984e6d6db6c4224d72c86b7757e35bcb" - integrity sha512-31E2lxlGM1KEfivQl8Yf5aYU/mflz9g06H6S15ITUFQueMFtFjESRMoDSkvMo8thYvLBax+VKTPlpnx+sPicOA== - "@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" integrity sha512-O/nO/g+/7NlitUxETkUv/IvADKuZXyH4BHf/g/7laqKC4i/7whLpB0gvpPc2zpF0q9Q6FXS3TS75QHac9MvVWw== -"@esbuild/android-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.8.tgz#a13db9441b5a4f4e4fec4a6f8ffacfea07888db7" - integrity sha512-rdqqYfRIn4jWOp+lzQttYMa2Xar3OK9Yt2fhOhzFXqg0rVWEfSclJvZq5fZslnz6ypHvVf3CT7qyf0A5pM682A== - "@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" integrity sha512-YSRRs2zOpwypck+6GL3wGXx2gNP7DXzetmo5pHXLrY/VIMsS59yKfjPizQ4lLt5vEI80M41gjm2BxrGZ5U+VMA== -"@esbuild/darwin-arm64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.8.tgz#49f5718d36541f40dd62bfdf84da9c65168a0fc2" - integrity sha512-RQw9DemMbIq35Bprbboyf8SmOr4UXsRVxJ97LgB55VKKeJOOdvsIPy0nFyF2l8U+h4PtBx/1kRf0BelOYCiQcw== - "@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" integrity sha512-alfGtT+IEICKtNE54hbvPg13xGBe4GkVxyGWtzr+yHO7HIiRJppPDhOKq3zstTcVf8msXb/t4eavW3jCDpMSmA== -"@esbuild/darwin-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.8.tgz#75c5c88371eea4bfc1f9ecfd0e75104c74a481ac" - integrity sha512-3sur80OT9YdeZwIVgERAysAbwncom7b4bCI2XKLjMfPymTud7e/oY4y+ci1XVp5TfQp/bppn7xLw1n/oSQY3/Q== - "@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" integrity sha512-dMtk1wc7FSH8CCkE854GyGuNKCewlh+7heYP/sclpOG6Cectzk14qdUIY5CrKDbkA/OczXq9WesqnPl09mj5dg== -"@esbuild/freebsd-arm64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.8.tgz#9d7259fea4fd2b5f7437b52b542816e89d7c8575" - integrity sha512-WAnPJSDattvS/XtPCTj1tPoTxERjcTpH6HsMr6ujTT+X6rylVe8ggxk8pVxzf5U1wh5sPODpawNicF5ta/9Tmw== - "@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" integrity sha512-G5UPPspryHu1T3uX8WiOEUa6q6OlQh6gNl4CO4Iw5PS+Kg5bVggVFehzXBJY6X6RSOMS8iXDv2330VzaObm4Ag== -"@esbuild/freebsd-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.8.tgz#abac03e1c4c7c75ee8add6d76ec592f46dbb39e3" - integrity sha512-ICvZyOplIjmmhjd6mxi+zxSdpPTKFfyPPQMQTK/w+8eNK6WV01AjIztJALDtwNNfFhfZLux0tZLC+U9nSyA5Zg== - "@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" integrity sha512-QxaouHWZ+2KWEj7cGJmvTIHVALfhpGxo3WLmlYfJ+dA5fJB6lDEIg+oe/0//FuyVHuS3l79/wyBxbHr0NgtxJQ== -"@esbuild/linux-arm64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.8.tgz#c577932cf4feeaa43cb9cec27b89cbe0df7d9098" - integrity sha512-z1zMZivxDLHWnyGOctT9JP70h0beY54xDDDJt4VpTX+iwA77IFsE1vCXWmprajJGa+ZYSqkSbRQ4eyLCpCmiCQ== - "@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" integrity sha512-j6gUW5aAaPgD416Hk9FHxn27On28H4eVI9rJ4az7oCGTFW48+LcgNDBN+9f8rKZz7EEowo889CPKyeaD0iw9Kg== -"@esbuild/linux-arm@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.8.tgz#d6014d8b98b5cbc96b95dad3d14d75bb364fdc0f" - integrity sha512-H4vmI5PYqSvosPaTJuEppU9oz1dq2A7Mr2vyg5TF9Ga+3+MGgBdGzcyBP7qK9MrwFQZlvNyJrvz6GuCaj3OukQ== - "@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" integrity sha512-4ub1YwXxYjj9h1UIZs2hYbnTZBtenPw5NfXCRgEkGb0b6OJ2gpkMvDqRDYIDRjRdWSe/TBiZltm3Y3Q8SN1xNg== -"@esbuild/linux-ia32@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.8.tgz#2379a0554307d19ac4a6cdc15b08f0ea28e7a40d" - integrity sha512-1a8suQiFJmZz1khm/rDglOc8lavtzEMRo0v6WhPgxkrjcU0LkHj+TwBrALwoz/OtMExvsqbbMI0ChyelKabSvQ== - "@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" integrity sha512-lo3I9k+mbEKoxtoIbM0yC/MZ1i2wM0cIeOejlVdZ3D86LAcFXFRdeuZmh91QJvUTW51bOK5W2BznGNIl4+mDaA== -"@esbuild/linux-loong64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.8.tgz#e2a5bbffe15748b49356a6cd7b2d5bf60c5a7123" - integrity sha512-fHZWS2JJxnXt1uYJsDv9+b60WCc2RlvVAy1F76qOLtXRO+H4mjt3Tr6MJ5l7Q78X8KgCFudnTuiQRBhULUyBKQ== - "@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" integrity sha512-J4gH3zhHNbdZN0Bcr1QUGVNkHTdpijgx5VMxeetSk6ntdt+vR1DqGmHxQYHRmNb77tP6GVvD+K0NyO4xjd7y4A== -"@esbuild/linux-mips64el@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.8.tgz#1359331e6f6214f26f4b08db9b9df661c57cfa24" - integrity sha512-Wy/z0EL5qZYLX66dVnEg9riiwls5IYnziwuju2oUiuxVc+/edvqXa04qNtbrs0Ukatg5HEzqT94Zs7J207dN5Q== - "@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" integrity sha512-tgT/7u+QhV6ge8wFMzaklOY7KqiyitgT1AUHMApau32ZlvTB/+efeCtMk4eXS+uEymYK249JsoiklZN64xt6oQ== -"@esbuild/linux-ppc64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.8.tgz#9ba436addc1646dc89dae48c62d3e951ffe70951" - integrity sha512-ETaW6245wK23YIEufhMQ3HSeHO7NgsLx8gygBVldRHKhOlD1oNeNy/P67mIh1zPn2Hr2HLieQrt6tWrVwuqrxg== - "@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" integrity sha512-0f/spw0PfBMZBNqtKe5FLzBDGo0SKZKvMl5PHYQr3+eiSscfJ96XEknCe+JoOayybWUFQbcJTrk946i3j9uYZA== -"@esbuild/linux-riscv64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.8.tgz#fbcf0c3a0b20f40b5fc31c3b7695f0769f9de66b" - integrity sha512-T2DRQk55SgoleTP+DtPlMrxi/5r9AeFgkhkZ/B0ap99zmxtxdOixOMI570VjdRCs9pE4Wdkz7JYrsPvsl7eESg== - "@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" integrity sha512-pZFe0OeskMHzHa9U38g+z8Yx5FNCLFtUnJtQMpwhS+r4S566aK2ci3t4NCP4tjt6d5j5uo4h7tExZMjeKoehAA== -"@esbuild/linux-s390x@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.8.tgz#989e8a05f7792d139d5564ffa7ff898ac6f20a4a" - integrity sha512-NPxbdmmo3Bk7mbNeHmcCd7R7fptJaczPYBaELk6NcXxy7HLNyWwCyDJ/Xx+/YcNH7Im5dHdx9gZ5xIwyliQCbg== - "@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" integrity sha512-SpYNEqg/6pZYoc+1zLCjVOYvxfZVZj6w0KROZ3Fje/QrM3nfvT2llI+wmKSrWuX6wmZeTapbarvuNNK/qepSgA== -"@esbuild/linux-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.8.tgz#b187295393a59323397fe5ff51e769ec4e72212b" - integrity sha512-lytMAVOM3b1gPypL2TRmZ5rnXl7+6IIk8uB3eLsV1JwcizuolblXRrc5ShPrO9ls/b+RTp+E6gbsuLWHWi2zGg== - "@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" integrity sha512-ACbZ0vXy9zksNArWlk2c38NdKg25+L9pr/mVaj9SUq6lHZu/35nx2xnQVRGLrC1KKQqJKRIB0q8GspiHI3J80Q== -"@esbuild/netbsd-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.8.tgz#c1ec0e24ea82313cb1c7bae176bd5acd5bde7137" - integrity sha512-hvWVo2VsXz/8NVt1UhLzxwAfo5sioj92uo0bCfLibB0xlOmimU/DeAEsQILlBQvkhrGjamP0/el5HU76HAitGw== - "@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" integrity sha512-PxcgvjdSjtgPMiPQrM3pwSaG4kGphP+bLSb+cihuP0LYdZv1epbAIecHVl5sD3npkfYBZ0ZnOjR878I7MdJDFg== -"@esbuild/openbsd-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.8.tgz#0c5b696ac66c6d70cf9ee17073a581a28af9e18d" - integrity sha512-/7Y7u77rdvmGTxR83PgaSvSBJCC2L3Kb1M/+dmSIvRvQPXXCuC97QAwMugBNG0yGcbEGfFBH7ojPzAOxfGNkwQ== - "@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" integrity sha512-ZkIOtrRL8SEJjr+VHjmW0znkPs+oJXhlJbNwfI37rvgeMtk3sxOQevXPXjmAPZPigVTncvFqLMd+uV0IBSEzqA== -"@esbuild/sunos-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.8.tgz#2a697e1f77926ff09fcc457d8f29916d6cd48fb1" - integrity sha512-9Lc4s7Oi98GqFA4HzA/W2JHIYfnXbUYgekUP/Sm4BG9sfLjyv6GKKHKKVs83SMicBF2JwAX6A1PuOLMqpD001w== - "@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" integrity sha512-+Sa4oTDbpBfGpl3Hn3XiUe4f8TU2JF7aX8cOfqFYMMjXp6ma6NJDztl5FDG8Ezx0OjwGikIHw+iA54YLDNNVfw== -"@esbuild/win32-arm64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.8.tgz#ec029e62a2fca8c071842ecb1bc5c2dd20b066f1" - integrity sha512-rq6WzBGjSzihI9deW3fC2Gqiak68+b7qo5/3kmB6Gvbh/NYPA0sJhrnp7wgV4bNwjqM+R2AApXGxMO7ZoGhIJg== - "@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" integrity sha512-EOGVLK1oWMBXgfttJdPHDTiivYSjX6jDNaATeNOaCOFEVcfMjtbx7WVQwPSE1eIfCp/CaSF2nSrDtzc4I9f8TQ== -"@esbuild/win32-ia32@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.8.tgz#cbb9a3146bde64dc15543e48afe418c7a3214851" - integrity sha512-AIAbverbg5jMvJznYiGhrd3sumfwWs8572mIJL5NQjJa06P8KfCPWZQ0NwZbPQnbQi9OWSZhFVSUWjjIrn4hSw== - "@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" integrity sha512-whqLG6Sc70AbU73fFYvuYzaE4MNMBIlR1Y/IrUeOXFrWHxBEjjbZaQ3IXIQS8wJdAzue2GwYZCjOrgrU1oUHoA== -"@esbuild/win32-x64@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.8.tgz#c8285183dbdb17008578dbacb6e22748709b4822" - integrity sha512-bfZ0cQ1uZs2PqpulNL5j/3w+GDhP36k1K5c38QdQg+Swy51jFZWWeIkteNsufkQxp986wnqRRsb/bHbY1WQ7TA== - "@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" @@ -5890,7 +5780,7 @@ esbuild-sass-plugin@2.16.0: resolve "^1.22.6" sass "^1.7.3" -esbuild@0.19.10: +esbuild@0.19.10, esbuild@^0.19.3: version "0.19.10" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.10.tgz#55e83e4a6b702e3498b9f872d84bfb4ebcb6d16e" integrity sha512-S1Y27QGt/snkNYrRcswgRFqZjaTG5a5xM3EQo97uNBnH505pdzSNe/HLBq1v0RO7iK/ngdbhJB6mDAp0OK+iUA== @@ -5919,34 +5809,6 @@ esbuild@0.19.10: "@esbuild/win32-ia32" "0.19.10" "@esbuild/win32-x64" "0.19.10" -esbuild@^0.19.3: - version "0.19.8" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.8.tgz#ad05b72281d84483fa6b5345bd246c27a207b8f1" - integrity sha512-l7iffQpT2OrZfH2rXIp7/FkmaeZM0vxbxN9KfiCwGYuZqzMg/JdvX26R31Zxn/Pxvsrg3Y9N6XTcnknqDyyv4w== - optionalDependencies: - "@esbuild/android-arm" "0.19.8" - "@esbuild/android-arm64" "0.19.8" - "@esbuild/android-x64" "0.19.8" - "@esbuild/darwin-arm64" "0.19.8" - "@esbuild/darwin-x64" "0.19.8" - "@esbuild/freebsd-arm64" "0.19.8" - "@esbuild/freebsd-x64" "0.19.8" - "@esbuild/linux-arm" "0.19.8" - "@esbuild/linux-arm64" "0.19.8" - "@esbuild/linux-ia32" "0.19.8" - "@esbuild/linux-loong64" "0.19.8" - "@esbuild/linux-mips64el" "0.19.8" - "@esbuild/linux-ppc64" "0.19.8" - "@esbuild/linux-riscv64" "0.19.8" - "@esbuild/linux-s390x" "0.19.8" - "@esbuild/linux-x64" "0.19.8" - "@esbuild/netbsd-x64" "0.19.8" - "@esbuild/openbsd-x64" "0.19.8" - "@esbuild/sunos-x64" "0.19.8" - "@esbuild/win32-arm64" "0.19.8" - "@esbuild/win32-ia32" "0.19.8" - "@esbuild/win32-x64" "0.19.8" - escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -10334,10 +10196,10 @@ vite-plugin-svgr@2.4.0: "@rollup/pluginutils" "^5.0.2" "@svgr/core" "^6.5.1" -vite@5.0.6, "vite@^5.0.0-beta.15 || ^5.0.0", "vite@^5.0.0-beta.19 || ^5.0.0": - version "5.0.6" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.6.tgz#f9e13503a4c5ccd67312c67803dec921f3bdea7c" - integrity sha512-MD3joyAEBtV7QZPl2JVVUai6zHms3YOmLR+BpMzLlX2Yzjfcc4gTgNi09d/Rua3F4EtC8zdwPU8eQYyib4vVMQ== +vite@5.0.12, "vite@^5.0.0-beta.15 || ^5.0.0", "vite@^5.0.0-beta.19 || ^5.0.0": + version "5.0.12" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.0.12.tgz#8a2ffd4da36c132aec4adafe05d7adde38333c47" + integrity sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w== dependencies: esbuild "^0.19.3" postcss "^8.4.32" From 2789d08154a384ae98d9e9d9f70a01334d45ade9 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 25 Jan 2024 20:26:48 +0530 Subject: [PATCH 048/112] docs: update the docs for next js integration (#7605) * docs: update the docs for next js integration * update * update * update docs with tabbed examples * fix --- .../@excalidraw/excalidraw/integration.mdx | 101 ++++++++++++++---- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index 87eb3777d..d6bf3fd0d 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -32,15 +32,9 @@ function App() { ### Next.js -Since _Excalidraw_ doesn't support server side rendering, you should render the component once the host is `mounted`. +Since Excalidraw doesn't support `server side rendering` so it should be rendered only on `client`. The way to achieve this in next.js is using `next.js dynamic import`. -Here are two ways on how you can render **Excalidraw** on **Next.js**. - - - -1. Using **Next.js Dynamic** import [Recommended]. - -Since Excalidraw doesn't support server side rendering so you can also use `dynamic import` to render by setting `ssr` to `false`. +If you want to only import `Excalidraw` component you can do :point_down: ```jsx showLineNumbers import dynamic from "next/dynamic"; @@ -55,25 +49,88 @@ export default function App() { } ``` -Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-dynamic-k8yjq2). +However the above component only works for named component exports. If you want to import some util / constant or something else apart from Excalidraw, then this approach will not work. Instead you can write a wrapper over Excalidraw and import the wrapper dynamically. +If you are using `pages router` then importing the wrapper dynamically would work, where as if you are using `app router` then you will have to also add `useClient` directive on top of the file in addition to dynamically importing the wrapper as shown :point_down: -2. Importing Excalidraw once **client** is rendered. + + -```jsx showLineNumbers -import { useState, useEffect } from "react"; -export default function App() { - const [Excalidraw, setExcalidraw] = useState(null); - useEffect(() => { - import("@excalidraw/excalidraw").then((comp) => - setExcalidraw(comp.Excalidraw), + ```jsx showLineNumbers + "use client"; + import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw"; + + import "@excalidraw/excalidraw/index.css"; + + const ExcalidrawWrapper: React.FC = () => { + console.info(convertToExcalidrawElements([{ + type: "rectangle", + id: "rect-1", + width: 186.47265625, + height: 141.9765625, + },])); + return ( +
    +
    ); - }, []); - return <>{Excalidraw && }; -} -``` + }; + export default ExcalidrawWrapper; + ``` + +
    + + + + ```jsx showLineNumbers + import dynamic from "next/dynamic"; + + // Since client components get prerenderd on server as well hence importing + // the excalidraw stuff dynamically with ssr false + + const ExcalidrawWrapper = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, + ); + + export default function Page() { + return ( + + ); + } + ``` + + + + + ```jsx showLineNumbers + import dynamic from "next/dynamic"; + + // Since client components get prerenderd on server as well hence importing + // the excalidraw stuff dynamically with ssr false + + const ExcalidrawWrapper = dynamic( + async () => (await import("../excalidrawWrapper")).default, + { + ssr: false, + }, + ); + + export default function Page() { + return ( + + ); + } + ``` + + +
    + + +Here is a [source code](https://github.com/excalidraw/excalidraw/tree/master/examples/excalidraw/with-nextjs) for the example with app and pages router. You you can try it out [here](https://excalidraw-package-example-with-nextjs-gh6smrdnq-excalidraw.vercel.app/). -Here is a working [demo](https://codesandbox.io/p/sandbox/excalidraw-with-next-5xb3d) The `types` are available at `@excalidraw/excalidraw/types`, you can view [example for typescript](https://codesandbox.io/s/excalidraw-types-9h2dm) From 10bd08ef19a049abcb4a7da96fc69a052c9520ee Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 26 Jan 2024 11:29:07 +0530 Subject: [PATCH 049/112] fix: make getBoundTextElement and related helpers pure (#7601) * fix: make getBoundTextElement pure * updating args * fix * pass boundTextElement to getBoundTextMaxWidth * fix labelled arrows * lint * pass elementsMap to removeElementsFromFrame * pass elementsMap to getMaximumGroups, alignElements and distributeElements * lint * pass allElementsMap to renderElement * lint * feat: make more typesafe * fix: remove unnecessary assertion * fix: remove unused params --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/actions/actionAlign.tsx | 7 +- .../excalidraw/actions/actionBoundText.tsx | 8 +- .../excalidraw/actions/actionDistribute.tsx | 6 +- .../actions/actionDuplicateSelection.tsx | 2 +- packages/excalidraw/actions/actionFlip.ts | 5 +- packages/excalidraw/actions/actionGroup.tsx | 6 +- .../excalidraw/actions/actionProperties.tsx | 50 ++++++++--- packages/excalidraw/actions/actionStyles.ts | 9 +- packages/excalidraw/align.ts | 9 +- packages/excalidraw/components/Actions.tsx | 8 +- packages/excalidraw/components/App.tsx | 57 ++++++++++-- .../components/canvases/StaticCanvas.tsx | 7 +- packages/excalidraw/data/transform.ts | 5 +- packages/excalidraw/distribute.ts | 5 +- packages/excalidraw/element/binding.ts | 11 ++- packages/excalidraw/element/bounds.ts | 27 ++++-- packages/excalidraw/element/collision.ts | 10 ++- packages/excalidraw/element/dragElements.ts | 5 +- .../excalidraw/element/linearElementEditor.ts | 25 ++++-- packages/excalidraw/element/newElement.ts | 2 +- packages/excalidraw/element/resizeElements.ts | 15 ++-- .../excalidraw/element/textElement.test.ts | 6 +- packages/excalidraw/element/textElement.ts | 44 ++++------ packages/excalidraw/element/textWysiwyg.tsx | 10 ++- packages/excalidraw/element/types.ts | 10 +++ packages/excalidraw/frame.ts | 11 ++- packages/excalidraw/groups.ts | 5 +- packages/excalidraw/renderer/renderElement.ts | 28 ++++-- packages/excalidraw/renderer/renderScene.ts | 16 +++- packages/excalidraw/scene/Scene.ts | 9 +- packages/excalidraw/scene/export.ts | 12 +-- packages/excalidraw/scene/types.ts | 2 + packages/excalidraw/snapping.ts | 8 +- .../tests/linearElementEditor.test.tsx | 88 +++++++++++++++---- 34 files changed, 385 insertions(+), 143 deletions(-) diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index 137f68ae9..8d7d36217 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -40,8 +40,13 @@ const alignSelectedElements = ( alignment: Alignment, ) => { const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = arrayToMap(elements); - const updatedElements = alignElements(selectedElements, alignment); + const updatedElements = alignElements( + selectedElements, + elementsMap, + alignment, + ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index b42169544..05dd9c786 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -45,8 +45,9 @@ export const actionUnbindText = register({ }, perform: (elements, appState, _, app) => { const selectedElements = app.scene.getSelectedElements(appState); + const elementsMap = app.scene.getNonDeletedElementsMap(); selectedElements.forEach((element) => { - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { const { width, height, baseline } = measureText( boundTextElement.originalText, @@ -106,7 +107,10 @@ export const actionBindText = register({ if ( textElement && bindingContainer && - getBoundTextElement(bindingContainer) === null + getBoundTextElement( + bindingContainer, + app.scene.getNonDeletedElementsMap(), + ) === null ) { return true; } diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index bf51bedf4..be48bc870 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -32,7 +32,11 @@ const distributeSelectedElements = ( ) => { const selectedElements = app.scene.getSelectedElements(appState); - const updatedElements = distributeElements(selectedElements, distribution); + const updatedElements = distributeElements( + selectedElements, + app.scene.getNonDeletedElementsMap(), + distribution, + ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index ba079168e..7126f549e 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -139,7 +139,7 @@ const duplicateElements = ( continue; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, arrayToMap(elements)); const isElementAFrameLike = isFrameLikeElement(element); if (idsOfElementsToDuplicate.get(element.id)) { diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 81476e241..c760af44d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -5,6 +5,7 @@ import { ExcalidrawElement, NonDeleted, NonDeletedElementsMap, + NonDeletedSceneElementsMap, } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; import { AppState } from "../types"; @@ -67,7 +68,7 @@ export const actionFlipVertical = register({ const flipSelectedElements = ( elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedElementsMap, + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", ) => { @@ -96,7 +97,7 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], - elementsMap: NonDeletedElementsMap, + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 42bd26efe..44523857a 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -105,7 +105,10 @@ export const actionGroup = register({ const frameElementsMap = groupByFrameLikes(selectedElements); frameElementsMap.forEach((elementsInFrame, frameId) => { - removeElementsFromFrame(elementsInFrame); + removeElementsFromFrame( + elementsInFrame, + app.scene.getNonDeletedElementsMap(), + ); }); } @@ -225,6 +228,7 @@ export const actionUngroup = register({ nextElements, getElementsInResizingFrame(nextElements, frame, appState), frame, + app, ); } }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index c2a47802f..79e50aa68 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -606,7 +606,7 @@ export const actionChangeFontSize = register({ perform: (elements, appState, value, app) => { return changeFontSize(elements, appState, app, () => value, value); }, - PanelComponent: ({ elements, appState, updateData }) => ( + PanelComponent: ({ elements, appState, updateData, app }) => (
    {t("labels.fontSize")} - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => hasSelection ? null @@ -738,7 +745,7 @@ export const actionChangeFontFamily = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { const options: { value: FontFamilyValues; text: string; @@ -778,14 +785,21 @@ export const actionChangeFontFamily = register({ if (isTextElement(element)) { return element.fontFamily; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); if (boundTextElement) { return boundTextElement.fontFamily; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => hasSelection ? null @@ -830,7 +844,8 @@ export const actionChangeTextAlign = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); return (
    {t("labels.textAlign")} @@ -863,14 +878,18 @@ export const actionChangeTextAlign = register({ if (isTextElement(element)) { return element.textAlign; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + elementsMap, + ); if (boundTextElement) { return boundTextElement.textAlign; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement(element, elementsMap) !== null, (hasSelection) => hasSelection ? null : appState.currentItemTextAlign, )} @@ -913,7 +932,7 @@ export const actionChangeVerticalAlign = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => { + PanelComponent: ({ elements, appState, updateData, app }) => { return (
    @@ -945,14 +964,21 @@ export const actionChangeVerticalAlign = register({ if (isTextElement(element) && element.containerId) { return element.verticalAlign; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); if (boundTextElement) { return boundTextElement.verticalAlign; } return null; }, (element) => - isTextElement(element) || getBoundTextElement(element) !== null, + isTextElement(element) || + getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ) !== null, (hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE), )} onChange={(value) => updateData(value)} diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 9c6589bbc..25a6baf2a 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -32,12 +32,15 @@ export let copiedStyles: string = "{}"; export const actionCopyStyles = register({ name: "copyStyles", trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { const elementsCopied = []; const element = elements.find((el) => appState.selectedElementIds[el.id]); elementsCopied.push(element); if (element && hasBoundTextElement(element)) { - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement( + element, + app.scene.getNonDeletedElementsMap(), + ); elementsCopied.push(boundTextElement); } if (element) { @@ -59,7 +62,7 @@ export const actionCopyStyles = register({ export const actionPasteStyles = register({ name: "pasteStyles", trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { const elementsCopied = JSON.parse(copiedStyles); const pastedElement = elementsCopied[0]; const boundTextElement = elementsCopied[1]; diff --git a/packages/excalidraw/align.ts b/packages/excalidraw/align.ts index 06382838f..90ecabb11 100644 --- a/packages/excalidraw/align.ts +++ b/packages/excalidraw/align.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./element/types"; +import { ElementsMap, ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; import { BoundingBox, getCommonBoundingBox } from "./element/bounds"; import { getMaximumGroups } from "./groups"; @@ -10,10 +10,13 @@ export interface Alignment { export const alignElements = ( selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, alignment: Alignment, ): ExcalidrawElement[] => { - const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements); - + const groups: ExcalidrawElement[][] = getMaximumGroups( + selectedElements, + elementsMap, + ); const selectionBoundingBox = getCommonBoundingBox(selectedElements); return groups.flatMap((group) => { diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index d67c8893d..c11d64d04 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,6 +1,10 @@ import { useState } from "react"; import { ActionManager } from "../actions/manager"; -import { ExcalidrawElementType, NonDeletedElementsMap } from "../element/types"; +import { + ExcalidrawElementType, + NonDeletedElementsMap, + NonDeletedSceneElementsMap, +} from "../element/types"; import { t } from "../i18n"; import { useDevice } from "./App"; import { @@ -47,7 +51,7 @@ export const SelectedShapeActions = ({ renderAction, }: { appState: UIAppState; - elementsMap: NonDeletedElementsMap; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; renderAction: ActionManager["renderAction"]; }) => { const targetElements = getTargetElements(elementsMap, appState); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1618cd2ae..30f86c24e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1431,6 +1431,8 @@ class App extends React.Component { pendingImageElementId: this.state.pendingImageElementId, }); + const allElementsMap = this.scene.getNonDeletedElementsMap(); + const shouldBlockPointerEvents = !( this.state.editingElement && isLinearElement(this.state.editingElement) @@ -1628,6 +1630,7 @@ class App extends React.Component { canvas={this.canvas} rc={this.rc} elementsMap={elementsMap} + allElementsMap={allElementsMap} visibleElements={visibleElements} versionNonce={versionNonce} selectionNonce={ @@ -3869,7 +3872,11 @@ class App extends React.Component { if (!isTextElement(selectedElement)) { container = selectedElement as ExcalidrawTextContainer; } - const midPoint = getContainerCenter(selectedElement, this.state); + const midPoint = getContainerCenter( + selectedElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); const sceneX = midPoint.x; const sceneY = midPoint.y; this.startTextEditing({ @@ -4333,6 +4340,7 @@ class App extends React.Component { this.frameNameBoundsCache, x, y, + this.scene.getNonDeletedElementsMap(), ) ? allHitElements[allHitElements.length - 2] : elementWithHighestZIndex; @@ -4362,7 +4370,14 @@ class App extends React.Component { ); return getElementsAtPosition(elements, (element) => - hitTest(element, this.state, this.frameNameBoundsCache, x, y), + hitTest( + element, + this.state, + this.frameNameBoundsCache, + x, + y, + this.scene.getNonDeletedElementsMap(), + ), ).filter((element) => { // hitting a frame's element from outside the frame is not considered a hit const containingFrame = getContainingFrame(element); @@ -4399,7 +4414,10 @@ class App extends React.Component { container, ); if (container && parentCenterPosition) { - const boundTextElementToContainer = getBoundTextElement(container); + const boundTextElementToContainer = getBoundTextElement( + container, + this.scene.getNonDeletedElementsMap(), + ); if (!boundTextElementToContainer) { shouldBindToContainer = true; } @@ -4412,7 +4430,10 @@ class App extends React.Component { if (isTextElement(selectedElements[0])) { existingTextElement = selectedElements[0]; } else if (container) { - existingTextElement = getBoundTextElement(selectedElements[0]); + existingTextElement = getBoundTextElement( + selectedElements[0], + this.scene.getNonDeletedElementsMap(), + ); } else { existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); } @@ -4621,7 +4642,11 @@ class App extends React.Component { [sceneX, sceneY], ) ) { - const midPoint = getContainerCenter(container, this.state); + const midPoint = getContainerCenter( + container, + this.state, + this.scene.getNonDeletedElementsMap(), + ); sceneX = midPoint.x; sceneY = midPoint.y; @@ -5257,8 +5282,8 @@ class App extends React.Component { const element = LinearElementEditor.getElement( linearElementEditor.elementId, ); - - const boundTextElement = getBoundTextElement(element); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const boundTextElement = getBoundTextElement(element, elementsMap); if (!element) { return; @@ -5285,6 +5310,7 @@ class App extends React.Component { linearElementEditor, { x: scenePointerX, y: scenePointerY }, this.state, + this.scene.getNonDeletedElementsMap(), ); if (hoverPointIndex >= 0 || segmentMidPointHoveredCoords) { @@ -5300,6 +5326,7 @@ class App extends React.Component { this.frameNameBoundsCache, scenePointerX, scenePointerY, + elementsMap, ) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); @@ -5311,6 +5338,7 @@ class App extends React.Component { this.frameNameBoundsCache, scenePointerX, scenePointerY, + this.scene.getNonDeletedElementsMap(), ) ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); @@ -6060,6 +6088,7 @@ class App extends React.Component { this.history, pointerDownState.origin, linearElementEditor, + this.scene.getNonDeletedElementsMap(), ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6995,6 +7024,7 @@ class App extends React.Component { ); }, linearElementEditor, + this.scene.getNonDeletedElementsMap(), ); if (didDrag) { pointerDownState.lastCoords.x = pointerCoords.x; @@ -7713,7 +7743,10 @@ class App extends React.Component { groupIds: [], }); - removeElementsFromFrame([linearElement]); + removeElementsFromFrame( + [linearElement], + this.scene.getNonDeletedElementsMap(), + ); this.scene.informMutation(); } @@ -7866,6 +7899,7 @@ class App extends React.Component { this.state, ), frame, + this, ); } @@ -8093,6 +8127,7 @@ class App extends React.Component { this.frameNameBoundsCache, pointerDownState.origin.x, pointerDownState.origin.y, + this.scene.getNonDeletedElementsMap(), )) || (!hitElement && pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements)) @@ -9334,7 +9369,11 @@ class App extends React.Component { let elementCenterX = container.x + container.width / 2; let elementCenterY = container.y + container.height / 2; - const elementCenter = getContainerCenter(container, appState); + const elementCenter = getContainerCenter( + container, + appState, + this.scene.getNonDeletedElementsMap(), + ); if (elementCenter) { elementCenterX = elementCenter.x; elementCenterY = elementCenter.y; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index 3dc5b9175..bfdb669e6 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -7,13 +7,17 @@ import type { RenderableElementsMap, StaticCanvasRenderConfig, } from "../../scene/types"; -import type { NonDeletedExcalidrawElement } from "../../element/types"; +import type { + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, +} from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; type StaticCanvasProps = { canvas: HTMLCanvasElement; rc: RoughCanvas; elementsMap: RenderableElementsMap; + allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; versionNonce: number | undefined; selectionNonce: number | undefined; @@ -67,6 +71,7 @@ const StaticCanvas = (props: StaticCanvasProps) => { rc: props.rc, scale: props.scale, elementsMap: props.elementsMap, + allElementsMap: props.allElementsMap, visibleElements: props.visibleElements, appState: props.appState, renderConfig: props.renderConfig, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 7b5286923..8ce842300 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -24,6 +24,7 @@ import { normalizeText, } from "../element/textElement"; import { + ElementsMap, ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, @@ -42,7 +43,7 @@ import { VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; -import { assertNever, cloneJSON, getFontString } from "../utils"; +import { arrayToMap, assertNever, cloneJSON, getFontString } from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; @@ -202,6 +203,7 @@ const DEFAULT_DIMENSION = 100; const bindTextToContainer = ( container: ExcalidrawElement, textProps: { text: string } & MarkOptional, + elementsMap: ElementsMap, ) => { const textElement: ExcalidrawTextElement = newTextElement({ x: 0, @@ -623,6 +625,7 @@ export const convertToExcalidrawElements = ( let [container, text] = bindTextToContainer( excalidrawElement, element?.label, + arrayToMap(elementStore.getElements()), ); elementStore.add(container); elementStore.add(text); diff --git a/packages/excalidraw/distribute.ts b/packages/excalidraw/distribute.ts index acad09b2d..368b2f24d 100644 --- a/packages/excalidraw/distribute.ts +++ b/packages/excalidraw/distribute.ts @@ -1,7 +1,7 @@ -import { ExcalidrawElement } from "./element/types"; import { newElementWith } from "./element/mutateElement"; import { getMaximumGroups } from "./groups"; import { getCommonBoundingBox } from "./element/bounds"; +import type { ElementsMap, ExcalidrawElement } from "./element/types"; export interface Distribution { space: "between"; @@ -10,6 +10,7 @@ export interface Distribution { export const distributeElements = ( selectedElements: ExcalidrawElement[], + elementsMap: ElementsMap, distribution: Distribution, ): ExcalidrawElement[] => { const [start, mid, end, extent] = @@ -18,7 +19,7 @@ export const distributeElements = ( : (["minY", "midY", "maxY", "height"] as const); const bounds = getCommonBoundingBox(selectedElements); - const groups = getMaximumGroups(selectedElements) + const groups = getMaximumGroups(selectedElements, elementsMap) .map((group) => [group, getCommonBoundingBox(group)] as const) .sort((a, b) => a[1][mid] - b[1][mid]); diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 3f6cf0022..66d29f3f6 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -321,9 +321,9 @@ export const updateBoundElements = ( const simultaneouslyUpdatedElementIds = getSimultaneouslyUpdatedElementIds( simultaneouslyUpdated, ); - + const scene = Scene.getScene(changedElement)!; getNonDeletedElements( - Scene.getScene(changedElement)!, + scene, boundLinearElements.map((el) => el.id), ).forEach((element) => { if (!isLinearElement(element)) { @@ -362,9 +362,12 @@ export const updateBoundElements = ( endBinding, changedElement as ExcalidrawBindableElement, ); - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement( + element, + scene.getNonDeletedElementsMap(), + ); if (boundText) { - handleBindTextResize(element, false); + handleBindTextResize(element, scene.getNonDeletedElementsMap(), false); } }); }; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 673649e5f..f892089f7 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -6,6 +6,7 @@ import { NonDeleted, ExcalidrawTextElementWithContainer, ElementsMapOrArray, + ElementsMap, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; @@ -74,13 +75,16 @@ export class ElementBounds { ) { return cachedBounds.bounds; } - - const bounds = ElementBounds.calculateBounds(element); + const scene = Scene.getScene(element); + const bounds = ElementBounds.calculateBounds( + element, + scene?.getNonDeletedElementsMap() || new Map(), + ); // hack to ensure that downstream checks could retrieve element Scene // so as to have correctly calculated bounds // FIXME remove when we get rid of all the id:Scene / element:Scene mapping - const shouldCache = Scene.getScene(element); + const shouldCache = !!scene; if (shouldCache) { ElementBounds.boundsCache.set(element, { @@ -92,7 +96,10 @@ export class ElementBounds { return bounds; } - private static calculateBounds(element: ExcalidrawElement): Bounds { + private static calculateBounds( + element: ExcalidrawElement, + elementsMap: ElementsMap, + ): Bounds { let bounds: Bounds; const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); @@ -111,7 +118,7 @@ export class ElementBounds { maxY + element.y, ]; } else if (isLinearElement(element)) { - bounds = getLinearElementRotatedBounds(element, cx, cy); + 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); @@ -154,16 +161,17 @@ export const getElementAbsoluteCoords = ( element: ExcalidrawElement, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { + const elementsMap = + Scene.getScene(element)?.getElementsMapIncludingDeleted() || new Map(); if (isFreeDrawElement(element)) { return getFreeDrawElementAbsoluteCoords(element); } else if (isLinearElement(element)) { return LinearElementEditor.getElementAbsoluteCoords( element, + elementsMap, includeBoundText, ); } else if (isTextElement(element)) { - const elementsMap = - Scene.getScene(element)?.getElementsMapIncludingDeleted(); const container = elementsMap ? getContainerElement(element, elementsMap) : null; @@ -677,7 +685,10 @@ const getLinearElementRotatedBounds = ( element: ExcalidrawLinearElement, cx: number, cy: number, + elementsMap: ElementsMap, ): Bounds => { + const boundTextElement = getBoundTextElement(element, elementsMap); + if (element.points.length < 2) { const [pointX, pointY] = element.points[0]; const [x, y] = rotate( @@ -689,7 +700,6 @@ const getLinearElementRotatedBounds = ( ); let coords: Bounds = [x, y, x, y]; - const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, @@ -714,7 +724,6 @@ const getLinearElementRotatedBounds = ( rotate(element.x + x, element.y + y, cx, cy, element.angle); const res = getMinMaxXYFromCurvePathOps(ops, transformXY); let coords: Bounds = [res[0], res[1], res[2], res[3]]; - const boundTextElement = getBoundTextElement(element); if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 709781b22..b8c07e3ab 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -28,6 +28,7 @@ import { StrokeRoundness, ExcalidrawFrameLikeElement, ExcalidrawIframeLikeElement, + ElementsMap, } from "./types"; import { @@ -78,6 +79,7 @@ export const hitTest = ( frameNameBoundsCache: FrameNameBoundsCache, x: number, y: number, + elementsMap: ElementsMap, ): boolean => { // How many pixels off the shape boundary we still consider a hit const threshold = 10 / appState.zoom.value; @@ -95,7 +97,7 @@ export const hitTest = ( ); } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { const isHittingBoundTextElement = hitTest( boundTextElement, @@ -103,6 +105,7 @@ export const hitTest = ( frameNameBoundsCache, x, y, + elementsMap, ); if (isHittingBoundTextElement) { return true; @@ -122,15 +125,16 @@ export const isHittingElementBoundingBoxWithoutHittingElement = ( frameNameBoundsCache: FrameNameBoundsCache, x: number, y: number, + elementsMap: ElementsMap, ): boolean => { const threshold = 10 / appState.zoom.value; // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element // eg for linear elements text can be outside the element bounding box - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if ( boundTextElement && - hitTest(boundTextElement, appState, frameNameBoundsCache, x, y) + hitTest(boundTextElement, appState, frameNameBoundsCache, x, y, elementsMap) ) { return false; } diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index ecec4d083..0144f55a4 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -57,7 +57,10 @@ export const dragSelectedElements = ( // skip arrow labels since we calculate its position during render !isArrowElement(element) ) { - const textElement = getBoundTextElement(element); + const textElement = getBoundTextElement( + element, + scene.getNonDeletedElementsMap(), + ); if (textElement) { updateElementCoords(pointerDownState, textElement, adjustedOffset); } diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index bf64ee732..5c3c6acaa 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -5,6 +5,7 @@ import { PointBinding, ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, + ElementsMap, } from "./types"; import { distance2d, @@ -193,6 +194,7 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, + elementsMap: ElementsMap, ): boolean { if (!linearElementEditor) { return false; @@ -272,9 +274,9 @@ export class LinearElementEditor { ); } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - handleBindTextResize(element, false); + handleBindTextResize(element, elementsMap, false); } // suggest bindings for first and last point if selected @@ -404,9 +406,10 @@ export class LinearElementEditor { static getEditorMidPoints = ( element: NonDeleted, + elementsMap: ElementsMap, appState: InteractiveCanvasAppState, ): typeof editorMidPointsCache["points"] => { - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement(element, elementsMap); // Since its not needed outside editor unless 2 pointer lines or bound text if ( @@ -465,6 +468,7 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, scenePointer: { x: number; y: number }, appState: AppState, + elementsMap: ElementsMap, ) => { const { elementId } = linearElementEditor; const element = LinearElementEditor.getElement(elementId); @@ -503,7 +507,7 @@ export class LinearElementEditor { } let index = 0; const midPoints: typeof editorMidPointsCache["points"] = - LinearElementEditor.getEditorMidPoints(element, appState); + LinearElementEditor.getEditorMidPoints(element, elementsMap, appState); while (index < midPoints.length) { if (midPoints[index] !== null) { const distance = distance2d( @@ -581,6 +585,7 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, appState: AppState, midPoint: Point, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, @@ -588,7 +593,11 @@ export class LinearElementEditor { if (!element) { return -1; } - const midPoints = LinearElementEditor.getEditorMidPoints(element, appState); + const midPoints = LinearElementEditor.getEditorMidPoints( + element, + elementsMap, + appState, + ); let index = 0; while (index < midPoints.length) { if (LinearElementEditor.arePointsEqual(midPoint, midPoints[index])) { @@ -605,6 +614,7 @@ export class LinearElementEditor { history: History, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, + elementsMap: ElementsMap, ): { didAddPoint: boolean; hitElement: NonDeleted | null; @@ -630,6 +640,7 @@ export class LinearElementEditor { linearElementEditor, scenePointer, appState, + elementsMap, ); let segmentMidpointIndex = null; if (segmentMidpoint) { @@ -637,6 +648,7 @@ export class LinearElementEditor { linearElementEditor, appState, segmentMidpoint, + elementsMap, ); } if (event.altKey && appState.editingLinearElement) { @@ -1418,6 +1430,7 @@ export class LinearElementEditor { static getElementAbsoluteCoords = ( element: ExcalidrawLinearElement, + elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { let coords: [number, number, number, number, number, number]; @@ -1462,7 +1475,7 @@ export class LinearElementEditor { if (!includeBoundText) { return coords; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { coords = LinearElementEditor.getMinMaxXYWithBoundText( element, diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 3158c064c..447a07993 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -342,7 +342,7 @@ export const refreshTextDimensions = ( text = wrapText( text, getFontString(textElement), - getBoundTextMaxWidth(container), + getBoundTextMaxWidth(container, textElement), ); } const dimensions = getAdjustedDimensions(textElement, text); diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 46b891aca..deb5fead3 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -126,6 +126,7 @@ export const transformElements = ( rotateMultipleElements( originalElements, selectedElements, + elementsMap, pointerX, pointerY, shouldRotateWithDiscreteAngle, @@ -219,7 +220,7 @@ const measureFontSizeFromWidth = ( if (hasContainer) { const container = getContainerElement(element, elementsMap); if (container) { - width = getBoundTextMaxWidth(container); + width = getBoundTextMaxWidth(container, element); } } const nextFontSize = element.fontSize * (nextWidth / width); @@ -394,7 +395,7 @@ export const resizeSingleElement = ( let scaleY = atStartBoundsHeight / boundsCurrentHeight; let boundTextFont: { fontSize?: number; baseline?: number } = {}; - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (transformHandleDirection.includes("e")) { scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; @@ -458,7 +459,7 @@ export const resizeSingleElement = ( const nextFont = measureFontSizeFromWidth( boundTextElement, elementsMap, - getBoundTextMaxWidth(updatedElement), + getBoundTextMaxWidth(updatedElement, boundTextElement), getBoundTextMaxHeight(updatedElement, boundTextElement), ); if (nextFont === null) { @@ -640,6 +641,7 @@ export const resizeSingleElement = ( } handleBindTextResize( element, + elementsMap, transformHandleDirection, shouldMaintainAspectRatio, ); @@ -882,7 +884,7 @@ export const resizeMultipleElements = ( newSize: { width, height }, }); - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement && boundTextFontSize) { mutateElement( boundTextElement, @@ -892,7 +894,7 @@ export const resizeMultipleElements = ( }, false, ); - handleBindTextResize(element, transformHandleType, true); + handleBindTextResize(element, elementsMap, transformHandleType, true); } } @@ -902,6 +904,7 @@ export const resizeMultipleElements = ( const rotateMultipleElements = ( originalElements: PointerDownState["originalElements"], elements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, @@ -941,7 +944,7 @@ const rotateMultipleElements = ( ); updateBoundElements(element, { simultaneouslyUpdated: elements }); - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { mutateElement( boundText, diff --git a/packages/excalidraw/element/textElement.test.ts b/packages/excalidraw/element/textElement.test.ts index b6221336d..2f3a2dcc7 100644 --- a/packages/excalidraw/element/textElement.test.ts +++ b/packages/excalidraw/element/textElement.test.ts @@ -319,17 +319,17 @@ describe("Test measureText", () => { it("should return max width when container is rectangle", () => { const container = API.createElement({ type: "rectangle", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(168); + expect(getBoundTextMaxWidth(container, null)).toBe(168); }); it("should return max width when container is ellipse", () => { const container = API.createElement({ type: "ellipse", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(116); + expect(getBoundTextMaxWidth(container, null)).toBe(116); }); it("should return max width when container is diamond", () => { const container = API.createElement({ type: "diamond", ...params }); - expect(getBoundTextMaxWidth(container)).toBe(79); + expect(getBoundTextMaxWidth(container, null)).toBe(79); }); }); diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index da1348ec2..b264c0d59 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -23,7 +23,6 @@ import { VERTICAL_ALIGN, } from "../constants"; import { MaybeTransformHandleType } from "./transformHandles"; -import Scene from "../scene/Scene"; import { isTextElement } from "."; import { isBoundToContainer, isArrowElement } from "./typeChecks"; import { LinearElementEditor } from "./linearElementEditor"; @@ -89,7 +88,7 @@ export const redrawTextBoundingBox = ( container, textElement as ExcalidrawTextElementWithContainer, ); - const maxContainerWidth = getBoundTextMaxWidth(container); + const maxContainerWidth = getBoundTextMaxWidth(container, textElement); if (!isArrowElement(container) && metrics.height > maxContainerHeight) { const nextHeight = computeContainerDimensionForBoundText( @@ -162,6 +161,7 @@ export const bindTextToShapeAfterDuplication = ( export const handleBindTextResize = ( container: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, transformHandleType: MaybeTransformHandleType, shouldMaintainAspectRatio = false, ) => { @@ -170,25 +170,17 @@ export const handleBindTextResize = ( return; } resetOriginalContainerCache(container.id); - let textElement = Scene.getScene(container)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElement; + const textElement = getBoundTextElement(container, elementsMap); if (textElement && textElement.text) { if (!container) { return; } - textElement = Scene.getScene(container)!.getElement( - boundTextElementId, - ) as ExcalidrawTextElement; let text = textElement.text; let nextHeight = textElement.height; let nextWidth = textElement.width; - const maxWidth = getBoundTextMaxWidth(container); - const maxHeight = getBoundTextMaxHeight( - container, - textElement as ExcalidrawTextElementWithContainer, - ); + const maxWidth = getBoundTextMaxWidth(container, textElement); + const maxHeight = getBoundTextMaxHeight(container, textElement); let containerHeight = container.height; let nextBaseLine = textElement.baseline; if ( @@ -243,10 +235,7 @@ export const handleBindTextResize = ( if (!isArrowElement(container)) { mutateElement( textElement, - computeBoundTextPosition( - container, - textElement as ExcalidrawTextElementWithContainer, - ), + computeBoundTextPosition(container, textElement), ); } } @@ -264,7 +253,7 @@ export const computeBoundTextPosition = ( } const containerCoords = getContainerCoords(container); const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement); - const maxContainerWidth = getBoundTextMaxWidth(container); + const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement); let x; let y; @@ -667,17 +656,18 @@ export const getBoundTextElementId = (container: ExcalidrawElement | null) => { : null; }; -export const getBoundTextElement = (element: ExcalidrawElement | null) => { +export const getBoundTextElement = ( + element: ExcalidrawElement | null, + elementsMap: ElementsMap, +) => { if (!element) { return null; } const boundTextElementId = getBoundTextElementId(element); + if (boundTextElementId) { - return ( - (Scene.getScene(element)?.getElement( - boundTextElementId, - ) as ExcalidrawTextElementWithContainer) || null - ); + return (elementsMap.get(boundTextElementId) || + null) as ExcalidrawTextElementWithContainer | null; } return null; }; @@ -699,6 +689,7 @@ export const getContainerElement = ( export const getContainerCenter = ( container: ExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (!isArrowElement(container)) { return { @@ -718,6 +709,7 @@ export const getContainerCenter = ( const index = container.points.length / 2 - 1; let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints( container, + elementsMap, appState, )[index]; if (!midSegmentMidpoint) { @@ -877,9 +869,7 @@ export const computeContainerDimensionForBoundText = ( export const getBoundTextMaxWidth = ( container: ExcalidrawElement, - boundTextElement: ExcalidrawTextElement | null = getBoundTextElement( - container, - ), + boundTextElement: ExcalidrawTextElement | null, ) => { const { width } = container; if (isArrowElement(container)) { diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 801f0c440..d12d34f89 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -34,6 +34,7 @@ import { computeContainerDimensionForBoundText, detectLineHeight, computeBoundTextPosition, + getBoundTextElement, } from "./textElement"; import { actionDecreaseFontSize, @@ -196,7 +197,8 @@ export const textWysiwyg = ({ } } - maxWidth = getBoundTextMaxWidth(container); + maxWidth = getBoundTextMaxWidth(container, updatedTextElement); + maxHeight = getBoundTextMaxHeight( container, updatedTextElement as ExcalidrawTextElementWithContainer, @@ -361,10 +363,14 @@ export const textWysiwyg = ({ fontFamily: app.state.currentItemFontFamily, }); if (container) { + const boundTextElement = getBoundTextElement( + container, + app.scene.getNonDeletedElementsMap(), + ); const wrappedText = wrapText( `${editable.value}${data}`, font, - getBoundTextMaxWidth(container), + getBoundTextMaxWidth(container, boundTextElement), ); const width = getTextWidth(wrappedText, font); editable.style.width = `${width}px`; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 7659ad1e9..f89e8d5f2 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -279,6 +279,16 @@ export type NonDeletedElementsMap = Map< export type SceneElementsMap = Map & MakeBrand<"SceneElementsMap">; +/** + * Map of all non-deleted Scene elements. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type NonDeletedSceneElementsMap = Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement +> & + MakeBrand<"NonDeletedSceneElementsMap">; + export type ElementsMapOrArray = | readonly ExcalidrawElement[] | Readonly; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index ecb70ef1e..1457c4ecf 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -444,6 +444,7 @@ export const addElementsToFrame = ( elementsToAdd: NonDeletedExcalidrawElement[], frame: ExcalidrawFrameLikeElement, ): T => { + const elementsMap = arrayToMap(allElements); const currTargetFrameChildrenMap = new Map(); for (const element of allElements.values()) { if (element.frameId === frame.id) { @@ -481,7 +482,7 @@ export const addElementsToFrame = ( finalElementsToAdd.push(element); } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if ( boundTextElement && !suppliedElementsToAddSet.has(boundTextElement.id) && @@ -506,6 +507,7 @@ export const addElementsToFrame = ( export const removeElementsFromFrame = ( elementsToRemove: ReadonlySetLike, + elementsMap: ElementsMap, ) => { const _elementsToRemove = new Map< ExcalidrawElement["id"], @@ -524,7 +526,7 @@ export const removeElementsFromFrame = ( const arr = toRemoveElementsByFrame.get(element.frameId) || []; arr.push(element); - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { _elementsToRemove.set(boundTextElement.id, boundTextElement); arr.push(boundTextElement); @@ -550,7 +552,7 @@ export const removeAllElementsFromFrame = ( frame: ExcalidrawFrameLikeElement, ) => { const elementsInFrame = getFrameChildren(allElements, frame.id); - removeElementsFromFrame(elementsInFrame); + removeElementsFromFrame(elementsInFrame, arrayToMap(allElements)); return allElements; }; @@ -558,6 +560,7 @@ export const replaceAllElementsInFrame = ( allElements: readonly T[], nextElementsInFrame: ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + app: AppClassProperties, ): T[] => { return addElementsToFrame( removeAllElementsFromFrame(allElements, frame), @@ -608,7 +611,7 @@ export const updateFrameMembershipOfSelectedElements = < }); if (elementsToRemove.size > 0) { - removeElementsFromFrame(elementsToRemove); + removeElementsFromFrame(elementsToRemove, elementsMap); } return allElements; }; diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index b0bedc4f9..f8c0eddb9 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -4,6 +4,7 @@ import { NonDeleted, NonDeletedExcalidrawElement, ElementsMapOrArray, + ElementsMap, } from "./element/types"; import { AppClassProperties, @@ -329,12 +330,12 @@ export const removeFromSelectedGroups = ( export const getMaximumGroups = ( elements: ExcalidrawElement[], + elementsMap: ElementsMap, ): ExcalidrawElement[][] => { const groups: Map = new Map< String, ExcalidrawElement[] >(); - elements.forEach((element: ExcalidrawElement) => { const groupId = element.groupIds.length === 0 @@ -344,7 +345,7 @@ export const getMaximumGroups = ( const currentGroupMembers = groups.get(groupId) || []; // Include bound text if present when grouping - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { currentGroupMembers.push(boundTextElement); } diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 39e6c4974..5ab3f3ca5 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -6,6 +6,7 @@ import { ExcalidrawImageElement, ExcalidrawTextElementWithContainer, ExcalidrawFrameLikeElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { isTextElement, @@ -190,6 +191,7 @@ const cappedElementCanvasSize = ( const generateElementCanvas = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, zoom: Zoom, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, @@ -247,7 +249,8 @@ const generateElementCanvas = ( zoomValue: zoom.value, canvasOffsetX, canvasOffsetY, - boundTextElementVersion: getBoundTextElement(element)?.version || null, + boundTextElementVersion: + getBoundTextElement(element, elementsMap)?.version || null, containingFrameOpacity: getContainingFrame(element)?.opacity || 100, }; }; @@ -407,6 +410,7 @@ export const elementWithCanvasCache = new WeakMap< const generateElementWithCanvas = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, ) => { @@ -416,7 +420,9 @@ const generateElementWithCanvas = ( prevElementWithCanvas && prevElementWithCanvas.zoomValue !== zoom.value && !appState?.shouldCacheIgnoreZoom; - const boundTextElementVersion = getBoundTextElement(element)?.version || null; + const boundTextElementVersion = + getBoundTextElement(element, elementsMap)?.version || null; + const containingFrameOpacity = getContainingFrame(element)?.opacity || 100; if ( @@ -428,6 +434,7 @@ const generateElementWithCanvas = ( ) { const elementWithCanvas = generateElementCanvas( element, + elementsMap, zoom, renderConfig, appState, @@ -445,6 +452,7 @@ const drawElementFromCanvas = ( context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, appState: StaticCanvasAppState, + allElementsMap: NonDeletedSceneElementsMap, ) => { const element = elementWithCanvas.element; const padding = getCanvasPadding(element); @@ -464,7 +472,8 @@ const drawElementFromCanvas = ( context.save(); context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio); - const boundTextElement = getBoundTextElement(element); + + const boundTextElement = getBoundTextElement(element, allElementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); @@ -511,7 +520,6 @@ const drawElementFromCanvas = ( offsetY - padding * zoom; tempCanvasContext.translate(-shiftX, -shiftY); - // Clear the bound text area tempCanvasContext.clearRect( -(boundTextElement.width / 2 + BOUND_TEXT_PADDING) * @@ -573,6 +581,7 @@ const drawElementFromCanvas = ( ) { const textElement = getBoundTextElement( element, + allElementsMap, ) as ExcalidrawTextElementWithContainer; const coords = getContainerCoords(element); context.strokeStyle = "#c92a2a"; @@ -580,7 +589,7 @@ const drawElementFromCanvas = ( context.strokeRect( (coords.x + appState.scrollX) * window.devicePixelRatio, (coords.y + appState.scrollY) * window.devicePixelRatio, - getBoundTextMaxWidth(element) * window.devicePixelRatio, + getBoundTextMaxWidth(element, textElement) * window.devicePixelRatio, getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio, ); } @@ -616,6 +625,7 @@ export const renderSelectionElement = ( export const renderElement = ( element: NonDeletedExcalidrawElement, elementsMap: RenderableElementsMap, + allElementsMap: NonDeletedSceneElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, @@ -687,6 +697,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, + elementsMap, renderConfig, appState, ); @@ -695,6 +706,7 @@ export const renderElement = ( context, renderConfig, appState, + allElementsMap, ); } @@ -737,7 +749,7 @@ export const renderElement = ( if (shouldResetImageFilter(element, renderConfig, appState)) { context.filter = "none"; } - const boundTextElement = getBoundTextElement(element); + const boundTextElement = getBoundTextElement(element, elementsMap); if (isArrowElement(element) && boundTextElement) { const tempCanvas = document.createElement("canvas"); @@ -820,6 +832,7 @@ export const renderElement = ( } else { const elementWithCanvas = generateElementWithCanvas( element, + elementsMap, renderConfig, appState, ); @@ -851,6 +864,7 @@ export const renderElement = ( context, renderConfig, appState, + allElementsMap, ); // reset @@ -1096,7 +1110,7 @@ export const renderElementToSvg = ( } case "line": case "arrow": { - const boundText = getBoundTextElement(element); + const boundText = getBoundTextElement(element, elementsMap); const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); if (boundText) { maskPath.setAttribute("id", `mask-${element.id}`); diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 6c358591b..0fa56829f 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -246,6 +246,7 @@ const renderLinearPointHandles = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, element: NonDeleted, + elementsMap: RenderableElementsMap, ) => { if (!appState.selectedLinearElement) { return; @@ -269,6 +270,7 @@ const renderLinearPointHandles = ( //Rendering segment mid points const midPoints = LinearElementEditor.getEditorMidPoints( element, + elementsMap, appState, ).filter((midPoint) => midPoint !== null) as Point[]; @@ -485,7 +487,12 @@ const _renderInteractiveScene = ({ }); if (editingLinearElement) { - renderLinearPointHandles(context, appState, editingLinearElement); + renderLinearPointHandles( + context, + appState, + editingLinearElement, + elementsMap, + ); } // Paint selection element @@ -528,6 +535,7 @@ const _renderInteractiveScene = ({ context, appState, selectedElements[0] as NonDeleted, + elementsMap, ); } @@ -553,6 +561,7 @@ const _renderInteractiveScene = ({ context, appState, selectedElements[0] as ExcalidrawLinearElement, + elementsMap, ); } const selectionColor = renderConfig.selectionColor || oc.black; @@ -891,6 +900,7 @@ const _renderStaticScene = ({ canvas, rc, elementsMap, + allElementsMap, visibleElements, scale, appState, @@ -972,6 +982,7 @@ const _renderStaticScene = ({ renderElement( element, elementsMap, + allElementsMap, rc, context, renderConfig, @@ -982,6 +993,7 @@ const _renderStaticScene = ({ renderElement( element, elementsMap, + allElementsMap, rc, context, renderConfig, @@ -1005,6 +1017,7 @@ const _renderStaticScene = ({ renderElement( element, elementsMap, + allElementsMap, rc, context, renderConfig, @@ -1024,6 +1037,7 @@ const _renderStaticScene = ({ renderElement( label, elementsMap, + allElementsMap, rc, context, renderConfig, diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 326f98c7f..88c3d8996 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -4,8 +4,8 @@ import { NonDeleted, ExcalidrawFrameLikeElement, ElementsMapOrArray, - NonDeletedElementsMap, SceneElementsMap, + NonDeletedSceneElementsMap, } from "../element/types"; import { isNonDeletedElement } from "../element"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -27,7 +27,7 @@ type SelectionHash = string & { __brand: "selectionHash" }; const getNonDeletedElements = ( allElements: readonly T[], ) => { - const elementsMap = new Map() as NonDeletedElementsMap; + const elementsMap = new Map() as NonDeletedSceneElementsMap; const elements: T[] = []; for (const element of allElements) { if (!element.isDeleted) { @@ -120,8 +120,9 @@ class Scene { private callbacks: Set = new Set(); private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; - private nonDeletedElementsMap: NonDeletedElementsMap = - new Map() as NonDeletedElementsMap; + private nonDeletedElementsMap = toBrandedType( + new Map(), + ); private elements: readonly ExcalidrawElement[] = []; private nonDeletedFramesLikes: readonly NonDeleted[] = []; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9f1f12a22..d463e2597 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -4,6 +4,7 @@ import { ExcalidrawFrameLikeElement, ExcalidrawTextElement, NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { Bounds, @@ -248,14 +249,15 @@ export const exportToCanvas = async ( files, }); - const elementsMap = toBrandedType( - arrayToMap(elementsForRender), - ); - renderStaticScene({ canvas, rc: rough.canvas(canvas), - elementsMap, + elementsMap: toBrandedType( + arrayToMap(elementsForRender), + ), + allElementsMap: toBrandedType( + arrayToMap(elements), + ), visibleElements: elementsForRender, scale, appState: { diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 957b080b3..02aa3b7bf 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -4,6 +4,7 @@ import { ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "../element/types"; import { AppClassProperties, @@ -66,6 +67,7 @@ export type StaticSceneRenderConfig = { canvas: HTMLCanvasElement; rc: RoughCanvas; elementsMap: RenderableElementsMap; + allElementsMap: NonDeletedSceneElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; scale: number; appState: StaticCanvasAppState; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index e7ff9b787..7557145ae 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -16,6 +16,7 @@ import { KEYS } from "./keys"; import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; import { getVisibleAndNonSelectedElements } from "./scene/selection"; import { AppState, KeyboardModifiersObject, Point } from "./types"; +import { arrayToMap } from "./utils"; const SNAP_DISTANCE = 8; @@ -286,7 +287,10 @@ export const getVisibleGaps = ( appState, ); - const referenceBounds = getMaximumGroups(referenceElements) + const referenceBounds = getMaximumGroups( + referenceElements, + arrayToMap(elements), + ) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), @@ -572,7 +576,7 @@ export const getReferenceSnapPoints = ( appState, ); - return getMaximumGroups(referenceElements) + return getMaximumGroups(referenceElements, arrayToMap(elements)) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index f4ddeafd2..ce0e1c856 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -24,6 +24,7 @@ import { import * as textElementUtils from "../element/textElement"; import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; +import { arrayToMap } from "../utils"; const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); @@ -307,6 +308,7 @@ describe("Test Linear Elements", () => { const midPointsWithSharpEdge = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); @@ -320,6 +322,7 @@ describe("Test Linear Elements", () => { const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( h.elements[0] as ExcalidrawLinearElement, + h.app.scene.getNonDeletedElementsMap(), h.state, ); expect(midPointsWithRoundEdge[0]).not.toEqual(midPointsWithSharpEdge[0]); @@ -351,7 +354,11 @@ describe("Test Linear Elements", () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); expect([line.x, line.y]).toEqual(points[0]); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const startPoint = centerPoint(points[0], midPoints[0] as Point); const deltaX = 50; @@ -373,6 +380,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); expect(midPoints[0]).not.toEqual(newMidPoints[0]); @@ -458,7 +466,11 @@ describe("Test Linear Elements", () => { it("should update only the first segment midpoint when its point is dragged", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const hitCoords: Point = [points[0][0], points[0][1]]; @@ -478,6 +490,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); @@ -487,7 +500,11 @@ describe("Test Linear Elements", () => { it("should hide midpoints in the segment when points moved close", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const hitCoords: Point = [points[0][0], points[0][1]]; @@ -507,6 +524,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); // This midpoint is hidden since the points are too close @@ -526,7 +544,11 @@ describe("Test Linear Elements", () => { ]); expect(line.points.length).toEqual(4); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); // delete 3rd point deletePoint(points[2]); @@ -538,6 +560,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); expect(newMidPoints.length).toEqual(2); @@ -615,7 +638,11 @@ describe("Test Linear Elements", () => { it("should update all the midpoints when its point is dragged", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const hitCoords: Point = [points[0][0], points[0][1]]; @@ -630,6 +657,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); @@ -651,7 +679,11 @@ describe("Test Linear Elements", () => { it("should hide midpoints in the segment when points moved close", async () => { const points = LinearElementEditor.getPointsGlobalCoordinates(line); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const hitCoords: Point = [points[0][0], points[0][1]]; @@ -671,6 +703,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); // This mid point is hidden due to point being too close @@ -685,7 +718,11 @@ describe("Test Linear Elements", () => { ]); expect(line.points.length).toEqual(4); - const midPoints = LinearElementEditor.getEditorMidPoints(line, h.state); + const midPoints = LinearElementEditor.getEditorMidPoints( + line, + h.app.scene.getNonDeletedElementsMap(), + h.state, + ); const points = LinearElementEditor.getPointsGlobalCoordinates(line); // delete 3rd point @@ -694,6 +731,7 @@ describe("Test Linear Elements", () => { const newMidPoints = LinearElementEditor.getEditorMidPoints( line, + h.app.scene.getNonDeletedElementsMap(), h.state, ); expect(newMidPoints.length).toEqual(2); @@ -762,7 +800,7 @@ describe("Test Linear Elements", () => { type: "text", x: 0, y: 0, - text: wrapText(text, font, getBoundTextMaxWidth(container)), + text: wrapText(text, font, getBoundTextMaxWidth(container, null)), containerId: container.id, width: 30, height: 20, @@ -986,8 +1024,13 @@ describe("Test Linear Elements", () => { collaboration made easy" `); - expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) - .toMatchInlineSnapshot(` + expect( + LinearElementEditor.getElementAbsoluteCoords( + container, + h.app.scene.getNonDeletedElementsMap(), + true, + ), + ).toMatchInlineSnapshot(` [ 20, 20, @@ -1020,8 +1063,13 @@ describe("Test Linear Elements", () => { "Online whiteboard collaboration made easy" `); - expect(LinearElementEditor.getElementAbsoluteCoords(container, true)) - .toMatchInlineSnapshot(` + expect( + LinearElementEditor.getElementAbsoluteCoords( + container, + h.app.scene.getNonDeletedElementsMap(), + true, + ), + ).toMatchInlineSnapshot(` [ 20, 35, @@ -1121,7 +1169,11 @@ describe("Test Linear Elements", () => { expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( - wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), + wrapText( + textElement.originalText, + font, + getBoundTextMaxWidth(arrow, null), + ), ).toMatchInlineSnapshot(` "Online whiteboard collaboration made easy" @@ -1140,11 +1192,17 @@ describe("Test Linear Elements", () => { expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( - h.elements[1], + h.elements[0], + arrayToMap(h.elements), + "nw", false, ); expect( - wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)), + wrapText( + textElement.originalText, + font, + getBoundTextMaxWidth(arrow, null), + ), ).toMatchInlineSnapshot(` "Online whiteboard collaboration made From 626fe252ab0c2d0cb295c849b887bd8c76133f40 Mon Sep 17 00:00:00 2001 From: Andran1k <91144891+Andran1k@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:57:22 +0400 Subject: [PATCH 050/112] fix: frame name field (#7457) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/App.tsx | 6 ++---- packages/excalidraw/frame.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 30f86c24e..71bfaa5d5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1299,10 +1299,7 @@ class App extends React.Component { const FRAME_NAME_EDIT_PADDING = 6; const reset = () => { - if (f.name?.trim() === "") { - mutateElement(f, { name: null }); - } - + mutateElement(f, { name: f.name?.trim() || null }); this.setState({ editingFrame: null }); }; @@ -1325,6 +1322,7 @@ class App extends React.Component { name: e.target.value, }); }} + onFocus={(e) => e.target.select()} onBlur={() => reset()} onKeyDown={(event) => { // for some inexplicable reason, `onBlur` triggered on ESC diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 1457c4ecf..c4a5a259d 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -746,7 +746,7 @@ export const getFrameLikeTitle = ( element: ExcalidrawFrameLikeElement, frameIdx: number, ) => { - // TODO name frames AI only is specific to AI frames + // TODO name frames "AI" only if specific to AI frames return element.name === null ? isFrameElement(element) ? `Frame ${frameIdx}` From 2409c091fff0bd359c003e3e366de1834d0b7c92 Mon Sep 17 00:00:00 2001 From: Aashir Israr <63807168+aashirisrar@users.noreply.github.com> Date: Mon, 29 Jan 2024 19:27:07 +0500 Subject: [PATCH 051/112] feat: support roundness for images (#7558) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/element/typeChecks.ts | 5 ++- packages/excalidraw/renderer/renderElement.ts | 36 +++++++++++++++++++ packages/excalidraw/scene/comparisons.ts | 3 +- .../tests/__snapshots__/export.test.tsx.snap | 2 +- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/element/typeChecks.ts b/packages/excalidraw/element/typeChecks.ts index ef1bcd3db..7193e251b 100644 --- a/packages/excalidraw/element/typeChecks.ts +++ b/packages/excalidraw/element/typeChecks.ts @@ -214,7 +214,10 @@ export const isBoundToContainer = ( }; export const isUsingAdaptiveRadius = (type: string) => - type === "rectangle" || type === "embeddable" || type === "iframe"; + type === "rectangle" || + type === "embeddable" || + type === "iframe" || + type === "image"; export const isUsingProportionalRadius = (type: string) => type === "line" || type === "arrow" || type === "diamond"; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 5ab3f3ca5..de4bcfe53 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -344,6 +344,17 @@ const drawElementOnCanvas = ( ? renderConfig.imageCache.get(element.fileId)?.image : undefined; if (img != null && !(img instanceof Promise)) { + if (element.roundness && context.roundRect) { + context.beginPath(); + context.roundRect( + 0, + 0, + element.width, + element.height, + getCornerRadius(Math.min(element.width, element.height), element), + ); + context.clip(); + } context.drawImage( img, 0 /* hardcoded for the selection box*/, @@ -1301,6 +1312,31 @@ export const renderElementToSvg = ( }) rotate(${degree} ${cx} ${cy})`, ); + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + const clipG = maybeWrapNodesInFrameClipPath( element, root, diff --git a/packages/excalidraw/scene/comparisons.ts b/packages/excalidraw/scene/comparisons.ts index 551aa2e6e..cb14d5810 100644 --- a/packages/excalidraw/scene/comparisons.ts +++ b/packages/excalidraw/scene/comparisons.ts @@ -42,7 +42,8 @@ export const canChangeRoundness = (type: ElementOrToolType) => type === "embeddable" || type === "arrow" || type === "line" || - type === "diamond"; + type === "diamond" || + type === "image"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow"; diff --git a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap index 72b379b8a..57dff6c1c 100644 --- a/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/export.test.tsx.snap @@ -21,5 +21,5 @@ exports[`export > exporting svg containing transformed images > svg export outpu - " + " `; From d426cc968d49071749c0d831490501cf572eb571 Mon Sep 17 00:00:00 2001 From: Milos Vetesnik Date: Mon, 29 Jan 2024 16:37:09 +0100 Subject: [PATCH 052/112] refactor: remove portal as it is no longer needed (#7623) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .env.development | 5 +---- .env.production | 7 ++----- excalidraw-app/collab/Collab.tsx | 9 ++------- excalidraw-app/data/index.ts | 29 ---------------------------- excalidraw-app/tests/collab.test.tsx | 11 ----------- 5 files changed, 5 insertions(+), 56 deletions(-) diff --git a/.env.development b/.env.development index 44955884f..bab59ee07 100644 --- a/.env.development +++ b/.env.development @@ -5,10 +5,7 @@ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room) -VITE_APP_WS_SERVER_URL=http://localhost:3002 - -# set this only if using the collaboration workflow we use on excalidraw.com -VITE_APP_PORTAL_URL= +VITE_APP_WS_SERVER_URL=http://localhost:3020 VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com diff --git a/.env.production b/.env.production index 26b46a52a..0c715854a 100644 --- a/.env.production +++ b/.env.production @@ -4,16 +4,13 @@ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries -VITE_APP_PORTAL_URL=https://portal.excalidraw.com - VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com -# Fill to set socket server URL used for collaboration. -# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow -VITE_APP_WS_SERVER_URL= +# socket server URL used for collaboration +VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}' diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 92d94dbc9..267dee66c 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -36,7 +36,6 @@ import { import { generateCollaborationLinkData, getCollaborationLink, - getCollabServer, getSyncableElements, SocketUpdateDataSource, SyncableExcalidrawElement, @@ -452,13 +451,9 @@ class Collab extends PureComponent { this.fallbackInitializationHandler = fallbackInitializationHandler; try { - const socketServerData = await getCollabServer(); - this.portal.socket = this.portal.open( - socketIOClient(socketServerData.url, { - transports: socketServerData.polling - ? ["websocket", "polling"] - : ["websocket"], + socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, { + transports: ["websocket", "polling"], }), roomId, roomKey, diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 0f54ee880..5699568b4 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -65,35 +65,6 @@ const generateRoomId = async () => { return bytesToHexString(buffer); }; -/** - * Right now the reason why we resolve connection params (url, polling...) - * from upstream is to allow changing the params immediately when needed without - * having to wait for clients to update the SW. - * - * If REACT_APP_WS_SERVER_URL env is set, we use that instead (useful for forks) - */ -export const getCollabServer = async (): Promise<{ - url: string; - polling: boolean; -}> => { - if (import.meta.env.VITE_APP_WS_SERVER_URL) { - return { - url: import.meta.env.VITE_APP_WS_SERVER_URL, - polling: true, - }; - } - - try { - const resp = await fetch( - `${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`, - ); - return await resp.json(); - } catch (error) { - console.error(error); - throw new Error(t("errors.cannotResolveCollabServer")); - } -}; - export type EncryptedData = { data: ArrayBuffer; iv: Uint8Array; diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 455316aed..c3e94a5ef 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -20,17 +20,6 @@ Object.defineProperty(window, "crypto", { }, }); -vi.mock("../../excalidraw-app/data/index.ts", async (importActual) => { - const module = (await importActual()) as any; - return { - __esmodule: true, - ...module, - getCollabServer: vi.fn(() => ({ - url: /* doesn't really matter */ "http://localhost:3002", - })), - }; -}); - vi.mock("../../excalidraw-app/data/firebase.ts", () => { const loadFromFirebase = async () => null; const saveToFirebase = () => {}; From e0fefa8025901ff73cb6b690bed3c73072c6f89a Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 31 Jan 2024 16:43:37 +0530 Subject: [PATCH 053/112] fix: don't bundle react-dom when importing from element (#7635) --- packages/excalidraw/components/App.tsx | 2 +- packages/excalidraw/element/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 71bfaa5d5..28daae36d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -115,7 +115,6 @@ import { newLinearElement, newTextElement, newImageElement, - textWysiwyg, transformElements, updateTextElement, redrawTextBoundingBox, @@ -409,6 +408,7 @@ import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; +import { textWysiwyg } from "../element/textWysiwyg"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 37d6a077b..093ef4829 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -50,7 +50,6 @@ export { dragNewElement, } from "./dragElements"; export { isTextElement, isExcalidrawElement } from "./typeChecks"; -export { textWysiwyg } from "./textWysiwyg"; export { redrawTextBoundingBox } from "./textElement"; export { getPerfectElementSize, From 63b50b3586be121125db4feefbade4096120fd83 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 31 Jan 2024 16:50:35 +0530 Subject: [PATCH 054/112] fix: don't bundle react-dom when importing from transformHandles (#7634) * fix: don't bundle react when importing from transfromHandles * rename to DEFAULT_TRANSFORM_HANDLE_SPACING --- packages/excalidraw/constants.ts | 1 + packages/excalidraw/element/transformHandles.ts | 9 +++++---- packages/excalidraw/renderer/renderScene.ts | 13 ++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index c4df44797..021c706a9 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -142,6 +142,7 @@ export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil; export const DEFAULT_TEXT_ALIGN = "left"; export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; +export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; export const CANVAS_ONLY_ACTIONS = ["selectAll"]; diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 00ebfacfd..19c60a93f 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -9,7 +9,7 @@ import { rotate } from "../math"; import { InteractiveCanvasAppState, Zoom } from "../types"; import { isTextElement } from "."; import { isFrameLikeElement, isLinearElement } from "./typeChecks"; -import { DEFAULT_SPACING } from "../renderer/renderScene"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants"; export type TransformHandleDirection = | "n" @@ -106,7 +106,8 @@ export const getTransformHandlesFromCoords = ( const width = x2 - x1; const height = y2 - y1; const dashedLineMargin = margin / zoom.value; - const centeringOffset = (size - DEFAULT_SPACING * 2) / (2 * zoom.value); + const centeringOffset = + (size - DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / (2 * zoom.value); const transformHandles: TransformHandles = { nw: omitSides.nw @@ -263,8 +264,8 @@ export const getTransformHandles = ( }; } const dashedLineMargin = isLinearElement(element) - ? DEFAULT_SPACING + 8 - : DEFAULT_SPACING; + ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 + : DEFAULT_TRANSFORM_HANDLE_SPACING; return getTransformHandlesFromCoords( getElementAbsoluteCoords(element, true), element.angle, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 0fa56829f..d31d69650 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -64,7 +64,11 @@ import { } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; import { UserIdleState } from "../types"; -import { FRAME_STYLE, THEME_FILTER } from "../constants"; +import { + DEFAULT_TRANSFORM_HANDLE_SPACING, + FRAME_STYLE, + THEME_FILTER, +} from "../constants"; import { EXTERNAL_LINK_IMG, getLinkHandleFromCoords, @@ -83,8 +87,6 @@ import { isElementInFrame, } from "../frame"; -export const DEFAULT_SPACING = 2; - const strokeRectWithRotation = ( context: CanvasRenderingContext2D, x: number, @@ -676,7 +678,8 @@ const _renderInteractiveScene = ({ ); } } else if (selectedElements.length > 1 && !appState.isRotating) { - const dashedLinePadding = (DEFAULT_SPACING * 2) / appState.zoom.value; + const dashedLinePadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; context.fillStyle = oc.white; const [x1, y1, x2, y2] = getCommonBounds(selectedElements); const initialLineDash = context.getLineDash(); @@ -1191,7 +1194,7 @@ const renderSelectionBorder = ( cy: number; activeEmbeddable: boolean; }, - padding = DEFAULT_SPACING * 2, + padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, ) => { const { angle, From 1741c234a686983558f01d0ff449251f2810b41c Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 31 Jan 2024 21:17:41 +0530 Subject: [PATCH 055/112] fix: decouple container cache logic to containerCache. (#7637) --- .../excalidraw/actions/actionBoundText.tsx | 2 +- packages/excalidraw/element/containerCache.ts | 33 +++++++++++++++++ packages/excalidraw/element/textElement.ts | 5 ++- .../excalidraw/element/textWysiwyg.test.tsx | 2 +- packages/excalidraw/element/textWysiwyg.tsx | 37 ++----------------- 5 files changed, 42 insertions(+), 37 deletions(-) create mode 100644 packages/excalidraw/element/containerCache.ts diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 05dd9c786..722ad5111 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -17,7 +17,7 @@ import { getOriginalContainerHeightFromCache, resetOriginalContainerCache, updateOriginalContainerCache, -} from "../element/textWysiwyg"; +} from "../element/containerCache"; import { hasBoundTextElement, isTextBindableContainer, diff --git a/packages/excalidraw/element/containerCache.ts b/packages/excalidraw/element/containerCache.ts new file mode 100644 index 000000000..c744f6c8e --- /dev/null +++ b/packages/excalidraw/element/containerCache.ts @@ -0,0 +1,33 @@ +import { ExcalidrawTextContainer } from "./types"; + +export const originalContainerCache: { + [id: ExcalidrawTextContainer["id"]]: + | { + height: ExcalidrawTextContainer["height"]; + } + | undefined; +} = {}; + +export const updateOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], + height: ExcalidrawTextContainer["height"], +) => { + const data = + originalContainerCache[id] || (originalContainerCache[id] = { height }); + data.height = height; + return data; +}; + +export const resetOriginalContainerCache = ( + id: ExcalidrawTextContainer["id"], +) => { + if (originalContainerCache[id]) { + delete originalContainerCache[id]; + } +}; + +export const getOriginalContainerHeightFromCache = ( + id: ExcalidrawTextContainer["id"], +) => { + return originalContainerCache[id]?.height ?? null; +}; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index b264c0d59..fc4c15f2d 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -31,11 +31,12 @@ import { isTextBindableContainer } from "./typeChecks"; import { getElementAbsoluteCoords } from "."; import { getSelectedElements } from "../scene"; import { isHittingElementNotConsideringBoundingBox } from "./collision"; + +import { ExtractSetType } from "../utility-types"; import { resetOriginalContainerCache, updateOriginalContainerCache, -} from "./textWysiwyg"; -import { ExtractSetType } from "../utility-types"; +} from "./containerCache"; export const normalizeText = (text: string) => { return ( diff --git a/packages/excalidraw/element/textWysiwyg.test.tsx b/packages/excalidraw/element/textWysiwyg.test.tsx index e6b0aa0b2..478fe5c1a 100644 --- a/packages/excalidraw/element/textWysiwyg.test.tsx +++ b/packages/excalidraw/element/textWysiwyg.test.tsx @@ -17,7 +17,7 @@ import { } from "./types"; import { API } from "../tests/helpers/api"; import { mutateElement } from "./mutateElement"; -import { getOriginalContainerHeightFromCache } from "./textWysiwyg"; +import { getOriginalContainerHeightFromCache } from "./containerCache"; import { getTextEditor, updateTextEditor } from "../tests/queries/dom"; // Unmount ReactDOM from root diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index d12d34f89..1a628dd46 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -17,7 +17,6 @@ import { ExcalidrawLinearElement, ExcalidrawTextElementWithContainer, ExcalidrawTextElement, - ExcalidrawTextContainer, } from "./types"; import { AppState } from "../types"; import { bumpVersion, mutateElement } from "./mutateElement"; @@ -44,6 +43,10 @@ import { actionZoomIn, actionZoomOut } from "../actions/actionCanvas"; import App from "../components/App"; import { LinearElementEditor } from "./linearElementEditor"; import { parseClipboard } from "../clipboard"; +import { + originalContainerCache, + updateOriginalContainerCache, +} from "./containerCache"; const getTransform = ( width: number, @@ -66,38 +69,6 @@ const getTransform = ( return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; }; -const originalContainerCache: { - [id: ExcalidrawTextContainer["id"]]: - | { - height: ExcalidrawTextContainer["height"]; - } - | undefined; -} = {}; - -export const updateOriginalContainerCache = ( - id: ExcalidrawTextContainer["id"], - height: ExcalidrawTextContainer["height"], -) => { - const data = - originalContainerCache[id] || (originalContainerCache[id] = { height }); - data.height = height; - return data; -}; - -export const resetOriginalContainerCache = ( - id: ExcalidrawTextContainer["id"], -) => { - if (originalContainerCache[id]) { - delete originalContainerCache[id]; - } -}; - -export const getOriginalContainerHeightFromCache = ( - id: ExcalidrawTextContainer["id"], -) => { - return originalContainerCache[id]?.height ?? null; -}; - export const textWysiwyg = ({ id, onChange, From 90ad885446314bfb9efa62e46988354f1dc2daaa Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 1 Feb 2024 17:56:55 +0530 Subject: [PATCH 056/112] feat: support onPointerUp prop (#7638) * feat: support onPointerUp prop * update changelog * Update packages/excalidraw/CHANGELOG.md Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --------- Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/CHANGELOG.md | 5 ++++- packages/excalidraw/components/App.tsx | 1 + packages/excalidraw/index.tsx | 2 ++ packages/excalidraw/types.ts | 4 ++++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 9f59bd4af..d2c40c25e 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -13,8 +13,11 @@ Please add the latest change on the top under the correct section. ## Unreleased +### Features + +- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). + - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) -- Remove `ExcalidrawEmbeddableElement.validated` attribute. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) ### Breaking Changes diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 28daae36d..462e803f1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -7564,6 +7564,7 @@ class App extends React.Component { this.setState({ pendingImageElementId: null }); } + this.props?.onPointerUp?.(activeTool, pointerDownState); this.onPointerUpEmitter.trigger( this.state.activeTool, pointerDownState, diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index b45084693..f7be8affc 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { generateIdForFile, onLinkOpen, onPointerDown, + onPointerUp, onScrollChange, children, validateEmbeddable, @@ -131,6 +132,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { generateIdForFile={generateIdForFile} onLinkOpen={onLinkOpen} onPointerDown={onPointerDown} + onPointerUp={onPointerUp} onScrollChange={onScrollChange} validateEmbeddable={validateEmbeddable} renderEmbeddable={renderEmbeddable} diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 201a186ba..ddd799fb9 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -456,6 +456,10 @@ export interface ExcalidrawProps { activeTool: AppState["activeTool"], pointerDownState: PointerDownState, ) => void; + onPointerUp?: ( + activeTool: AppState["activeTool"], + pointerDownState: PointerDownState, + ) => void; onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void; onUserFollow?: (payload: OnUserFollowedPayload) => void; children?: React.ReactNode; From 1c39bd57816f1de2b51c35e73a5d0e21b1a74fab Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 1 Feb 2024 18:24:17 +0530 Subject: [PATCH 057/112] fix: don't bundle react and jotai when importing from scene (#7640) * don't bundle react and jotai when importing from scene * fix --- packages/excalidraw/components/App.tsx | 2 +- packages/excalidraw/scene/index.ts | 1 - packages/excalidraw/types.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 462e803f1..c357b4ca3 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -216,7 +216,6 @@ import { getNormalizedZoom, getSelectedElements, hasBackground, - isOverScrollBars, isSomeElementSelected, } from "../scene"; import Scene from "../scene/Scene"; @@ -409,6 +408,7 @@ import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { textWysiwyg } from "../element/textWysiwyg"; +import { isOverScrollBars } from "../scene/scrollbars"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); diff --git a/packages/excalidraw/scene/index.ts b/packages/excalidraw/scene/index.ts index 5a7b9028a..33399d79e 100644 --- a/packages/excalidraw/scene/index.ts +++ b/packages/excalidraw/scene/index.ts @@ -1,4 +1,3 @@ -export { isOverScrollBars } from "./scrollbars"; export { isSomeElementSelected, getElementsWithinSelection, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index ddd799fb9..e29bb9f89 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -31,7 +31,7 @@ import type { throttleRAF } from "./utils"; import { Spreadsheet } from "./charts"; import { Language } from "./i18n"; import { ClipboardData } from "./clipboard"; -import { isOverScrollBars } from "./scene"; +import { isOverScrollBars } from "./scene/scrollbars"; import { MaybeTransformHandleType } from "./element/transformHandles"; import Library from "./data/library"; import type { FileSystemHandle } from "./data/filesystem"; From 4888d9d355cb847803cfb15bb1563f99c960b4f7 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 1 Feb 2024 14:41:38 +0100 Subject: [PATCH 058/112] chore: change default port of collab server (#7641) --- .env.development | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.development b/.env.development index bab59ee07..95e21ff87 100644 --- a/.env.development +++ b/.env.development @@ -5,7 +5,7 @@ VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries # collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room) -VITE_APP_WS_SERVER_URL=http://localhost:3020 +VITE_APP_WS_SERVER_URL=http://localhost:3002 VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=https://app.excalidraw.com From 0e0f34edd89ca16273b175d16c87831f72dd97b9 Mon Sep 17 00:00:00 2001 From: Milos Vetesnik Date: Thu, 1 Feb 2024 15:03:15 +0100 Subject: [PATCH 059/112] fix: follow mode border for hosts apps (#7642) --- .../components/FollowMode/FollowMode.tsx | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/excalidraw/components/FollowMode/FollowMode.tsx b/packages/excalidraw/components/FollowMode/FollowMode.tsx index da91ad42e..dc1746ca8 100644 --- a/packages/excalidraw/components/FollowMode/FollowMode.tsx +++ b/packages/excalidraw/components/FollowMode/FollowMode.tsx @@ -16,25 +16,20 @@ const FollowMode = ({ onDisconnect, }: FollowModeProps) => { return ( -
    -
    -
    -
    - Following{" "} - - {userToFollow.username} - -
    - + {userToFollow.username} +
    +
    ); From 0c3dffb082c85552758459444a4777dc50a33326 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 1 Feb 2024 21:12:10 +0530 Subject: [PATCH 060/112] fix: make getEmbedLink independent of t function (#7643) * fix: make getEmbedLink independent of t function * rename warning to error and make it type safe --- packages/excalidraw/components/App.tsx | 7 +++++-- packages/excalidraw/element/Hyperlink.tsx | 7 +++++-- packages/excalidraw/element/embeddable.ts | 7 +++---- packages/excalidraw/element/types.ts | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c357b4ca3..f965a7679 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -6501,8 +6501,11 @@ class App extends React.Component { return; } - if (embedLink.warning) { - this.setToast({ message: embedLink.warning, closable: true }); + if (embedLink.error instanceof URIError) { + this.setToast({ + message: t("toast.unrecognizedLinkFormat"), + closable: true, + }); } const element = newEmbeddableElement({ diff --git a/packages/excalidraw/element/Hyperlink.tsx b/packages/excalidraw/element/Hyperlink.tsx index a69fdeb83..930b87763 100644 --- a/packages/excalidraw/element/Hyperlink.tsx +++ b/packages/excalidraw/element/Hyperlink.tsx @@ -120,8 +120,11 @@ export const Hyperlink = ({ } else { const { width, height } = element; const embedLink = getEmbedLink(link); - if (embedLink?.warning) { - setToast({ message: embedLink.warning, closable: true }); + if (embedLink?.error instanceof URIError) { + setToast({ + message: t("toast.unrecognizedLinkFormat"), + closable: true, + }); } const ar = embedLink ? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index f62b0f95f..fb51c7283 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -1,6 +1,5 @@ import { register } from "../actions/register"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; -import { t } from "../i18n"; import { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; @@ -107,8 +106,8 @@ export const getEmbedLink = ( const vimeoLink = link.match(RE_VIMEO); if (vimeoLink?.[1]) { const target = vimeoLink?.[1]; - const warning = !/^\d+$/.test(target) - ? t("toast.unrecognizedLinkFormat") + const error = !/^\d+$/.test(target) + ? new URIError("Invalid embed link format") : undefined; type = "video"; link = `https://player.vimeo.com/video/${target}?api=1`; @@ -120,7 +119,7 @@ export const getEmbedLink = ( intrinsicSize: aspectRatio, type, }); - return { link, intrinsicSize: aspectRatio, type, warning }; + return { link, intrinsicSize: aspectRatio, type, error }; } const figmaLink = link.match(RE_FIGMA); diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index f89e8d5f2..aae0a8a30 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -104,7 +104,7 @@ export type ExcalidrawIframeLikeElement = export type IframeData = | { intrinsicSize: { w: number; h: number }; - warning?: string; + error?: Error; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } From d67eaa8710a431f3e9bb9cdf3cef6d900f5edded Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 3 Feb 2024 11:53:35 +0100 Subject: [PATCH 061/112] fix: file save timing out with big file sizes (#7649) --- packages/excalidraw/data/filesystem.ts | 2 +- packages/excalidraw/data/index.ts | 32 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index fa29604f4..11f64d23e 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -76,7 +76,7 @@ export const fileOpen = (opts: { }; export const fileSave = ( - blob: Blob, + blob: Blob | Promise, opts: { /** supply without the extension */ name: string; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 0c63053a9..fa2ec9de6 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -100,7 +100,7 @@ export const exportCanvas = async ( throw new Error(t("alerts.cannotExportEmptyCanvas")); } if (type === "svg" || type === "clipboard-svg") { - const tempSvg = await exportToSvg( + const svgPromise = exportToSvg( elements, { exportBackground, @@ -113,9 +113,12 @@ export const exportCanvas = async ( files, { exportingFrame }, ); + if (type === "svg") { - return await fileSave( - new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }), + return fileSave( + svgPromise.then((svg) => { + return new Blob([svg.outerHTML], { type: MIME_TYPES.svg }); + }), { description: "Export to SVG", name, @@ -124,7 +127,9 @@ export const exportCanvas = async ( }, ); } else if (type === "clipboard-svg") { - await copyTextToSystemClipboard(tempSvg.outerHTML); + await copyTextToSystemClipboard( + await svgPromise.then((svg) => svg.outerHTML), + ); return; } } @@ -137,17 +142,20 @@ export const exportCanvas = async ( }); if (type === "png") { - let blob = await canvasToBlob(tempCanvas); + let blob = canvasToBlob(tempCanvas); + if (appState.exportEmbedScene) { - blob = await ( - await import("./image") - ).encodePngMetadata({ - blob, - metadata: serializeAsJSON(elements, appState, files, "local"), - }); + blob = blob.then((blob) => + import("./image").then(({ encodePngMetadata }) => + encodePngMetadata({ + blob, + metadata: serializeAsJSON(elements, appState, files, "local"), + }), + ), + ); } - return await fileSave(blob, { + return fileSave(blob, { description: "Export to PNG", name, // FIXME reintroduce `excalidraw.png` when most people upgrade away From a289c42830ea6b458a520c861dc5aaa95299c726 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 3 Feb 2024 14:53:31 +0100 Subject: [PATCH 062/112] feat: add loading state to FilledButton (#7650) --- excalidraw-app/collab/RoomDialog.tsx | 8 +- packages/excalidraw/actions/manager.tsx | 3 +- .../excalidraw/components/FilledButton.scss | 76 ++++++++++++++++--- .../excalidraw/components/FilledButton.tsx | 50 +++++++++--- .../components/ImageExportDialog.scss | 2 + .../components/ImageExportDialog.tsx | 6 +- .../components/ShareableLinkDialog.tsx | 2 +- packages/excalidraw/components/ToolButton.tsx | 3 +- 8 files changed, 119 insertions(+), 31 deletions(-) diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx index 48bc12446..f2614674d 100644 --- a/excalidraw-app/collab/RoomDialog.tsx +++ b/excalidraw-app/collab/RoomDialog.tsx @@ -120,7 +120,7 @@ export const RoomModal = ({ size="large" variant="icon" label="Share" - startIcon={getShareIcon()} + icon={getShareIcon()} className="RoomDialog__active__share" onClick={shareRoomLink} /> @@ -130,7 +130,7 @@ export const RoomModal = ({ @@ -166,7 +166,7 @@ export const RoomModal = ({ variant="outlined" color="danger" label={t("roomDialog.button_stopSession")} - startIcon={playerStopFilledIcon} + icon={playerStopFilledIcon} onClick={() => { trackEvent("share", "room closed"); onRoomDestroy(); @@ -195,7 +195,7 @@ export const RoomModal = ({ { trackEvent("share", "room creation", `ui (${getFrame()})`); onRoomCreate(); diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index fc56d1bda..90dfe6088 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -10,6 +10,7 @@ import { import { ExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; +import { isPromiseLike } from "../utils"; const trackAction = ( action: Action, @@ -55,7 +56,7 @@ export class ActionManager { app: AppClassProperties, ) { this.updater = (actionResult) => { - if (actionResult && "then" in actionResult) { + if (isPromiseLike(actionResult)) { actionResult.then((actionResult) => { return updater(actionResult); }); diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index bfa443f89..5891698e8 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -10,11 +10,39 @@ background-color: var(--back-color); border-color: var(--border-color); + .Spinner { + --spinner-color: var(--color-surface-lowest); + position: absolute; + visibility: visible; + } + + &[disabled] { + pointer-events: none; + + .ExcButton__contents { + visibility: hidden; + } + } + + &__contents { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + flex-wrap: nowrap; + // needed because of .Spinner + position: relative; + } + &--color-primary { &.ExcButton--variant-filled { --text-color: var(--color-surface-lowest); --back-color: var(--color-primary); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-brand-hover); } @@ -27,9 +55,13 @@ &.ExcButton--variant-outlined, &.ExcButton--variant-icon { --text-color: var(--color-primary); - --border-color: var(--color-border-outline); + --border-color: var(--color-primary); --back-color: transparent; + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-brand-hover); --border-color: var(--color-brand-hover); @@ -47,6 +79,10 @@ --text-color: var(--color-danger-text); --back-color: var(--color-danger-dark); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-danger-darker); } @@ -62,6 +98,10 @@ --border-color: var(--color-danger); --back-color: transparent; + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-danger-darkest); --border-color: var(--color-danger-darkest); @@ -79,6 +119,10 @@ --text-color: var(--island-bg-color); --back-color: var(--color-gray-50); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-gray-60); } @@ -94,6 +138,10 @@ --border-color: var(--color-muted); --back-color: var(--island-bg-color); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-muted-background-darker); --border-color: var(--color-muted-darker); @@ -111,6 +159,10 @@ --text-color: black; --back-color: var(--color-warning-dark); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --back-color: var(--color-warning-darker); } @@ -126,6 +178,10 @@ --border-color: var(--color-warning-dark); --back-color: var(--input-bg-color); + .Spinner { + --spinner-color: var(--text-color); + } + &:hover { --text-color: var(--color-warning-darker); --border-color: var(--color-warning-darker); @@ -138,17 +194,11 @@ } } - display: flex; - justify-content: center; - align-items: center; - flex-shrink: 0; - flex-wrap: nowrap; - border-radius: 0.5rem; border-width: 1px; border-style: solid; - font-family: "Assistant"; + font-family: var(--font-family); user-select: none; @@ -159,9 +209,12 @@ font-size: 0.875rem; min-height: 3rem; padding: 0.5rem 1.5rem; - gap: 0.75rem; letter-spacing: 0.4px; + + .ExcButton__contents { + gap: 0.75rem; + } } &--size-medium { @@ -169,9 +222,12 @@ font-size: 0.75rem; min-height: 2.5rem; padding: 0.5rem 1rem; - gap: 0.5rem; letter-spacing: normal; + + .ExcButton__contents { + gap: 0.5rem; + } } &--variant-icon { diff --git a/packages/excalidraw/components/FilledButton.tsx b/packages/excalidraw/components/FilledButton.tsx index 3f844cf37..ff17db623 100644 --- a/packages/excalidraw/components/FilledButton.tsx +++ b/packages/excalidraw/components/FilledButton.tsx @@ -1,7 +1,10 @@ -import React, { forwardRef } from "react"; +import React, { forwardRef, useState } from "react"; import clsx from "clsx"; import "./FilledButton.scss"; +import { AbortError } from "../errors"; +import Spinner from "./Spinner"; +import { isPromiseLike } from "../utils"; export type ButtonVariant = "filled" | "outlined" | "icon"; export type ButtonColor = "primary" | "danger" | "warning" | "muted"; @@ -11,7 +14,7 @@ export type FilledButtonProps = { label: string; children?: React.ReactNode; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; variant?: ButtonVariant; color?: ButtonColor; @@ -19,14 +22,14 @@ export type FilledButtonProps = { className?: string; fullWidth?: boolean; - startIcon?: React.ReactNode; + icon?: React.ReactNode; }; export const FilledButton = forwardRef( ( { children, - startIcon, + icon, onClick, label, variant = "filled", @@ -37,6 +40,27 @@ export const FilledButton = forwardRef( }, ref, ) => { + const [isLoading, setIsLoading] = useState(false); + + const _onClick = async (event: React.MouseEvent) => { + const ret = onClick?.(event); + + if (isPromiseLike(ret)) { + try { + setIsLoading(true); + await ret; + } catch (error: any) { + if (!(error instanceof AbortError)) { + throw error; + } else { + console.warn(error); + } + } finally { + setIsLoading(false); + } + } + }; + return ( ); }, diff --git a/packages/excalidraw/components/ImageExportDialog.scss b/packages/excalidraw/components/ImageExportDialog.scss index c99836599..ea9e74f80 100644 --- a/packages/excalidraw/components/ImageExportDialog.scss +++ b/packages/excalidraw/components/ImageExportDialog.scss @@ -12,6 +12,8 @@ flex-direction: row; justify-content: space-between; + user-select: none; + & h3 { font-family: "Assistant"; font-style: normal; diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index d0df35193..7ca54e985 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -271,7 +271,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={downloadIcon} + icon={downloadIcon} > {t("imageExportDialog.button.exportToPng")} @@ -283,7 +283,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={downloadIcon} + icon={downloadIcon} > {t("imageExportDialog.button.exportToSvg")} @@ -296,7 +296,7 @@ const ImageExportModal = ({ exportingFrame, }) } - startIcon={copyIcon} + icon={copyIcon} > {t("imageExportDialog.button.copyPngToClipboard")} diff --git a/packages/excalidraw/components/ShareableLinkDialog.tsx b/packages/excalidraw/components/ShareableLinkDialog.tsx index 7a53a4a82..cb8ba4cef 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.tsx +++ b/packages/excalidraw/components/ShareableLinkDialog.tsx @@ -66,7 +66,7 @@ export const ShareableLinkDialog = ({ diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx index ffe9a382c..2dace89d7 100644 --- a/packages/excalidraw/components/ToolButton.tsx +++ b/packages/excalidraw/components/ToolButton.tsx @@ -6,6 +6,7 @@ import { useExcalidrawContainer } from "./App"; import { AbortError } from "../errors"; import Spinner from "./Spinner"; import { PointerType } from "../element/types"; +import { isPromiseLike } from "../utils"; export type ToolButtonSize = "small" | "medium"; @@ -65,7 +66,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { const onClick = async (event: React.MouseEvent) => { const ret = "onClick" in props && props.onClick?.(event); - if (ret && "then" in ret) { + if (isPromiseLike(ret)) { try { setIsLoading(true); await ret; From 0513b647ec13bc2688eaf75d41c2b45049f2624b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 3 Feb 2024 15:04:23 +0100 Subject: [PATCH 063/112] feat: change collab trigger & add share dialog (#7647) --- excalidraw-app/App.tsx | 88 ++++-- excalidraw-app/collab/Collab.tsx | 91 +++--- excalidraw-app/components/AppMainMenu.tsx | 4 +- .../components/AppWelcomeScreen.tsx | 4 +- .../ShareDialog.scss} | 27 +- excalidraw-app/share/ShareDialog.tsx | 290 ++++++++++++++++++ packages/excalidraw/assets/lock.svg | 20 -- packages/excalidraw/components/Button.tsx | 1 + .../excalidraw/components/FilledButton.scss | 1 + .../components/JSONExportDialog.tsx | 2 +- .../components/ShareableLinkDialog.scss | 4 +- packages/excalidraw/components/TextField.tsx | 17 +- .../LiveCollaborationTrigger.scss | 8 +- .../LiveCollaborationTrigger.tsx | 8 +- packages/excalidraw/css/variables.module.scss | 1 + packages/excalidraw/locales/en.json | 7 +- packages/excalidraw/types.ts | 1 - packages/excalidraw/utils.ts | 2 +- 18 files changed, 440 insertions(+), 136 deletions(-) rename excalidraw-app/{collab/RoomDialog.scss => share/ShareDialog.scss} (82%) create mode 100644 excalidraw-app/share/ShareDialog.tsx delete mode 100644 packages/excalidraw/assets/lock.svg diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 4a3d42847..e38dd7a94 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -1,6 +1,6 @@ import polyfill from "../packages/excalidraw/polyfill"; import LanguageDetector from "i18next-browser-languagedetector"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { trackEvent } from "../packages/excalidraw/analytics"; import { getDefaultAppState } from "../packages/excalidraw/appState"; import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog"; @@ -54,7 +54,6 @@ import { import Collab, { CollabAPI, collabAPIAtom, - collabDialogShownAtom, isCollaboratingAtom, isOfflineAtom, } from "./collab/Collab"; @@ -104,6 +103,7 @@ import { ShareableLinkDialog } from "../packages/excalidraw/components/Shareable import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; +import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; polyfill(); @@ -305,8 +305,8 @@ const ExcalidrawWrapper = () => { const [excalidrawAPI, excalidrawRefCallback] = useCallbackRefState(); + const [, setShareDialogState] = useAtom(shareDialogStateAtom); const [collabAPI] = useAtom(collabAPIAtom); - const [, setCollabDialogShown] = useAtom(collabDialogShownAtom); const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); @@ -607,37 +607,38 @@ const ExcalidrawWrapper = () => { exportedElements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, - canvas: HTMLCanvasElement, ) => { if (exportedElements.length === 0) { throw new Error(t("alerts.cannotExportEmptyCanvas")); } - if (canvas) { - try { - const { url, errorMessage } = await exportToBackend( - exportedElements, - { - ...appState, - viewBackgroundColor: appState.exportBackground - ? appState.viewBackgroundColor - : getDefaultAppState().viewBackgroundColor, - }, - files, - ); + try { + const { url, errorMessage } = await exportToBackend( + exportedElements, + { + ...appState, + viewBackgroundColor: appState.exportBackground + ? appState.viewBackgroundColor + : getDefaultAppState().viewBackgroundColor, + }, + files, + ); - if (errorMessage) { - throw new Error(errorMessage); - } + if (errorMessage) { + throw new Error(errorMessage); + } - if (url) { - setLatestShareableLink(url); - } - } catch (error: any) { - if (error.name !== "AbortError") { - const { width, height } = canvas; - console.error(error, { width, height }); - throw new Error(error.message); - } + if (url) { + setLatestShareableLink(url); + } + } catch (error: any) { + if (error.name !== "AbortError") { + const { width, height } = appState; + console.error(error, { + width, + height, + devicePixelRatio: window.devicePixelRatio, + }); + throw new Error(error.message); } } }; @@ -666,6 +667,11 @@ const ExcalidrawWrapper = () => { const isOffline = useAtomValue(isOfflineAtom); + const onCollabDialogOpen = useCallback( + () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), + [setShareDialogState], + ); + // browsers generally prevent infinite self-embedding, there are // cases where it still happens, and while we disallow self-embedding // by not whitelisting our own origin, this serves as an additional guard @@ -741,18 +747,20 @@ const ExcalidrawWrapper = () => { return ( setCollabDialogShown(true)} + onSelect={() => + setShareDialogState({ isOpen: true, type: "share" }) + } /> ); }} > @@ -848,6 +856,24 @@ const ExcalidrawWrapper = () => { {excalidrawAPI && !isCollabDisabled && ( )} + + { + if (excalidrawAPI) { + try { + await onExportToBackend( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + ); + } catch (error: any) { + setErrorMessage(error.message); + } + } + }} + /> + {errorMessage && ( setErrorMessage("")}> {errorMessage} diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 267dee66c..14538b674 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -52,7 +52,6 @@ import { saveUsernameToLocalStorage, } from "../data/localStorage"; import Portal from "./Portal"; -import RoomDialog from "./RoomDialog"; import { t } from "../../packages/excalidraw/i18n"; import { UserIdleState } from "../../packages/excalidraw/types"; import { @@ -77,23 +76,24 @@ import { import { decryptData } from "../../packages/excalidraw/data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; -import { atom, useAtom } from "jotai"; +import { atom } from "jotai"; import { appJotaiStore } from "../app-jotai"; import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; export const collabAPIAtom = atom(null); -export const collabDialogShownAtom = atom(false); export const isCollaboratingAtom = atom(false); export const isOfflineAtom = atom(false); interface CollabState { - errorMessage: string; + errorMessage: string | null; username: string; - activeRoomLink: string; + activeRoomLink: string | null; } +export const activeRoomLinkAtom = atom(null); + type CollabInstance = InstanceType; export interface CollabAPI { @@ -104,19 +104,20 @@ export interface CollabAPI { stopCollaboration: CollabInstance["stopCollaboration"]; syncElements: CollabInstance["syncElements"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; - setUsername: (username: string) => void; + setUsername: CollabInstance["setUsername"]; + getUsername: CollabInstance["getUsername"]; + getActiveRoomLink: CollabInstance["getActiveRoomLink"]; + setErrorMessage: CollabInstance["setErrorMessage"]; } -interface PublicProps { +interface CollabProps { excalidrawAPI: ExcalidrawImperativeAPI; } -type Props = PublicProps & { modalIsShown: boolean }; - -class Collab extends PureComponent { +class Collab extends PureComponent { portal: Portal; fileManager: FileManager; - excalidrawAPI: Props["excalidrawAPI"]; + excalidrawAPI: CollabProps["excalidrawAPI"]; activeIntervalId: number | null; idleTimeoutId: number | null; @@ -124,12 +125,12 @@ class Collab extends PureComponent { private lastBroadcastedOrReceivedSceneVersion: number = -1; private collaborators = new Map(); - constructor(props: Props) { + constructor(props: CollabProps) { super(props); this.state = { - errorMessage: "", + errorMessage: null, username: importUsernameFromLocalStorage() || "", - activeRoomLink: "", + activeRoomLink: null, }; this.portal = new Portal(this); this.fileManager = new FileManager({ @@ -194,6 +195,9 @@ class Collab extends PureComponent { fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, stopCollaboration: this.stopCollaboration, setUsername: this.setUsername, + getUsername: this.getUsername, + getActiveRoomLink: this.getActiveRoomLink, + setErrorMessage: this.setErrorMessage, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -341,9 +345,7 @@ class Collab extends PureComponent { this.fileManager.reset(); if (!opts?.isUnload) { this.setIsCollaborating(false); - this.setState({ - activeRoomLink: "", - }); + this.setActiveRoomLink(null); this.collaborators = new Map(); this.excalidrawAPI.updateScene({ collaborators: this.collaborators, @@ -409,7 +411,7 @@ class Collab extends PureComponent { if (!this.state.username) { import("@excalidraw/random-username").then(({ getRandomUsername }) => { const username = getRandomUsername(); - this.onUsernameChange(username); + this.setUsername(username); }); } @@ -624,9 +626,7 @@ class Collab extends PureComponent { this.initializeIdleDetector(); - this.setState({ - activeRoomLink: window.location.href, - }); + this.setActiveRoomLink(window.location.href); return scenePromise; }; @@ -909,41 +909,31 @@ class Collab extends PureComponent { { leading: false }, ); - handleClose = () => { - appJotaiStore.set(collabDialogShownAtom, false); - }; - setUsername = (username: string) => { this.setState({ username }); - }; - - onUsernameChange = (username: string) => { - this.setUsername(username); saveUsernameToLocalStorage(username); }; - render() { - const { username, errorMessage, activeRoomLink } = this.state; + getUsername = () => this.state.username; - const { modalIsShown } = this.props; + setActiveRoomLink = (activeRoomLink: string | null) => { + this.setState({ activeRoomLink }); + appJotaiStore.set(activeRoomLinkAtom, activeRoomLink); + }; + + getActiveRoomLink = () => this.state.activeRoomLink; + + setErrorMessage = (errorMessage: string | null) => { + this.setState({ errorMessage }); + }; + + render() { + const { errorMessage } = this.state; return ( <> - {modalIsShown && ( - this.startCollaboration(null)} - onRoomDestroy={this.stopCollaboration} - setErrorMessage={(errorMessage) => { - this.setState({ errorMessage }); - }} - /> - )} - {errorMessage && ( - this.setState({ errorMessage: "" })}> + {errorMessage != null && ( + this.setState({ errorMessage: null })}> {errorMessage} )} @@ -962,11 +952,6 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { window.collab = window.collab || ({} as Window["collab"]); } -const _Collab: React.FC = (props) => { - const [collabDialogShown] = useAtom(collabDialogShownAtom); - return ; -}; - -export default _Collab; +export default Collab; export type TCollabClass = Collab; diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 34a2ee3ae..6806c969c 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -4,7 +4,7 @@ import { MainMenu } from "../../packages/excalidraw/index"; import { LanguageList } from "./LanguageList"; export const AppMainMenu: React.FC<{ - setCollabDialogShown: (toggle: boolean) => any; + onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; }> = React.memo((props) => { @@ -17,7 +17,7 @@ export const AppMainMenu: React.FC<{ {props.isCollabEnabled && ( props.setCollabDialogShown(true)} + onSelect={() => props.onCollabDialogOpen()} /> )} diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx index a5176c2ff..f74bc14e2 100644 --- a/excalidraw-app/components/AppWelcomeScreen.tsx +++ b/excalidraw-app/components/AppWelcomeScreen.tsx @@ -6,7 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants"; import { POINTER_EVENTS } from "../../packages/excalidraw/constants"; export const AppWelcomeScreen: React.FC<{ - setCollabDialogShown: (toggle: boolean) => any; + onCollabDialogOpen: () => any; isCollabEnabled: boolean; }> = React.memo((props) => { const { t } = useI18n(); @@ -52,7 +52,7 @@ export const AppWelcomeScreen: React.FC<{ {props.isCollabEnabled && ( props.setCollabDialogShown(true)} + onSelect={() => props.onCollabDialogOpen()} /> )} {!isExcalidrawPlusSignedUser && ( diff --git a/excalidraw-app/collab/RoomDialog.scss b/excalidraw-app/share/ShareDialog.scss similarity index 82% rename from excalidraw-app/collab/RoomDialog.scss rename to excalidraw-app/share/ShareDialog.scss index 61624664b..87fde8491 100644 --- a/excalidraw-app/collab/RoomDialog.scss +++ b/excalidraw-app/share/ShareDialog.scss @@ -1,7 +1,7 @@ @import "../../packages/excalidraw/css/variables.module.scss"; .excalidraw { - .RoomDialog { + .ShareDialog { display: flex; flex-direction: column; gap: 1.5rem; @@ -10,8 +10,25 @@ height: calc(100vh - 5rem); } + &__separator { + border-top: 1px solid var(--dialog-border-color); + text-align: center; + display: flex; + justify-content: center; + align-items: center; + margin-top: 1em; + + span { + background: var(--island-bg-color); + padding: 0px 0.75rem; + transform: translateY(-1ch); + display: inline-flex; + line-height: 1; + } + } + &__popover { - @keyframes RoomDialog__popover__scaleIn { + @keyframes ShareDialog__popover__scaleIn { from { opacity: 0; } @@ -50,10 +67,10 @@ } transform-origin: var(--radix-popover-content-transform-origin); - animation: RoomDialog__popover__scaleIn 150ms ease-out; + animation: ShareDialog__popover__scaleIn 150ms ease-out; } - &__inactive { + &__picker { font-family: "Assistant"; &__illustration { @@ -95,7 +112,7 @@ } } - &__start_session { + &__button { display: flex; align-items: center; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx new file mode 100644 index 000000000..2fa92dff8 --- /dev/null +++ b/excalidraw-app/share/ShareDialog.tsx @@ -0,0 +1,290 @@ +import { useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard"; +import { trackEvent } from "../../packages/excalidraw/analytics"; +import { getFrame } from "../../packages/excalidraw/utils"; +import { useI18n } from "../../packages/excalidraw/i18n"; +import { KEYS } from "../../packages/excalidraw/keys"; +import { Dialog } from "../../packages/excalidraw/components/Dialog"; +import { + copyIcon, + LinkIcon, + playerPlayIcon, + playerStopFilledIcon, + share, + shareIOS, + shareWindows, + tablerCheckIcon, +} from "../../packages/excalidraw/components/icons"; +import { TextField } from "../../packages/excalidraw/components/TextField"; +import { FilledButton } from "../../packages/excalidraw/components/FilledButton"; +import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab"; +import { atom, useAtom, useAtomValue } from "jotai"; + +import "./ShareDialog.scss"; + +type OnExportToBackend = () => void; +type ShareDialogType = "share" | "collaborationOnly"; + +export const shareDialogStateAtom = atom< + { isOpen: false } | { isOpen: true; type: ShareDialogType } +>({ isOpen: false }); + +const getShareIcon = () => { + const navigator = window.navigator as any; + const isAppleBrowser = /Apple/.test(navigator.vendor); + const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1; + + if (isAppleBrowser) { + return shareIOS; + } else if (isWindowsBrowser) { + return shareWindows; + } + + return share; +}; + +export type ShareDialogProps = { + collabAPI: CollabAPI | null; + handleClose: () => void; + onExportToBackend: OnExportToBackend; + type: ShareDialogType; +}; + +const ActiveRoomDialog = ({ + collabAPI, + activeRoomLink, + handleClose, +}: { + collabAPI: CollabAPI; + activeRoomLink: string; + handleClose: () => void; +}) => { + const { t } = useI18n(); + const [justCopied, setJustCopied] = useState(false); + const timerRef = useRef(0); + const ref = useRef(null); + const isShareSupported = "share" in navigator; + + const copyRoomLink = async () => { + try { + await copyTextToSystemClipboard(activeRoomLink); + + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); + } catch (error: any) { + collabAPI.setErrorMessage(error.message); + } + + ref.current?.select(); + }; + + const shareRoomLink = async () => { + try { + await navigator.share({ + title: t("roomDialog.shareTitle"), + text: t("roomDialog.shareTitle"), + url: activeRoomLink, + }); + } catch (error: any) { + // Just ignore. + } + }; + + return ( + <> +

    + {t("labels.liveCollaboration").replace(/\./g, "")} +

    + event.key === KEYS.ENTER && handleClose()} + /> +
    + + {isShareSupported && ( + + )} + + + + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + className="ShareDialog__popover" + side="top" + align="end" + sideOffset={5.5} + > + {tablerCheckIcon} copied + + +
    +
    +

    + + {t("roomDialog.desc_privacy")} +

    +

    {t("roomDialog.desc_exitSession")}

    +
    + +
    + { + trackEvent("share", "room closed"); + collabAPI.stopCollaboration(); + if (!collabAPI.isCollaborating()) { + handleClose(); + } + }} + /> +
    + + ); +}; + +const ShareDialogPicker = (props: ShareDialogProps) => { + const { t } = useI18n(); + + const { collabAPI } = props; + + const startCollabJSX = collabAPI ? ( + <> +
    + {t("labels.liveCollaboration").replace(/\./g, "")} +
    + +
    +
    {t("roomDialog.desc_intro")}
    + {t("roomDialog.desc_privacy")} +
    + +
    + { + trackEvent("share", "room creation", `ui (${getFrame()})`); + collabAPI.startCollaboration(null); + }} + /> +
    + + {props.type === "share" && ( +
    + {t("shareDialog.or")} +
    + )} + + ) : null; + + return ( + <> + {startCollabJSX} + + {props.type === "share" && ( + <> +
    + {t("exportDialog.link_title")} +
    +
    + {t("exportDialog.link_details")} +
    + +
    + { + await props.onExportToBackend(); + props.handleClose(); + }} + /> +
    + + )} + + ); +}; + +const ShareDialogInner = (props: ShareDialogProps) => { + const activeRoomLink = useAtomValue(activeRoomLinkAtom); + + return ( + +
    + {props.collabAPI && activeRoomLink ? ( + + ) : ( + + )} +
    +
    + ); +}; + +export const ShareDialog = (props: { + collabAPI: CollabAPI | null; + onExportToBackend: OnExportToBackend; +}) => { + const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom); + + if (!shareDialogState.isOpen) { + return null; + } + + return ( + setShareDialogState({ isOpen: false })} + collabAPI={props.collabAPI} + onExportToBackend={props.onExportToBackend} + type={shareDialogState.type} + > + ); +}; diff --git a/packages/excalidraw/assets/lock.svg b/packages/excalidraw/assets/lock.svg deleted file mode 100644 index aa9dbf170..000000000 --- a/packages/excalidraw/assets/lock.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/packages/excalidraw/components/Button.tsx b/packages/excalidraw/components/Button.tsx index 43b6de9e1..779cee582 100644 --- a/packages/excalidraw/components/Button.tsx +++ b/packages/excalidraw/components/Button.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import React from "react"; import { composeEventHandlers } from "../utils"; import "./Button.scss"; diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index 5891698e8..70f75cbbb 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -24,6 +24,7 @@ } } + &, &__contents { display: flex; justify-content: center; diff --git a/packages/excalidraw/components/JSONExportDialog.tsx b/packages/excalidraw/components/JSONExportDialog.tsx index b5cea4af6..95f4117fc 100644 --- a/packages/excalidraw/components/JSONExportDialog.tsx +++ b/packages/excalidraw/components/JSONExportDialog.tsx @@ -78,7 +78,7 @@ const JSONExportModal = ({ onClick={async () => { try { trackEvent("export", "link", `ui (${getFrame()})`); - await onExportToBackend(elements, appState, files, canvas); + await onExportToBackend(elements, appState, files); onCloseRequest(); } catch (error: any) { setAppState({ errorMessage: error.message }); diff --git a/packages/excalidraw/components/ShareableLinkDialog.scss b/packages/excalidraw/components/ShareableLinkDialog.scss index 2b89f09d6..2429d50ca 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.scss +++ b/packages/excalidraw/components/ShareableLinkDialog.scss @@ -22,7 +22,7 @@ } &__popover { - @keyframes RoomDialog__popover__scaleIn { + @keyframes ShareableLinkDialog__popover__scaleIn { from { opacity: 0; } @@ -61,7 +61,7 @@ } transform-origin: var(--radix-popover-content-transform-origin); - animation: RoomDialog__popover__scaleIn 150ms ease-out; + animation: ShareableLinkDialog__popover__scaleIn 150ms ease-out; } &__linkRow { diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index 10b3d9b53..44a7c25ff 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -13,8 +13,6 @@ import { Button } from "./Button"; import { eyeIcon, eyeClosedIcon } from "./icons"; type TextFieldProps = { - value?: string; - onChange?: (value: string) => void; onClick?: () => void; onKeyDown?: (event: KeyboardEvent) => void; @@ -26,12 +24,11 @@ type TextFieldProps = { label?: string; placeholder?: string; isRedacted?: boolean; -}; +} & ({ value: string } | { defaultValue: string }); export const TextField = forwardRef( ( { - value, onChange, label, fullWidth, @@ -40,6 +37,7 @@ export const TextField = forwardRef( selectOnRender, onKeyDown, isRedacted = false, + ...rest }, ref, ) => { @@ -73,10 +71,17 @@ export const TextField = forwardRef( > onChange?.(event.target.value)} diff --git a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.scss b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.scss index edbcf198f..573fbccce 100644 --- a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.scss +++ b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.scss @@ -3,7 +3,7 @@ .excalidraw { .collab-button { --button-bg: var(--color-primary); - --button-color: white; + --button-color: var(--color-surface-lowest); --button-border: var(--color-primary); --button-width: var(--lg-button-size); @@ -35,12 +35,6 @@ } } - &.theme--dark { - .collab-button { - color: var(--color-gray-90); - } - } - .CollabButton.is-collaborating { background-color: var(--button-special-active-bg-color); diff --git a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx index 3111680cb..a22bc523a 100644 --- a/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx +++ b/packages/excalidraw/components/live-collaboration/LiveCollaborationTrigger.tsx @@ -1,5 +1,5 @@ import { t } from "../../i18n"; -import { usersIcon } from "../icons"; +import { share } from "../icons"; import { Button } from "../Button"; import clsx from "clsx"; @@ -17,16 +17,18 @@ const LiveCollaborationTrigger = ({ } & React.ButtonHTMLAttributes) => { const appState = useUIAppState(); + const showIconOnly = appState.width < 830; + return ( . Please include information below by copying and pasting into the GitHub issue.", "sceneContent": "Scene content:" }, + "shareDialog": { + "or": "Or" + }, "roomDialog": { - "desc_intro": "You can invite people to your current scene to collaborate with you.", - "desc_privacy": "Don't worry, the session uses end-to-end encryption, so whatever you draw will stay private. Not even our server will be able to see what you come up with.", + "desc_intro": "Invite people to collaborate on your drawing.", + "desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.", "button_startSession": "Start session", "button_stopSession": "Stop session", "desc_inProgressIntro": "Live-collaboration session is now in progress.", diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index e29bb9f89..89b121b2f 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -495,7 +495,6 @@ export type ExportOpts = { exportedElements: readonly NonDeletedExcalidrawElement[], appState: UIAppState, files: BinaryFiles, - canvas: HTMLCanvasElement, ) => void; renderCustomUI?: ( exportedElements: readonly NonDeletedExcalidrawElement[], diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 47fa52311..525652e6b 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -845,7 +845,7 @@ export const composeEventHandlers = ( if ( !checkForDefaultPrevented || - !(event as unknown as Event).defaultPrevented + !(event as unknown as Event)?.defaultPrevented ) { return ourEventHandler?.(event); } From def1df2c6811a26aa438b2aedd2a459b2fec13cd Mon Sep 17 00:00:00 2001 From: "YuBin, Hsu" <31545456+yubinTW@users.noreply.github.com> Date: Thu, 8 Feb 2024 19:53:10 +0800 Subject: [PATCH 064/112] fix: keep customData when converting to ExcalidrawElement (#7656) * feat: keep customData when converting to ExcalidrawElement (#7654) * docs: add changelog for keeping customData when converting to ExcalidrawElement --- packages/excalidraw/CHANGELOG.md | 4 + .../data/__snapshots__/transform.test.ts.snap | 50 +++ packages/excalidraw/data/transform.test.ts | 18 ++ packages/excalidraw/element/newElement.ts | 2 + .../__snapshots__/contextmenu.test.tsx.snap | 93 ++++++ .../__snapshots__/dragCreate.test.tsx.snap | 5 + .../tests/__snapshots__/move.test.tsx.snap | 6 + .../multiPointCreate.test.tsx.snap | 2 + .../regressionTests.test.tsx.snap | 288 ++++++++++++++++++ .../__snapshots__/selection.test.tsx.snap | 5 + .../data/__snapshots__/restore.test.ts.snap | 9 + 11 files changed, 482 insertions(+) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index d2c40c25e..34ad056fa 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -19,6 +19,10 @@ Please add the latest change on the top under the correct section. - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) +### Fixes + +- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656) + ### Breaking Changes - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index dcd48f8b5..450fce7de 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -14,6 +14,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -49,6 +50,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -79,6 +81,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", @@ -132,6 +135,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", @@ -190,6 +194,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -227,6 +232,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -271,6 +277,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -313,6 +320,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", @@ -368,6 +376,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "baseline": 0, "boundElements": null, "containerId": "id48", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -410,6 +419,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "id40", @@ -465,6 +475,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "baseline": 0, "boundElements": null, "containerId": "id37", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -507,6 +518,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -542,6 +554,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -577,6 +590,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": { "elementId": "id44", @@ -632,6 +646,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "baseline": 0, "boundElements": null, "containerId": "id41", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -676,6 +691,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -720,6 +736,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when }, ], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -757,6 +774,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -787,6 +805,7 @@ exports[`Test Transform > should transform linear elements 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -832,6 +851,7 @@ exports[`Test Transform > should transform linear elements 2`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "triangle", "endBinding": null, "fillStyle": "solid", @@ -877,6 +897,7 @@ exports[`Test Transform > should transform linear elements 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -922,6 +943,7 @@ exports[`Test Transform > should transform linear elements 4`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -967,6 +989,7 @@ exports[`Test Transform > should transform regular shapes 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -997,6 +1020,7 @@ exports[`Test Transform > should transform regular shapes 2`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1027,6 +1051,7 @@ exports[`Test Transform > should transform regular shapes 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1057,6 +1082,7 @@ exports[`Test Transform > should transform regular shapes 4`] = ` "angle": 0, "backgroundColor": "#c0eb75", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1087,6 +1113,7 @@ exports[`Test Transform > should transform regular shapes 5`] = ` "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1117,6 +1144,7 @@ exports[`Test Transform > should transform regular shapes 6`] = ` "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -1149,6 +1177,7 @@ exports[`Test Transform > should transform text element 1`] = ` "baseline": 0, "boundElements": null, "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1188,6 +1217,7 @@ exports[`Test Transform > should transform text element 2`] = ` "baseline": 0, "boundElements": null, "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1230,6 +1260,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1280,6 +1311,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1330,6 +1362,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1380,6 +1413,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "type": "text", }, ], + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -1427,6 +1461,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "baseline": 0, "boundElements": null, "containerId": "id25", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1466,6 +1501,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "baseline": 0, "boundElements": null, "containerId": "id26", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1505,6 +1541,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "baseline": 0, "boundElements": null, "containerId": "id27", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1545,6 +1582,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide "baseline": 0, "boundElements": null, "containerId": "id28", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1588,6 +1626,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1623,6 +1662,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1658,6 +1698,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1693,6 +1734,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1728,6 +1770,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1763,6 +1806,7 @@ exports[`Test Transform > should transform to text containers when label provide "type": "text", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1795,6 +1839,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id13", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1834,6 +1879,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id14", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1874,6 +1920,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id15", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1916,6 +1963,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id16", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1956,6 +2004,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id17", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, @@ -1997,6 +2046,7 @@ exports[`Test Transform > should transform to text containers when label provide "baseline": 0, "boundElements": null, "containerId": "id18", + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 20, diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index 7c71f33f8..239cd2f4c 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -822,4 +822,22 @@ describe("Test Transform", () => { "Duplicate id found for rect-1", ); }); + + it("should contains customData if provided", () => { + const rawData = [ + { + type: "rectangle", + x: 100, + y: 100, + customData: { createdBy: "user01" }, + }, + ]; + const convertedElements = convertToExcalidrawElements( + rawData as ExcalidrawElementSkeleton[], + opts, + ); + expect(convertedElements[0].customData).toStrictEqual({ + createdBy: "user01", + }); + }); }); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 447a07993..f1e0d8093 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -68,6 +68,7 @@ export type ElementConstructorOpts = MarkOptional< | "roundness" | "locked" | "opacity" + | "customData" >; const _newElementBase = ( @@ -121,6 +122,7 @@ const _newElementBase = ( updated: getUpdatedTimestamp(), link, locked, + customData: rest.customData, }; return element; }; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index b14000c2c..682af4bfe 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -387,6 +387,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -421,6 +422,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -584,6 +586,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -643,6 +646,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -786,6 +790,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -818,6 +823,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -877,6 +883,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -920,6 +927,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -949,6 +957,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -992,6 +1001,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1021,6 +1031,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1164,6 +1175,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1196,6 +1208,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1255,6 +1268,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1298,6 +1312,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1327,6 +1342,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1370,6 +1386,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1399,6 +1416,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1544,6 +1562,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1603,6 +1622,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1744,6 +1764,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1803,6 +1824,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1844,6 +1866,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1987,6 +2010,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2019,6 +2043,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2078,6 +2103,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2121,6 +2147,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2150,6 +2177,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2298,6 +2326,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2332,6 +2361,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2393,6 +2423,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2436,6 +2467,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2465,6 +2497,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2511,6 +2544,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2542,6 +2576,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2689,6 +2724,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -2721,6 +2757,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -2780,6 +2817,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2823,6 +2861,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2852,6 +2891,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2895,6 +2935,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2924,6 +2965,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2967,6 +3009,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2996,6 +3039,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3039,6 +3083,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3068,6 +3113,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3111,6 +3157,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3140,6 +3187,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3183,6 +3231,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3212,6 +3261,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3255,6 +3305,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3284,6 +3335,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3327,6 +3379,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3356,6 +3409,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "angle": 0, "backgroundColor": "#a5d8ff", "boundElements": null, + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [], @@ -3499,6 +3553,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3531,6 +3586,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3590,6 +3646,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3633,6 +3690,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3662,6 +3720,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3705,6 +3764,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3734,6 +3794,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3877,6 +3938,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3909,6 +3971,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3968,6 +4031,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4011,6 +4075,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4040,6 +4105,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4083,6 +4149,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4112,6 +4179,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4258,6 +4326,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4290,6 +4359,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4349,6 +4419,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4392,6 +4463,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4421,6 +4493,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4467,6 +4540,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -4498,6 +4572,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -4544,6 +4619,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4573,6 +4649,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4992,6 +5069,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5024,6 +5102,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5083,6 +5162,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5126,6 +5206,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5155,6 +5236,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5576,6 +5658,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5610,6 +5693,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5671,6 +5755,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5714,6 +5799,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5743,6 +5829,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5789,6 +5876,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5820,6 +5908,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -6872,6 +6961,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6904,6 +6994,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6936,6 +7027,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6995,6 +7087,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] hi "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], diff --git a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index 5c986f44b..91203eefb 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -7,6 +7,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -56,6 +57,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -90,6 +92,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -122,6 +125,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -171,6 +175,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index f348d0501..f287e547b 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -5,6 +5,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -37,6 +38,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -69,6 +71,7 @@ exports[`move element > rectangle 5`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -106,6 +109,7 @@ exports[`move element > rectangles with binding arrow 5`] = ` "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -143,6 +147,7 @@ exports[`move element > rectangles with binding arrow 6`] = ` "type": "arrow", }, ], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -175,6 +180,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": { "elementId": "id1", diff --git a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap index 12e7e61ed..3697f91b1 100644 --- a/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap @@ -5,6 +5,7 @@ exports[`multi point mode in linear elements > arrow 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -59,6 +60,7 @@ exports[`multi point mode in linear elements > line 3`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 65fa16899..de1166432 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -142,6 +142,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -185,6 +186,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -214,6 +216,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -257,6 +260,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -286,6 +290,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -315,6 +320,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -361,6 +367,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -390,6 +397,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -421,6 +429,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -602,6 +611,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -645,6 +655,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -674,6 +685,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -717,6 +729,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -746,6 +759,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -775,6 +789,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -821,6 +836,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -850,6 +866,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -881,6 +898,7 @@ exports[`given element A and group of elements B and given both are selected whe "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1053,6 +1071,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1096,6 +1115,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1125,6 +1145,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1171,6 +1192,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1202,6 +1224,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1247,6 +1270,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1278,6 +1302,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1323,6 +1348,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1354,6 +1380,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1385,6 +1412,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1432,6 +1460,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1464,6 +1493,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1496,6 +1526,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1541,6 +1572,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1573,6 +1605,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1605,6 +1638,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1650,6 +1684,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1682,6 +1717,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1714,6 +1750,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -1888,6 +1925,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -1931,6 +1969,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2106,6 +2145,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2149,6 +2189,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2178,6 +2219,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2221,6 +2263,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2250,6 +2293,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2279,6 +2323,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2325,6 +2370,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2354,6 +2400,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2385,6 +2432,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -2559,6 +2607,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] histo "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2602,6 +2651,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] histo "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2631,6 +2681,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] histo "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2801,6 +2852,7 @@ exports[`regression tests > arrow keys > [end of test] history 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -2973,6 +3025,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3016,6 +3069,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3045,6 +3099,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3088,6 +3143,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3117,6 +3173,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3146,6 +3203,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3189,6 +3247,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3218,6 +3277,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3247,6 +3307,7 @@ exports[`regression tests > can drag element that covers another element, while "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3417,6 +3478,7 @@ exports[`regression tests > change the properties of a shape > [end of test] his "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3460,6 +3522,7 @@ exports[`regression tests > change the properties of a shape > [end of test] his "angle": 0, "backgroundColor": "#ffec99", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3503,6 +3566,7 @@ exports[`regression tests > change the properties of a shape > [end of test] his "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3546,6 +3610,7 @@ exports[`regression tests > change the properties of a shape > [end of test] his "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3691,6 +3756,7 @@ exports[`regression tests > click on an element and drag it > [dragged] element "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3750,6 +3816,7 @@ exports[`regression tests > click on an element and drag it > [dragged] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3793,6 +3860,7 @@ exports[`regression tests > click on an element and drag it > [dragged] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -3965,6 +4033,7 @@ exports[`regression tests > click on an element and drag it > [end of test] hist "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4008,6 +4077,7 @@ exports[`regression tests > click on an element and drag it > [end of test] hist "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4051,6 +4121,7 @@ exports[`regression tests > click on an element and drag it > [end of test] hist "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4223,6 +4294,7 @@ exports[`regression tests > click to select a shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4266,6 +4338,7 @@ exports[`regression tests > click to select a shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4295,6 +4368,7 @@ exports[`regression tests > click to select a shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4468,6 +4542,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4511,6 +4586,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4540,6 +4616,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4583,6 +4660,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4612,6 +4690,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4641,6 +4720,7 @@ exports[`regression tests > click-drag to select a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4813,6 +4893,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4856,6 +4937,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4885,6 +4967,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -4931,6 +5014,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -4962,6 +5046,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5007,6 +5092,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5038,6 +5124,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5085,6 +5172,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5116,6 +5204,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -5184,6 +5273,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5268,6 +5358,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5345,6 +5436,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5388,6 +5480,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5417,6 +5510,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5483,6 +5577,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5616,6 +5711,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5659,6 +5755,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5688,6 +5785,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5754,6 +5852,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5837,6 +5936,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -5914,6 +6014,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6084,6 +6185,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6254,6 +6356,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6297,6 +6400,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6326,6 +6430,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6369,6 +6474,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6398,6 +6504,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6427,6 +6534,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6474,6 +6582,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -6505,6 +6614,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -6536,6 +6646,7 @@ exports[`regression tests > double click to edit a group > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -6712,6 +6823,7 @@ exports[`regression tests > drags selected elements from point inside common bou "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6755,6 +6867,7 @@ exports[`regression tests > drags selected elements from point inside common bou "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6784,6 +6897,7 @@ exports[`regression tests > drags selected elements from point inside common bou "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6828,6 +6942,7 @@ exports[`regression tests > drags selected elements from point inside common bou "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -6857,6 +6972,7 @@ exports[`regression tests > drags selected elements from point inside common bou "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7025,6 +7141,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7068,6 +7185,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7097,6 +7215,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7140,6 +7259,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7169,6 +7289,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7198,6 +7319,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7241,6 +7363,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7270,6 +7393,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7299,6 +7423,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7328,6 +7453,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7386,6 +7512,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7415,6 +7542,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7444,6 +7572,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7473,6 +7602,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7517,6 +7647,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -7575,6 +7706,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7604,6 +7736,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7633,6 +7766,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7662,6 +7796,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7706,6 +7841,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -7750,6 +7886,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7811,6 +7948,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7840,6 +7978,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7869,6 +8008,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -7898,6 +8038,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -7942,6 +8083,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -7986,6 +8128,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8051,6 +8194,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8080,6 +8224,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8109,6 +8254,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8138,6 +8284,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8182,6 +8329,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8226,6 +8374,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8277,6 +8426,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8338,6 +8488,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8367,6 +8518,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8396,6 +8548,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8425,6 +8578,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8469,6 +8623,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8513,6 +8668,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8564,6 +8720,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8627,6 +8784,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8656,6 +8814,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8685,6 +8844,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -8714,6 +8874,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8758,6 +8919,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8802,6 +8964,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -8853,6 +9016,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -8904,6 +9068,7 @@ exports[`regression tests > draw every type of shape > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9099,6 +9264,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9142,6 +9308,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9171,6 +9338,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9214,6 +9382,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9243,6 +9412,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9272,6 +9442,7 @@ exports[`regression tests > given a group of selected elements with an element t "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9445,6 +9616,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9488,6 +9660,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9517,6 +9690,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "angle": 0, "backgroundColor": "#ffc9c9", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9689,6 +9863,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9718,6 +9893,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9890,6 +10066,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9919,6 +10096,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9962,6 +10140,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -9991,6 +10170,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "angle": 0, "backgroundColor": "red", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -10161,6 +10341,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -10331,6 +10512,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] history 1 "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -10501,6 +10683,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] history 1 "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -10694,6 +10877,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -10902,6 +11086,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -11083,6 +11268,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -11298,6 +11484,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -11483,6 +11670,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] history 1 "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -11676,6 +11864,7 @@ exports[`regression tests > key l selects line tool > [end of test] history 1`] "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -11861,6 +12050,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] history 1 "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12027,6 +12217,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12219,6 +12410,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12397,6 +12589,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12440,6 +12633,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12469,6 +12663,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12512,6 +12707,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12541,6 +12737,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12570,6 +12767,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -12617,6 +12815,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12648,6 +12847,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12679,6 +12879,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12728,6 +12929,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12759,6 +12961,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12790,6 +12993,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12821,6 +13025,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12852,6 +13057,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -12883,6 +13089,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] histor "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -13057,6 +13264,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13100,6 +13308,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13129,6 +13338,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13424,6 +13634,7 @@ exports[`regression tests > shift click on selected element should deselect it o "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13598,6 +13809,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13641,6 +13853,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13670,6 +13883,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13714,6 +13928,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13743,6 +13958,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13919,6 +14135,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13962,6 +14179,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -13991,6 +14209,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14034,6 +14253,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14063,6 +14283,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14092,6 +14313,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14139,6 +14361,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14170,6 +14393,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14201,6 +14425,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14248,6 +14473,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14277,6 +14503,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14306,6 +14533,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14486,6 +14714,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14529,6 +14758,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14558,6 +14788,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14604,6 +14835,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14635,6 +14867,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14680,6 +14913,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14711,6 +14945,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14742,6 +14977,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14785,6 +15021,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14816,6 +15053,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14847,6 +15085,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14876,6 +15115,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -14922,6 +15162,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14953,6 +15194,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -14984,6 +15226,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15015,6 +15258,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15065,6 +15309,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15097,6 +15342,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15129,6 +15375,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15161,6 +15408,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15464,6 +15712,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15507,6 +15756,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15536,6 +15786,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15579,6 +15830,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15608,6 +15860,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15637,6 +15890,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -15684,6 +15938,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15715,6 +15970,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15746,6 +16002,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15792,6 +16049,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15823,6 +16081,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15854,6 +16113,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15902,6 +16162,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15933,6 +16194,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -15965,6 +16227,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -16015,6 +16278,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -16046,6 +16310,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -16078,6 +16343,7 @@ exports[`regression tests > supports nested groups > [end of test] history 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [ @@ -16147,6 +16413,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16233,6 +16500,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16310,6 +16578,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16353,6 +16622,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16382,6 +16652,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16425,6 +16696,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16454,6 +16726,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16483,6 +16756,7 @@ exports[`regression tests > switches from group of selected elements to another "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16549,6 +16823,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16634,6 +16909,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16711,6 +16987,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16754,6 +17031,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -16783,6 +17061,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17066,6 +17345,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17095,6 +17375,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17124,6 +17405,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -17189,6 +17471,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17218,6 +17501,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17247,6 +17531,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -17321,6 +17606,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17364,6 +17650,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -17393,6 +17680,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] history "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], diff --git a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap index 92ebee631..9b0ebecc6 100644 --- a/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap @@ -5,6 +5,7 @@ exports[`select single element on the scene > arrow 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": "arrow", "endBinding": null, "fillStyle": "solid", @@ -52,6 +53,7 @@ exports[`select single element on the scene > arrow escape 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -99,6 +101,7 @@ exports[`select single element on the scene > diamond 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -131,6 +134,7 @@ exports[`select single element on the scene > ellipse 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -163,6 +167,7 @@ exports[`select single element on the scene > rectangle 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": null, + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index 0c06b65f1..457ed4f14 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -5,6 +5,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": [], + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -52,6 +53,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and "angle": 0, "backgroundColor": "blue", "boundElements": [], + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [ @@ -88,6 +90,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and "angle": 0, "backgroundColor": "blue", "boundElements": [], + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [ @@ -124,6 +127,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and "angle": 0, "backgroundColor": "blue", "boundElements": [], + "customData": undefined, "fillStyle": "cross-hatch", "frameId": null, "groupIds": [ @@ -160,6 +164,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = ` "angle": 0, "backgroundColor": "transparent", "boundElements": [], + "customData": undefined, "fillStyle": "solid", "frameId": null, "groupIds": [], @@ -196,6 +201,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] = "angle": 0, "backgroundColor": "transparent", "boundElements": [], + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -243,6 +249,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] = "angle": 0, "backgroundColor": "transparent", "boundElements": [], + "customData": undefined, "endArrowhead": null, "endBinding": null, "fillStyle": "solid", @@ -292,6 +299,7 @@ exports[`restoreElements > should restore text element correctly passing value f "baseline": 0, "boundElements": [], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 14, @@ -333,6 +341,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo "baseline": 0, "boundElements": [], "containerId": null, + "customData": undefined, "fillStyle": "solid", "fontFamily": 1, "fontSize": 10, From adc4c9f4847c6d701fb05c6e57758194a297b160 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 8 Feb 2024 19:50:50 +0100 Subject: [PATCH 065/112] fix: prevent panning to trigger history on macos chrome (#7671) --- packages/excalidraw/components/App.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index f965a7679..7b9cc8cc2 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1410,6 +1410,13 @@ class App extends React.Component { }); }; + private toggleOverscrollBehavior(event: React.PointerEvent) { + // when pointer inside editor, disable overscroll behavior to prevent + // panning to trigger history back/forward on MacOS Chrome + document.documentElement.style.overscrollBehaviorX = + event.type === "pointerenter" ? "none" : "auto"; + } + public render() { const selectedElements = this.scene.getSelectedElements(this.state); const { renderTopRightUI, renderCustomStats } = this.props; @@ -1463,6 +1470,8 @@ class App extends React.Component { onKeyDown={ this.props.handleKeyboardGlobally ? undefined : this.onKeyDown } + onPointerEnter={this.toggleOverscrollBehavior} + onPointerLeave={this.toggleOverscrollBehavior} > @@ -2455,6 +2464,7 @@ class App extends React.Component { isSomeElementSelected.clearCache(); selectGroupsForSelectedElements.clearCache(); touchTimeout = 0; + document.documentElement.style.overscrollBehaviorX = ""; } private onResize = withBatchedUpdates(() => { From 48c3465b19f10ec755b3eb84e21a01a468e96e43 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 9 Feb 2024 19:29:50 +0530 Subject: [PATCH 066/112] docs: release patch v0.17.3 (#7673) * docs: release patch v0.17.3 * update cl --- packages/excalidraw/CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 34ad056fa..3759b44c4 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -61,10 +61,12 @@ Please add the latest change on the top under the correct section. - `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336) -## 0.17.1 (2023-11-28) +## 0.17.3 (2024-02-09) ### Fixes +- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656) + - Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean. ``` @@ -73,6 +75,10 @@ define: { } ``` +- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) + +- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339) + ## Excalidraw Library ### Fixes From 73bf50e8a8ebb7f3665c04bace5bd6b5adca56ea Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 15 Feb 2024 11:11:18 +0530 Subject: [PATCH 067/112] fix: remove t from getDefaultAppState and allow name to be nullable (#7666) * fix: remove t and allow name to be nullable * pass name as required prop * remove Unnamed * pass name to excalidrawPlus as well for better type safe * render once we have excalidrawAPI to avoid defaulting * rename `getAppName` -> `getName` (temporary) * stop preventing editing filenames when `props.name` supplied * keep `name` as optional param for export functions * keep `appState.name` on `props.name` state separate * fix lint * assertive first * fix lint * Add TODO --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 46 ++++++++++--------- .../components/ExportToExcalidrawPlus.tsx | 8 ++-- .../excalidraw/actions/actionClipboard.tsx | 2 + packages/excalidraw/actions/actionExport.tsx | 17 ++++--- packages/excalidraw/appState.ts | 4 +- packages/excalidraw/components/App.tsx | 26 +++++------ .../components/ImageExportDialog.tsx | 13 +++--- packages/excalidraw/components/LayerUI.tsx | 1 + .../excalidraw/components/ProjectName.tsx | 27 ++++------- packages/excalidraw/constants.ts | 6 +++ packages/excalidraw/data/index.ts | 12 +++-- packages/excalidraw/data/json.ts | 5 +- packages/excalidraw/data/resave.ts | 3 +- packages/excalidraw/tests/excalidraw.test.tsx | 5 +- packages/excalidraw/types.ts | 7 ++- 15 files changed, 101 insertions(+), 81 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index e38dd7a94..972737b9d 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -709,27 +709,30 @@ const ExcalidrawWrapper = () => { toggleTheme: true, export: { onExportToBackend, - renderCustomUI: (elements, appState, files) => { - return ( - { - excalidrawAPI?.updateScene({ - appState: { - errorMessage: error.message, - }, - }); - }} - onSuccess={() => { - excalidrawAPI?.updateScene({ - appState: { openDialog: null }, - }); - }} - /> - ); - }, + renderCustomUI: excalidrawAPI + ? (elements, appState, files) => { + return ( + { + excalidrawAPI?.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + }} + onSuccess={() => { + excalidrawAPI.updateScene({ + appState: { openDialog: null }, + }); + }} + /> + ); + } + : undefined, }, }, }} @@ -775,6 +778,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.getSceneElements(), excalidrawAPI.getAppState(), excalidrawAPI.getFiles(), + excalidrawAPI.getName(), ); }} > diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 4c566950b..bfbb4a556 100644 --- a/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async ( elements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, + name: string, ) => { const firebase = await loadFirebaseStorage(); @@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async ( .ref(`/migrations/scenes/${id}`) .put(blob, { customMetadata: { - data: JSON.stringify({ version: 2, name: appState.name }), + data: JSON.stringify({ version: 2, name }), created: Date.now().toString(), }, }); @@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{ elements: readonly NonDeletedExcalidrawElement[]; appState: Partial; files: BinaryFiles; + name: string; onError: (error: Error) => void; onSuccess: () => void; -}> = ({ elements, appState, files, onError, onSuccess }) => { +}> = ({ elements, appState, files, name, onError, onSuccess }) => { const { t } = useI18n(); return ( @@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{ onClick={async () => { try { trackEvent("export", "eplus", `ui (${getFrame()})`); - await exportToExcalidrawPlus(elements, appState, files); + await exportToExcalidrawPlus(elements, appState, files, name); onSuccess(); } catch (error: any) { console.error(error); diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index dadc61013..b2457341d 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -138,6 +138,7 @@ export const actionCopyAsSvg = register({ { ...appState, exportingFrame, + name: app.getName(), }, ); return { @@ -184,6 +185,7 @@ export const actionCopyAsPng = register({ await exportCanvas("clipboard", exportedElements, appState, app.files, { ...appState, exportingFrame, + name: app.getName(), }); return { appState: { diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 74dff34c8..7cb6b1291 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -26,14 +26,11 @@ export const actionChangeProjectName = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, name: value }, commitToHistory: false }; }, - PanelComponent: ({ appState, updateData, appProps, data }) => ( + PanelComponent: ({ appState, updateData, appProps, data, app }) => ( updateData(name)} - isNameEditable={ - typeof appProps.name === "undefined" && !appState.viewModeEnabled - } ignoreFocus={data?.ignoreFocus ?? false} /> ), @@ -144,8 +141,13 @@ export const actionSaveToActiveFile = register({ try { const { fileHandle } = isImageFileHandle(appState.fileHandle) - ? await resaveAsImageWithScene(elements, appState, app.files) - : await saveAsJSON(elements, appState, app.files); + ? await resaveAsImageWithScene( + elements, + appState, + app.files, + app.getName(), + ) + : await saveAsJSON(elements, appState, app.files, app.getName()); return { commitToHistory: false, @@ -190,6 +192,7 @@ export const actionSaveFileToDisk = register({ fileHandle: null, }, app.files, + app.getName(), ); return { commitToHistory: false, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 4dec9a790..a0ab233c9 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -7,9 +7,7 @@ import { EXPORT_SCALES, THEME, } from "./constants"; -import { t } from "./i18n"; import { AppState, NormalizedZoomValue } from "./types"; -import { getDateTime } from "./utils"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit< isRotating: false, lastPointerDownWith: "mouse", multiElement: null, - name: `${t("labels.untitled")}-${getDateTime()}`, + name: null, contextMenu: null, openMenu: null, openPopup: null, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7b9cc8cc2..3d3838afc 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -270,6 +270,7 @@ import { updateStable, addEventListener, normalizeEOL, + getDateTime, } from "../utils"; import { createSrcDoc, @@ -619,7 +620,7 @@ class App extends React.Component { gridModeEnabled = false, objectsSnapModeEnabled = false, theme = defaultAppState.theme, - name = defaultAppState.name, + name = `${t("labels.untitled")}-${getDateTime()}`, } = props; this.state = { ...defaultAppState, @@ -662,6 +663,7 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, + getName: this.getName, registerAction: (action: Action) => { this.actionManager.registerAction(action); }, @@ -1734,7 +1736,7 @@ class App extends React.Component { this.files, { exportBackground: this.state.exportBackground, - name: this.state.name, + name: this.getName(), viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, @@ -2133,7 +2135,7 @@ class App extends React.Component { let gridSize = actionResult?.appState?.gridSize || null; const theme = actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; - let name = actionResult?.appState?.name ?? this.state.name; + const name = actionResult?.appState?.name ?? this.state.name; const errorMessage = actionResult?.appState?.errorMessage ?? this.state.errorMessage; if (typeof this.props.viewModeEnabled !== "undefined") { @@ -2148,10 +2150,6 @@ class App extends React.Component { gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; } - if (typeof this.props.name !== "undefined") { - name = this.props.name; - } - editingElement = editingElement || actionResult.appState?.editingElement || null; @@ -2709,12 +2707,6 @@ class App extends React.Component { }); } - if (this.props.name && prevProps.name !== this.props.name) { - this.setState({ - name: this.props.name, - }); - } - this.excalidrawContainerRef.current?.classList.toggle( "theme--dark", this.state.theme === "dark", @@ -4122,6 +4114,14 @@ class App extends React.Component { return gesture.pointers.size >= 2; }; + public getName = () => { + return ( + this.state.name || + this.props.name || + `${t("labels.untitled")}-${getDateTime()}` + ); + }; + // fires only on Safari private onGestureStart = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index 7ca54e985..cecdfa79a 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -32,7 +32,6 @@ import { Switch } from "./Switch"; import { Tooltip } from "./Tooltip"; import "./ImageExportDialog.scss"; -import { useAppProps } from "./App"; import { FilledButton } from "./FilledButton"; import { cloneJSON } from "../utils"; import { prepareElementsForExport } from "../data"; @@ -58,6 +57,7 @@ type ImageExportModalProps = { files: BinaryFiles; actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; + name: string; }; const ImageExportModal = ({ @@ -66,14 +66,14 @@ const ImageExportModal = ({ files, actionManager, onExportImage, + name, }: ImageExportModalProps) => { const hasSelection = isSomeElementSelected( elementsSnapshot, appStateSnapshot, ); - const appProps = useAppProps(); - const [projectName, setProjectName] = useState(appStateSnapshot.name); + const [projectName, setProjectName] = useState(name); const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection); const [exportWithBackground, setExportWithBackground] = useState( appStateSnapshot.exportBackground, @@ -158,10 +158,6 @@ const ImageExportModal = ({ className="TextInput" value={projectName} style={{ width: "30ch" }} - disabled={ - typeof appProps.name !== "undefined" || - appStateSnapshot.viewModeEnabled - } onChange={(event) => { setProjectName(event.target.value); actionManager.executeAction( @@ -347,6 +343,7 @@ export const ImageExportDialog = ({ actionManager, onExportImage, onCloseRequest, + name, }: { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -354,6 +351,7 @@ export const ImageExportDialog = ({ actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; + name: string; }) => { // we need to take a snapshot so that the exported state can't be modified // while the dialog is open @@ -372,6 +370,7 @@ export const ImageExportDialog = ({ files={files} actionManager={actionManager} onExportImage={onExportImage} + name={name} /> ); diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 7247e8bf1..eb8027138 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -195,6 +195,7 @@ const LayerUI = ({ actionManager={actionManager} onExportImage={onExportImage} onCloseRequest={() => setAppState({ openDialog: null })} + name={app.getName()} /> ); }; diff --git a/packages/excalidraw/components/ProjectName.tsx b/packages/excalidraw/components/ProjectName.tsx index 69ff33527..592961793 100644 --- a/packages/excalidraw/components/ProjectName.tsx +++ b/packages/excalidraw/components/ProjectName.tsx @@ -11,7 +11,6 @@ type Props = { value: string; onChange: (value: string) => void; label: string; - isNameEditable: boolean; ignoreFocus?: boolean; }; @@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => { return (
    - {props.isNameEditable ? ( - setFileName(event.target.value)} - /> - ) : ( - - {props.value} - - )} + setFileName(event.target.value)} + />
    ); }; diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 021c706a9..09e497564 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -381,3 +381,9 @@ export const EDITOR_LS_KEYS = { MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw", PUBLISH_LIBRARY: "publish-library-data", } as const; + +/** + * not translated as this is used only in public, stateless API as default value + * where filename is optional and we can't retrieve name from app state + */ +export const DEFAULT_FILENAME = "Untitled"; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index fa2ec9de6..51446921f 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -2,7 +2,12 @@ import { copyBlobToClipboardAsPng, copyTextToSystemClipboard, } from "../clipboard"; -import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; +import { + DEFAULT_EXPORT_PADDING, + DEFAULT_FILENAME, + isFirefox, + MIME_TYPES, +} from "../constants"; import { getNonDeletedElements } from "../element"; import { isFrameLikeElement } from "../element/typeChecks"; import { @@ -84,14 +89,15 @@ export const exportCanvas = async ( exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, viewBackgroundColor, - name, + name = appState.name || DEFAULT_FILENAME, fileHandle = null, exportingFrame = null, }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; - name: string; + /** filename, if applicable */ + name?: string; fileHandle?: FileSystemHandle | null; exportingFrame: ExcalidrawFrameLikeElement | null; }, diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 037c5ca18..94dddf288 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -1,6 +1,7 @@ import { fileOpen, fileSave } from "./filesystem"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { + DEFAULT_FILENAME, EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES, @@ -71,6 +72,8 @@ export const saveAsJSON = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + /** filename */ + name: string = appState.name || DEFAULT_FILENAME, ) => { const serialized = serializeAsJSON(elements, appState, files, "local"); const blob = new Blob([serialized], { @@ -78,7 +81,7 @@ export const saveAsJSON = async ( }); const fileHandle = await fileSave(blob, { - name: appState.name, + name, extension: "excalidraw", description: "Excalidraw file", fileHandle: isImageFileHandle(appState.fileHandle) diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index 0998fd3c7..c73890e22 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -7,8 +7,9 @@ export const resaveAsImageWithScene = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + name: string, ) => { - const { exportBackground, viewBackgroundColor, name, fileHandle } = appState; + const { exportBackground, viewBackgroundColor, fileHandle } = appState; const fileHandleType = getFileHandleType(fileHandle); diff --git a/packages/excalidraw/tests/excalidraw.test.tsx b/packages/excalidraw/tests/excalidraw.test.tsx index 962dabcc0..98d0e9006 100644 --- a/packages/excalidraw/tests/excalidraw.test.tsx +++ b/packages/excalidraw/tests/excalidraw.test.tsx @@ -303,7 +303,7 @@ describe("", () => { }); describe("Test name prop", () => { - it('should allow editing name when the name prop is "undefined"', async () => { + it("should allow editing name", async () => { const { container } = await render(); //open menu toggleMenu(container); @@ -315,7 +315,7 @@ describe("", () => { expect(textInput?.nodeName).toBe("INPUT"); }); - it('should set the name and not allow editing when the name prop is present"', async () => { + it('should set the name when the name prop is present"', async () => { const name = "test"; const { container } = await render(); //open menu @@ -326,7 +326,6 @@ describe("", () => { ) as HTMLInputElement; expect(textInput?.value).toEqual(name); expect(textInput?.nodeName).toBe("INPUT"); - expect(textInput?.disabled).toBe(true); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 89b121b2f..1eaa04449 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -247,7 +247,7 @@ export interface AppState { scrollY: number; cursorButton: "up" | "down"; scrolledOutside: boolean; - name: string; + name: string | null; isResizing: boolean; isRotating: boolean; zoom: Zoom; @@ -435,6 +435,7 @@ export interface ExcalidrawProps { objectsSnapModeEnabled?: boolean; libraryReturnUrl?: string; theme?: Theme; + // @TODO come with better API before v0.18.0 name?: string; renderCustomStats?: ( elements: readonly NonDeletedExcalidrawElement[], @@ -577,6 +578,7 @@ export type AppClassProperties = { setOpenDialog: App["setOpenDialog"]; insertEmbeddableElement: App["insertEmbeddableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; + getName: App["getName"]; }; export type PointerDownState = Readonly<{ @@ -651,10 +653,11 @@ export type ExcalidrawImperativeAPI = { history: { clear: InstanceType["resetHistory"]; }; - scrollToContent: InstanceType["scrollToContent"]; getSceneElements: InstanceType["getSceneElements"]; getAppState: () => InstanceType["state"]; getFiles: () => InstanceType["files"]; + getName: InstanceType["getName"]; + scrollToContent: InstanceType["scrollToContent"]; registerAction: (action: Action) => void; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"]; From 47f87f4ecbc62058b6e2b6b7d953952bf6f3ecaf Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 16 Feb 2024 11:35:01 +0530 Subject: [PATCH 068/112] fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap (#7663) * fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap * lint * fix * use non deleted elements where possible * use non deleted elements map in actions * pass elementsMap instead of array to elementOverlapsWithFrame * lint * fix * pass elementsMap to getElementsCorners * pass elementsMap to getEligibleElementsForBinding * pass elementsMap in bindOrUnbindSelectedElements and unbindLinearElements * pass elementsMap in elementsAreInFrameBounds,elementOverlapsWithFrame,isCursorInFrame,getElementsInResizingFrame * pass elementsMap in getElementsWithinSelection, getElementsCompletelyInFrame, isElementContainingFrame, getElementsInNewFrame * pass elementsMap to getElementWithTransformHandleType * pass elementsMap to getVisibleGaps, getMaximumGroups,getReferenceSnapPoints,snapDraggedElements * lint * pass elementsMap to bindTextToShapeAfterDuplication,bindLinearElementToElement,getTextBindableContainerAtPosition * revert changes for bindTextToShapeAfterDuplication --- .../excalidraw/actions/actionBoundText.tsx | 18 ++- .../excalidraw/actions/actionFinalize.tsx | 7 +- packages/excalidraw/actions/actionFlip.ts | 2 +- packages/excalidraw/actions/actionGroup.tsx | 9 +- .../excalidraw/actions/actionProperties.tsx | 4 + packages/excalidraw/actions/actionStyles.ts | 6 +- packages/excalidraw/components/App.tsx | 128 +++++++++++++----- packages/excalidraw/data/restore.ts | 1 + packages/excalidraw/data/transform.ts | 10 +- .../element/ElementCanvasButtons.tsx | 9 +- packages/excalidraw/element/Hyperlink.tsx | 49 +++++-- packages/excalidraw/element/binding.ts | 118 +++++++++++++--- packages/excalidraw/element/bounds.test.ts | 17 +-- packages/excalidraw/element/bounds.ts | 18 ++- packages/excalidraw/element/collision.ts | 90 +++++++++--- packages/excalidraw/element/dragElements.ts | 2 +- .../excalidraw/element/linearElementEditor.ts | 83 ++++++++++-- packages/excalidraw/element/newElement.ts | 10 +- packages/excalidraw/element/resizeElements.ts | 29 ++-- packages/excalidraw/element/resizeTest.ts | 6 +- packages/excalidraw/element/textElement.ts | 35 +++-- packages/excalidraw/element/textWysiwyg.tsx | 16 ++- .../excalidraw/element/transformHandles.ts | 5 +- packages/excalidraw/frame.ts | 86 +++++++----- packages/excalidraw/renderer/renderElement.ts | 37 +++-- packages/excalidraw/renderer/renderScene.ts | 53 ++++++-- packages/excalidraw/scene/Fonts.ts | 6 +- packages/excalidraw/scene/export.ts | 3 +- packages/excalidraw/scene/selection.ts | 6 +- packages/excalidraw/snapping.ts | 40 +++--- packages/excalidraw/tests/binding.test.tsx | 9 +- packages/excalidraw/tests/flip.test.tsx | 13 +- packages/excalidraw/tests/helpers/ui.ts | 10 +- .../tests/linearElementEditor.test.tsx | 94 ++++++++++--- packages/excalidraw/tests/move.test.tsx | 4 +- packages/excalidraw/tests/resize.test.tsx | 16 ++- 36 files changed, 779 insertions(+), 270 deletions(-) diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 722ad5111..e0ea95cd4 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -58,7 +58,11 @@ export const actionUnbindText = register({ element.id, ); resetOriginalContainerCache(element.id); - const { x, y } = computeBoundTextPosition(element, boundTextElement); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, @@ -145,7 +149,11 @@ export const actionBindText = register({ }), }); const originalContainerHeight = container.height; - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); // overwritting the cache with original container height so // it can be restored when unbind updateOriginalContainerCache(container.id, originalContainerHeight); @@ -286,7 +294,11 @@ export const actionWrapTextInContainer = register({ }, false, ); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); updatedElements = pushContainerBelowText( [...updatedElements, container], diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index a7c34c5ac..623876d58 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,6 +1,6 @@ import { KEYS } from "../keys"; import { isInvisiblySmallElement } from "../element"; -import { updateActiveTool } from "../utils"; +import { arrayToMap, updateActiveTool } from "../utils"; import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; @@ -26,6 +26,8 @@ export const actionFinalize = register({ _, { interactiveCanvas, focusContainer, scene }, ) => { + const elementsMap = arrayToMap(elements); + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; @@ -37,6 +39,7 @@ export const actionFinalize = register({ element, startBindingElement, endBindingElement, + elementsMap, ); } return { @@ -125,12 +128,14 @@ export const actionFinalize = register({ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( multiPointElement, -1, + arrayToMap(elements), ); maybeBindLinearElement( multiPointElement, appState, Scene.getScene(multiPointElement)!, { x, y }, + elementsMap, ); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index c760af44d..70fbe026d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -115,7 +115,7 @@ const flipElements = ( (isBindingEnabled(appState) ? bindOrUnbindSelectedElements - : unbindLinearElements)(selectedElements); + : unbindLinearElements)(selectedElements, elementsMap); return selectedElements; }; diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 44523857a..44e590bc2 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -180,6 +180,8 @@ export const actionUngroup = register({ trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + if (groupIds.length === 0) { return { appState, elements, commitToHistory: false }; } @@ -226,7 +228,12 @@ export const actionUngroup = register({ if (frame) { nextElements = replaceAllElementsInFrame( nextElements, - getElementsInResizingFrame(nextElements, frame, appState), + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), frame, app, ); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 79e50aa68..8f2c350d6 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -209,6 +209,7 @@ const changeFontSize = ( redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -730,6 +731,7 @@ export const actionChangeFontFamily = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -829,6 +831,7 @@ export const actionChangeTextAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -918,6 +921,7 @@ export const actionChangeVerticalAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 25a6baf2a..538375031 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -128,7 +128,11 @@ export const actionPasteStyles = register({ element.id === newElement.containerId, ) || null; } - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } if ( diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3d3838afc..b4410ab2b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1536,6 +1536,7 @@ class App extends React.Component { { isMagicFrameElement(firstSelectedElement) && ( { ?.status === "done" && ( { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); - if ( - !this.state.showWelcomeScreen && - !this.scene.getElementsIncludingDeleted().length - ) { + const elements = this.scene.getElementsIncludingDeleted(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + + if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); } @@ -2756,27 +2759,21 @@ class App extends React.Component { LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, + elementsMap, ), ), + elementsMap, ); } - this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + this.history.record(this.state, elements); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during // init, which would trigger onChange with empty elements, which would then // override whatever is in localStorage currently. if (!this.state.isLoading) { - this.props.onChange?.( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); - this.onChangeEmitter.trigger( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); + this.props.onChange?.(elements, this.state, this.files); + this.onChangeEmitter.trigger(elements, this.state, this.files); } } @@ -3126,7 +3123,11 @@ class App extends React.Component { newElement, this.scene.getElementsMapIncludingDeleted(), ); - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + this.scene.getElementsMapIncludingDeleted(), + ); } }); @@ -3836,7 +3837,7 @@ class App extends React.Component { y: element.y + offsetY, }); - updateBoundElements(element, { + updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { simultaneouslyUpdated: selectedElements, }); }); @@ -4010,9 +4011,10 @@ class App extends React.Component { } if (isArrowKey(event.key)) { const selectedElements = this.scene.getSelectedElements(this.state); + const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements) - : unbindLinearElements(selectedElements); + ? bindOrUnbindSelectedElements(selectedElements, elementsMap) + : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } }); @@ -4193,20 +4195,21 @@ class App extends React.Component { isExistingElement?: boolean; }, ) { + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const updateElement = ( text: string, originalText: string, isDeleted: boolean, ) => { this.scene.replaceAllElements([ + // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { return updateTextElement( _element, - getContainerElement( - _element, - this.scene.getElementsMapIncludingDeleted(), - ), + getContainerElement(_element, elementsMap), + elementsMap, { text, isDeleted, @@ -4238,7 +4241,7 @@ class App extends React.Component { onChange: withBatchedUpdates((text) => { updateElement(text, text, false); if (isNonDeletedElement(element)) { - updateBoundElements(element); + updateBoundElements(element, elementsMap); } }), onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { @@ -4377,6 +4380,7 @@ class App extends React.Component { !(isTextElement(element) && element.containerId)), ); + const elementsMap = this.scene.getNonDeletedElementsMap(); return getElementsAtPosition(elements, (element) => hitTest( element, @@ -4384,7 +4388,7 @@ class App extends React.Component { this.frameNameBoundsCache, x, y, - this.scene.getNonDeletedElementsMap(), + elementsMap, ), ).filter((element) => { // hitting a frame's element from outside the frame is not considered a hit @@ -4392,7 +4396,7 @@ class App extends React.Component { return containingFrame && this.state.frameRendering.enabled && this.state.frameRendering.clip - ? isCursorInFrame({ x, y }, containingFrame) + ? isCursorInFrame({ x, y }, containingFrame, elementsMap) : true; }); } @@ -4637,6 +4641,7 @@ class App extends React.Component { this.state, sceneX, sceneY, + this.scene.getNonDeletedElementsMap(), ); if (container) { @@ -4648,6 +4653,7 @@ class App extends React.Component { this.state, this.frameNameBoundsCache, [sceneX, sceneY], + this.scene.getNonDeletedElementsMap(), ) ) { const midPoint = getContainerCenter( @@ -4688,6 +4694,7 @@ class App extends React.Component { index <= hitElementIndex && isPointHittingLink( element, + this.scene.getNonDeletedElementsMap(), this.state, [scenePointer.x, scenePointer.y], this.device.editor.isMobile, @@ -4718,8 +4725,10 @@ class App extends React.Component { this.lastPointerDownEvent!, this.state, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const lastPointerDownHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], this.device.editor.isMobile, @@ -4730,6 +4739,7 @@ class App extends React.Component { ); const lastPointerUpHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], this.device.editor.isMobile, @@ -4766,10 +4776,11 @@ class App extends React.Component { x: number; y: number; }) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame), + isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -4873,6 +4884,7 @@ class App extends React.Component { y: scenePointerY, }, event, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -4912,6 +4924,7 @@ class App extends React.Component { scenePointerX, scenePointerY, this.state, + this.scene.getNonDeletedElementsMap(), ); if ( @@ -5062,6 +5075,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if ( elementWithTransformHandleType && @@ -5109,7 +5123,11 @@ class App extends React.Component { !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - showHyperlinkTooltip(this.hitLinkElement, this.state); + showHyperlinkTooltip( + this.hitLinkElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); } else { hideHyperlinkToolip(); if ( @@ -5305,10 +5323,12 @@ class App extends React.Component { this.state, this.frameNameBoundsCache, [scenePointerX, scenePointerY], + elementsMap, ) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, this.state.zoom, scenePointerX, scenePointerY, @@ -5738,10 +5758,12 @@ class App extends React.Component { if ( clicklength < 300 && isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon(this.hitLinkElement, this.state, [ - scenePointer.x, - scenePointer.y, - ]) + !isPointHittingLinkIcon( + this.hitLinkElement, + this.scene.getNonDeletedElementsMap(), + this.state, + [scenePointer.x, scenePointer.y], + ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); } else { @@ -6039,7 +6061,9 @@ class App extends React.Component { ): boolean => { if (this.state.activeTool.type === "selection") { const elements = this.scene.getNonDeletedElements(); + const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1 && !this.state.editingLinearElement) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -6049,6 +6073,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if (elementWithTransformHandleType != null) { this.setState({ @@ -6072,6 +6097,7 @@ class App extends React.Component { getResizeOffsetXY( pointerDownState.resize.handleType, selectedElements, + elementsMap, pointerDownState.origin.x, pointerDownState.origin.y, ), @@ -6352,6 +6378,7 @@ class App extends React.Component { this.state, sceneX, sceneY, + this.scene.getNonDeletedElementsMap(), ); if (hasBoundTextElement(element)) { @@ -6846,6 +6873,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6869,6 +6897,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6985,6 +7014,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], + this.scene.getNonDeletedElementsMap(), ); if (!ret) { return; @@ -7143,10 +7173,11 @@ class App extends React.Component { this.maybeCacheReferenceSnapPoints(event, selectedElements); const { snapOffset, snapLines } = snapDraggedElements( - getSelectedElements(originalElements, this.state), + originalElements, dragOffset, this.state, event, + this.scene.getNonDeletedElementsMap(), ); this.setState({ snapLines }); @@ -7330,6 +7361,7 @@ class App extends React.Component { event, this.state, this.setState.bind(this), + this.scene.getNonDeletedElementsMap(), ); // regular box-select } else { @@ -7360,6 +7392,7 @@ class App extends React.Component { const elementsWithinSelection = getElementsWithinSelection( elements, draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -7491,7 +7524,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, }); - + const elementsMap = this.scene.getNonDeletedElementsMap(); // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { @@ -7506,6 +7539,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + elementsMap, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7529,6 +7563,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + elementsMap, ); const { startBindingElement, endBindingElement } = @@ -7539,6 +7574,7 @@ class App extends React.Component { element, startBindingElement, endBindingElement, + elementsMap, ); } @@ -7678,6 +7714,7 @@ class App extends React.Component { this.state, this.scene, pointerCoords, + elementsMap, ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -7748,7 +7785,13 @@ class App extends React.Component { const frame = getContainingFrame(linearElement); if (frame && linearElement) { - if (!elementOverlapsWithFrame(linearElement, frame)) { + if ( + !elementOverlapsWithFrame( + linearElement, + frame, + this.scene.getNonDeletedElementsMap(), + ) + ) { // remove the linear element from all groups // before removing it from the frame as well mutateElement(linearElement, { @@ -7859,6 +7902,7 @@ class App extends React.Component { const elementsInsideFrame = getElementsInNewFrame( this.scene.getElementsIncludingDeleted(), draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.scene.replaceAllElements( @@ -7909,6 +7953,7 @@ class App extends React.Component { this.scene.getElementsIncludingDeleted(), frame, this.state, + elementsMap, ), frame, this, @@ -8189,7 +8234,10 @@ class App extends React.Component { if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { (isBindingEnabled(this.state) ? bindOrUnbindSelectedElements - : unbindLinearElements)(this.scene.getSelectedElements(this.state)); + : unbindLinearElements)( + this.scene.getSelectedElements(this.state), + elementsMap, + ); } if (activeTool.type === "laser") { @@ -8719,7 +8767,10 @@ class App extends React.Component { if (selectedElements.length > 50) { return; } - const suggestedBindings = getEligibleElementsForBinding(selectedElements); + const suggestedBindings = getEligibleElementsForBinding( + selectedElements, + this.scene.getNonDeletedElementsMap(), + ); this.setState({ suggestedBindings }); } @@ -9058,6 +9109,7 @@ class App extends React.Component { x: gridX - pointerDownState.originInGrid.x, y: gridY - pointerDownState.originInGrid.y, }, + this.scene.getNonDeletedElementsMap(), ); gridX += snapOffset.x; @@ -9096,6 +9148,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), draggingElement as ExcalidrawFrameLikeElement, this.state, + this.scene.getNonDeletedElementsMap(), ), }); } @@ -9215,6 +9268,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), frame, this.state, + this.scene.getNonDeletedElementsMap(), ).forEach((element) => elementsToHighlight.add(element)); }); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 12e7f1af1..022457f01 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -462,6 +462,7 @@ export const restoreElements = ( refreshTextDimensions( element, getContainerElement(element, restoredElementsMap), + restoredElementsMap, ), ); } diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 8ce842300..8d5b63a19 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -222,7 +222,7 @@ const bindTextToContainer = ( }), }); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox(textElement, container, elementsMap); return [container, textElement] as const; }; @@ -231,6 +231,7 @@ const bindLinearElementToElement = ( start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, + elementsMap: ElementsMap, ): { linearElement: ExcalidrawLinearElement; startBoundElement?: ExcalidrawElement; @@ -316,6 +317,7 @@ const bindLinearElementToElement = ( linearElement, startBoundElement as ExcalidrawBindableElement, "start", + elementsMap, ); } } @@ -390,6 +392,7 @@ const bindLinearElementToElement = ( linearElement, endBoundElement as ExcalidrawBindableElement, "end", + elementsMap, ); } } @@ -612,6 +615,7 @@ export const convertToExcalidrawElements = ( } } + const elementsMap = arrayToMap(elementStore.getElements()); // Add labels and arrow bindings for (const [id, element] of elementsWithIds) { const excalidrawElement = elementStore.getElement(id)!; @@ -625,7 +629,7 @@ export const convertToExcalidrawElements = ( let [container, text] = bindTextToContainer( excalidrawElement, element?.label, - arrayToMap(elementStore.getElements()), + elementsMap, ); elementStore.add(container); elementStore.add(text); @@ -653,6 +657,7 @@ export const convertToExcalidrawElements = ( originalStart, originalEnd, elementStore, + elementsMap, ); container = linearElement; elementStore.add(linearElement); @@ -677,6 +682,7 @@ export const convertToExcalidrawElements = ( start, end, elementStore, + elementsMap, ); elementStore.add(linearElement); diff --git a/packages/excalidraw/element/ElementCanvasButtons.tsx b/packages/excalidraw/element/ElementCanvasButtons.tsx index 99d9d55e1..0fc7621fd 100644 --- a/packages/excalidraw/element/ElementCanvasButtons.tsx +++ b/packages/excalidraw/element/ElementCanvasButtons.tsx @@ -1,6 +1,6 @@ import { AppState } from "../types"; import { sceneCoordsToViewportCoords } from "../utils"; -import { NonDeletedExcalidrawElement } from "./types"; +import { ElementsMap, NonDeletedExcalidrawElement } from "./types"; import { getElementAbsoluteCoords } from "."; import { useExcalidrawAppState } from "../components/App"; @@ -11,8 +11,9 @@ const CONTAINER_PADDING = 5; const getContainerCoords = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: x1 + element.width, sceneY: y1 }, appState, @@ -25,9 +26,11 @@ const getContainerCoords = ( export const ElementCanvasButtons = ({ children, element, + elementsMap, }: { children: React.ReactNode; element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; }) => { const appState = useExcalidrawAppState(); @@ -42,7 +45,7 @@ export const ElementCanvasButtons = ({ return null; } - const { x, y } = getContainerCoords(element, appState); + const { x, y } = getContainerCoords(element, appState, elementsMap); return (
    ["setState"]; onLinkOpen: ExcalidrawProps["onLinkOpen"]; setToast: ( @@ -182,7 +185,7 @@ export const Hyperlink = ({ if (timeoutId) { clearTimeout(timeoutId); } - const shouldHide = shouldHideLinkPopup(element, appState, [ + const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [ event.clientX, event.clientY, ]) as boolean; @@ -199,7 +202,7 @@ export const Hyperlink = ({ clearTimeout(timeoutId); } }; - }, [appState, element, isEditing, setAppState]); + }, [appState, element, isEditing, setAppState, elementsMap]); const handleRemove = useCallback(() => { trackEvent("hyperlink", "delete"); @@ -214,7 +217,7 @@ export const Hyperlink = ({ trackEvent("hyperlink", "edit", "popup-ui"); setAppState({ showHyperlinkPopup: "editor" }); }; - const { x, y } = getCoordsForPopover(element, appState); + const { x, y } = getCoordsForPopover(element, appState, elementsMap); if ( appState.contextMenu || appState.draggingElement || @@ -324,8 +327,9 @@ export const Hyperlink = ({ const getCoordsForPopover = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: x1 + element.width / 2, sceneY: y1 }, appState, @@ -430,11 +434,12 @@ export const getLinkHandleFromCoords = ( export const isPointHittingLinkIcon = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [x, y]: Point, ) => { const threshold = 4 / appState.zoom.value; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( [x1, y1, x2, y2], element.angle, @@ -450,6 +455,7 @@ export const isPointHittingLinkIcon = ( export const isPointHittingLink = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [x, y]: Point, isMobile: boolean, @@ -461,23 +467,30 @@ export const isPointHittingLink = ( if ( !isMobile && appState.viewModeEnabled && - isPointHittingElementBoundingBox(element, [x, y], threshold, null) + isPointHittingElementBoundingBox( + element, + elementsMap, + [x, y], + threshold, + null, + ) ) { return true; } - return isPointHittingLinkIcon(element, appState, [x, y]); + return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); }; let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null; export const showHyperlinkTooltip = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (HYPERLINK_TOOLTIP_TIMEOUT_ID) { clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID); } HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout( - () => renderTooltip(element, appState), + () => renderTooltip(element, appState, elementsMap), HYPERLINK_TOOLTIP_DELAY, ); }; @@ -485,6 +498,7 @@ export const showHyperlinkTooltip = ( const renderTooltip = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (!element.link) { return; @@ -496,7 +510,7 @@ const renderTooltip = ( tooltipDiv.style.maxWidth = "20rem"; tooltipDiv.textContent = element.link; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( [x1, y1, x2, y2], @@ -535,6 +549,7 @@ export const hideHyperlinkToolip = () => { export const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [clientX, clientY]: Point, ): Boolean => { @@ -546,11 +561,17 @@ export const shouldHideLinkPopup = ( const threshold = 15 / appState.zoom.value; // hitbox to prevent hiding when hovered in element bounding box if ( - isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null) + isPointHittingElementBoundingBox( + element, + elementsMap, + [sceneX, sceneY], + threshold, + null, + ) ) { return false; } - const [x1, y1, x2] = getElementAbsoluteCoords(element); + const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); // hit box to prevent hiding when hovered in the vertical area between element and popover if ( sceneX >= x1 && @@ -561,7 +582,11 @@ export const shouldHideLinkPopup = ( return false; } // hit box to prevent hiding when hovered around popover within threshold - const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState); + const { x: popoverX, y: popoverY } = getCoordsForPopover( + element, + appState, + elementsMap, + ); if ( clientX >= popoverX - threshold && diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 66d29f3f6..be766e33f 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -5,6 +5,7 @@ import { NonDeletedExcalidrawElement, PointBinding, ExcalidrawElement, + ElementsMap, } from "./types"; import { getElementAtPosition } from "../scene"; import { AppState } from "../types"; @@ -66,6 +67,7 @@ export const bindOrUnbindLinearElement = ( linearElement: NonDeleted, startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", + elementsMap: ElementsMap, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -76,6 +78,7 @@ export const bindOrUnbindLinearElement = ( "start", boundToElementIds, unboundFromElementIds, + elementsMap, ); bindOrUnbindLinearElementEdge( linearElement, @@ -84,6 +87,7 @@ export const bindOrUnbindLinearElement = ( "end", boundToElementIds, unboundFromElementIds, + elementsMap, ); const onlyUnbound = Array.from(unboundFromElementIds).filter( @@ -111,6 +115,7 @@ const bindOrUnbindLinearElementEdge = ( boundToElementIds: Set, // Is mutated unboundFromElementIds: Set, + elementsMap: ElementsMap, ): void => { if (bindableElement !== "keep") { if (bindableElement != null) { @@ -127,7 +132,12 @@ const bindOrUnbindLinearElementEdge = ( : startOrEnd === "start" || otherEdgeBindableElement.id !== bindableElement.id) ) { - bindLinearElement(linearElement, bindableElement, startOrEnd); + bindLinearElement( + linearElement, + bindableElement, + startOrEnd, + elementsMap, + ); boundToElementIds.add(bindableElement.id); } } else { @@ -140,23 +150,34 @@ const bindOrUnbindLinearElementEdge = ( }; export const bindOrUnbindSelectedElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + elementsMap: ElementsMap, ): void => { - elements.forEach((element) => { - if (isBindingElement(element)) { + selectedElements.forEach((selectedElement) => { + if (isBindingElement(selectedElement)) { bindOrUnbindLinearElement( - element, - getElligibleElementForBindingElement(element, "start"), - getElligibleElementForBindingElement(element, "end"), + selectedElement, + getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ), + getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ), + elementsMap, ); - } else if (isBindableElement(element)) { - maybeBindBindableElement(element); + } else if (isBindableElement(selectedElement)) { + maybeBindBindableElement(selectedElement, elementsMap); } }); }; const maybeBindBindableElement = ( bindableElement: NonDeleted, + elementsMap: ElementsMap, ): void => { getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( ([linearElement, where]) => @@ -164,6 +185,7 @@ const maybeBindBindableElement = ( linearElement, where === "end" ? "keep" : bindableElement, where === "start" ? "keep" : bindableElement, + elementsMap, ), ); }; @@ -173,9 +195,15 @@ export const maybeBindLinearElement = ( appState: AppState, scene: Scene, pointerCoords: { x: number; y: number }, + elementsMap: ElementsMap, ): void => { if (appState.startBoundElement != null) { - bindLinearElement(linearElement, appState.startBoundElement, "start"); + bindLinearElement( + linearElement, + appState.startBoundElement, + "start", + elementsMap, + ); } const hoveredElement = getHoveredElementForBinding(pointerCoords, scene); if ( @@ -186,7 +214,7 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement(linearElement, hoveredElement, "end"); + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); } }; @@ -194,11 +222,17 @@ export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): void => { mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { elementId: hoveredElement.id, - ...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd), + ...calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), } as PointBinding, }); @@ -240,10 +274,11 @@ export const isLinearElementSimpleAndAlreadyBound = ( export const unbindLinearElements = ( elements: NonDeleted[], + elementsMap: ElementsMap, ): void => { elements.forEach((element) => { if (isBindingElement(element)) { - bindOrUnbindLinearElement(element, null, null); + bindOrUnbindLinearElement(element, null, null, elementsMap); } }); }; @@ -272,7 +307,11 @@ export const getHoveredElementForBinding = ( scene.getNonDeletedElements(), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords), + bindingBorderTest( + element, + pointerCoords, + scene.getNonDeletedElementsMap(), + ), ); return hoveredElement as NonDeleted | null; }; @@ -281,21 +320,33 @@ const calculateFocusAndGap = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const adjacentPointIndex = edgePointIndex - direction; + const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, edgePointIndex, + elementsMap, ); const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); return { - focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), - gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), + focus: determineFocusDistance( + hoveredElement, + adjacentPoint, + edgePoint, + elementsMap, + ), + gap: Math.max( + 1, + distanceToBindableElement(hoveredElement, edgePoint, elementsMap), + ), }; }; @@ -306,6 +357,8 @@ const calculateFocusAndGap = ( // in explicitly. export const updateBoundElements = ( changedElement: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; @@ -355,12 +408,14 @@ export const updateBoundElements = ( "start", startBinding, changedElement as ExcalidrawBindableElement, + elementsMap, ); updateBoundPoint( element, "end", endBinding, changedElement as ExcalidrawBindableElement, + elementsMap, ); const boundText = getBoundTextElement( element, @@ -393,6 +448,7 @@ const updateBoundPoint = ( startOrEnd: "start" | "end", binding: PointBinding | null | undefined, changedElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, ): void => { if ( binding == null || @@ -414,11 +470,13 @@ const updateBoundPoint = ( const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); const focusPointAbsolute = determineFocusPoint( bindingElement, binding.focus, adjacentPoint, + elementsMap, ); let newEdgePoint; // The linear element was not originally pointing inside the bound shape, @@ -431,6 +489,7 @@ const updateBoundPoint = ( adjacentPoint, focusPointAbsolute, binding.gap, + elementsMap, ); if (intersections.length === 0) { // This should never happen, since focusPoint should always be @@ -449,6 +508,7 @@ const updateBoundPoint = ( point: LinearElementEditor.pointFromAbsoluteCoords( linearElement, newEdgePoint, + elementsMap, ), }, ], @@ -480,12 +540,14 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( elements: NonDeleted[], + elementsMap: ElementsMap, ): SuggestedBinding[] => { const includedElementIds = new Set(elements.map(({ id }) => id)); return elements.flatMap((element) => isBindingElement(element, false) ? (getElligibleElementsForBindingElement( element as NonDeleted, + elementsMap, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) @@ -499,10 +561,11 @@ export const getEligibleElementsForBinding = ( const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, + elementsMap: ElementsMap, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement(linearElement, "start"), - getElligibleElementForBindingElement(linearElement, "end"), + getElligibleElementForBindingElement(linearElement, "start", elementsMap), + getElligibleElementForBindingElement(linearElement, "end", elementsMap), ].filter( (element): element is NonDeleted => element != null, @@ -512,9 +575,10 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd), + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), Scene.getScene(linearElement)!, ); }; @@ -522,17 +586,23 @@ const getElligibleElementForBindingElement = ( const getLinearElementEdgeCoors = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): { x: number; y: number } => { const index = startOrEnd === "start" ? 0 : -1; return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index), + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + index, + elementsMap, + ), ); }; const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, ): SuggestedPointBinding[] => { - return Scene.getScene(bindableElement)! + const scene = Scene.getScene(bindableElement)!; + return scene .getNonDeletedElements() .map((element) => { if (!isBindingElement(element, false)) { @@ -542,11 +612,13 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, + scene.getNonDeletedElementsMap(), ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, + scene.getNonDeletedElementsMap(), ); if (!canBindStart && !canBindEnd) { return null; @@ -564,6 +636,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( linearElement: NonDeleted, startOrEnd: "start" | "end", bindableElement: NonDeleted, + elementsMap: ElementsMap, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; @@ -576,7 +649,8 @@ const isLinearElementEligibleForNewBindingByBindable = ( ) && bindingBorderTest( bindableElement, - getLinearElementEdgeCoors(linearElement, startOrEnd), + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elementsMap, ) ); }; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 850c50654..253137b07 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -1,4 +1,5 @@ import { ROUNDNESS } from "../constants"; +import { arrayToMap } from "../utils"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; @@ -35,26 +36,26 @@ const _ce = ({ describe("getElementAbsoluteCoords", () => { it("test x1 coordinate", () => { - const [x1] = getElementAbsoluteCoords(_ce({ x: 10, y: 0, w: 10, h: 0 })); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [x1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x1).toEqual(10); }); it("test x2 coordinate", () => { - const [, , x2] = getElementAbsoluteCoords( - _ce({ x: 10, y: 0, w: 10, h: 0 }), - ); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x2).toEqual(20); }); it("test y1 coordinate", () => { - const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 10, w: 0, h: 10 })); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y1).toEqual(10); }); it("test y2 coordinate", () => { - const [, , , y2] = getElementAbsoluteCoords( - _ce({ x: 0, y: 10, w: 0, h: 10 }), - ); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y2).toEqual(20); }); }); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index f892089f7..7eb7fa48a 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -102,8 +102,10 @@ export class ElementBounds { ): Bounds { let bounds: Bounds; - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); - + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => @@ -159,10 +161,9 @@ export class ElementBounds { // This set of functions retrieves the absolute position of the 4 points. export const getElementAbsoluteCoords = ( element: ExcalidrawElement, + elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { - const elementsMap = - Scene.getScene(element)?.getElementsMapIncludingDeleted() || new Map(); if (isFreeDrawElement(element)) { return getFreeDrawElementAbsoluteCoords(element); } else if (isLinearElement(element)) { @@ -179,6 +180,7 @@ export const getElementAbsoluteCoords = ( const coords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); return [ coords.x, @@ -207,8 +209,12 @@ export const getElementAbsoluteCoords = ( */ export const getElementLineSegments = ( element: ExcalidrawElement, + elementsMap: ElementsMap, ): [Point, Point][] => { - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); const center: Point = [cx, cy]; @@ -703,6 +709,7 @@ const getLinearElementRotatedBounds = ( if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x, y, x, y], boundTextElement, ); @@ -727,6 +734,7 @@ const getLinearElementRotatedBounds = ( if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, coords, boundTextElement, ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index b8c07e3ab..ff5a139de 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -91,6 +91,7 @@ export const hitTest = ( ) { return isPointHittingElementBoundingBox( element, + elementsMap, point, threshold, frameNameBoundsCache, @@ -116,6 +117,7 @@ export const hitTest = ( appState, frameNameBoundsCache, point, + elementsMap, ); }; @@ -145,9 +147,11 @@ export const isHittingElementBoundingBoxWithoutHittingElement = ( appState, frameNameBoundsCache, [x, y], + elementsMap, ) && isPointHittingElementBoundingBox( element, + elementsMap, [x, y], threshold, frameNameBoundsCache, @@ -160,6 +164,7 @@ export const isHittingElementNotConsideringBoundingBox = ( appState: AppState, frameNameBoundsCache: FrameNameBoundsCache | null, point: Point, + elementsMap: ElementsMap, ): boolean => { const threshold = 10 / appState.zoom.value; const check = isTextElement(element) @@ -169,6 +174,7 @@ export const isHittingElementNotConsideringBoundingBox = ( : isNearCheck; return hitTestPointAgainstElement({ element, + elementsMap, point, threshold, check, @@ -183,6 +189,7 @@ const isElementSelected = ( export const isPointHittingElementBoundingBox = ( element: NonDeleted, + elementsMap: ElementsMap, [x, y]: Point, threshold: number, frameNameBoundsCache: FrameNameBoundsCache | null, @@ -194,6 +201,7 @@ export const isPointHittingElementBoundingBox = ( if (isFrameLikeElement(element)) { return hitTestPointAgainstElement({ element, + elementsMap, point: [x, y], threshold, check: isInsideCheck, @@ -201,7 +209,7 @@ export const isPointHittingElementBoundingBox = ( }); } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementCenterX = (x1 + x2) / 2; const elementCenterY = (y1 + y2) / 2; // reverse rotate to take element's angle into account. @@ -224,12 +232,14 @@ export const isPointHittingElementBoundingBox = ( export const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, + elementsMap: ElementsMap, ): boolean => { const threshold = maxBindingGap(element, element.width, element.height); const check = isOutsideCheck; const point: Point = [x, y]; return hitTestPointAgainstElement({ element, + elementsMap, point, threshold, check, @@ -251,6 +261,7 @@ export const maxBindingGap = ( type HitTestArgs = { element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; point: Point; threshold: number; check: (distance: number, threshold: number) => boolean; @@ -266,19 +277,28 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { case "text": case "diamond": case "ellipse": - const distance = distanceToBindableElement(args.element, args.point); + const distance = distanceToBindableElement( + args.element, + args.point, + args.elementsMap, + ); return args.check(distance, args.threshold); case "freedraw": { if ( !args.check( - distanceToRectangle(args.element, args.point), + distanceToRectangle(args.element, args.point, args.elementsMap), args.threshold, ) ) { return false; } - return hitTestFreeDrawElement(args.element, args.point, args.threshold); + return hitTestFreeDrawElement( + args.element, + args.point, + args.threshold, + args.elementsMap, + ); } case "arrow": case "line": @@ -293,7 +313,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { // check distance to frame element first if ( args.check( - distanceToBindableElement(args.element, args.point), + distanceToBindableElement(args.element, args.point, args.elementsMap), args.threshold, ) ) { @@ -316,6 +336,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { export const distanceToBindableElement = ( element: ExcalidrawBindableElement, point: Point, + elementsMap: ElementsMap, ): number => { switch (element.type) { case "rectangle": @@ -325,11 +346,11 @@ export const distanceToBindableElement = ( case "embeddable": case "frame": case "magicframe": - return distanceToRectangle(element, point); + return distanceToRectangle(element, point, elementsMap); case "diamond": - return distanceToDiamond(element, point); + return distanceToDiamond(element, point, elementsMap); case "ellipse": - return distanceToEllipse(element, point); + return distanceToEllipse(element, point, elementsMap); } }; @@ -358,8 +379,13 @@ const distanceToRectangle = ( | ExcalidrawIframeLikeElement | ExcalidrawFrameLikeElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); return Math.max( GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), @@ -377,8 +403,13 @@ const distanceToRectangleBox = (box: RectangleBox, point: Point): number => { const distanceToDiamond = ( element: ExcalidrawDiamondElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); const side = GALine.equation(hheight, hwidth, -hheight * hwidth); return GAPoint.distanceToLine(pointRel, side); }; @@ -386,16 +417,22 @@ const distanceToDiamond = ( const distanceToEllipse = ( element: ExcalidrawEllipseElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [pointRel, tangent] = ellipseParamsForTest(element, point); + const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); }; const ellipseParamsForTest = ( element: ExcalidrawEllipseElement, point: Point, + elementsMap: ElementsMap, ): [GA.Point, GA.Line] => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); const [px, py] = GAPoint.toTuple(pointRel); // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` @@ -440,6 +477,7 @@ const hitTestFreeDrawElement = ( element: ExcalidrawFreeDrawElement, point: Point, threshold: number, + elementsMap: ElementsMap, ): boolean => { // Check point-distance-to-line-segment for every segment in the // element's points (its input points, not its outline points). @@ -454,7 +492,10 @@ const hitTestFreeDrawElement = ( y = point[1] - element.y; } else { // Counter-rotate the point around center before testing - const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element); + const [minX, minY, maxX, maxY] = getElementAbsoluteCoords( + element, + elementsMap, + ); const rotatedPoint = rotatePoint( point, [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2], @@ -520,6 +561,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => { const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( args.element, args.point, + args.elementsMap, ); const side1 = GALine.equation(0, 1, -hheight); const side2 = GALine.equation(1, 0, -hwidth); @@ -572,9 +614,10 @@ const hitTestLinear = (args: HitTestArgs): boolean => { const pointRelativeToElement = ( element: ExcalidrawElement, pointTuple: Point, + elementsMap: ElementsMap, ): [GA.Point, GA.Point, number, number] => { const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); @@ -609,11 +652,12 @@ const pointRelativeToDivElement = ( // Returns point in absolute coordinates export const pointInAbsoluteCoords = ( element: ExcalidrawElement, + elementsMap: ElementsMap, // Point relative to the element position point: Point, ): Point => { const [x, y] = point; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x2 - x1) / 2; const cy = (y2 - y1) / 2; const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle); @@ -622,8 +666,9 @@ export const pointInAbsoluteCoords = ( const relativizationToElementCenter = ( element: ExcalidrawElement, + elementsMap: ElementsMap, ): GA.Transform => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); @@ -649,12 +694,14 @@ const coordsCenter = ( // of the element. export const determineFocusDistance = ( element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates a: Point, // Another point on the line, in absolute coordinates (closer to element) b: Point, + elementsMap: ElementsMap, ): number => { - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const line = GALine.through(aRel, bRel); @@ -693,13 +740,14 @@ export const determineFocusPoint = ( // returned focusPoint focus: number, adjecentPoint: Point, + elementsMap: ElementsMap, ): Point => { if (focus === 0) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); return GAPoint.toTuple(center); } - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const adjecentPointRel = GATransform.apply( relateToCenter, GAPoint.from(adjecentPoint), @@ -728,14 +776,16 @@ export const determineFocusPoint = ( // and the `element`, in ascending order of distance from `a`. export const intersectElementWithLine = ( element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates a: Point, // Another point on the line, in absolute coordinates b: Point, // If given, the element is inflated by this value gap: number = 0, + elementsMap: ElementsMap, ): Point[] => { - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const line = GALine.through(aRel, bRel); diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 0144f55a4..5121f52bd 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -65,7 +65,7 @@ export const dragSelectedElements = ( updateElementCoords(pointerDownState, textElement, adjustedOffset); } } - updateBoundElements(element, { + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { simultaneouslyUpdated: Array.from(elementsToUpdate), }); }); diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 5c3c6acaa..85483b3d7 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -135,6 +135,7 @@ export class LinearElementEditor { event: PointerEvent, appState: AppState, setState: React.Component["setState"], + elementsMap: ElementsMap, ) { if ( !appState.editingLinearElement || @@ -151,10 +152,12 @@ export class LinearElementEditor { } const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(appState.draggingElement); + getElementAbsoluteCoords(appState.draggingElement, elementsMap); - const pointsSceneCoords = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); const nextSelectedPoints = pointsSceneCoords.reduce( (acc: number[], point, index) => { @@ -222,6 +225,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, referencePoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -239,6 +243,7 @@ export class LinearElementEditor { } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -255,6 +260,7 @@ export class LinearElementEditor { linearElementEditor.pointerDownState.lastClickedPoint ? LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -290,6 +296,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[0], + elementsMap, ), ), ); @@ -303,6 +310,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[lastSelectedIndex], + elementsMap, ), ), ); @@ -323,6 +331,7 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, + elementsMap: ElementsMap, ): LinearElementEditor { const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; @@ -364,6 +373,7 @@ export class LinearElementEditor { LinearElementEditor.getPointAtIndexGlobalCoordinates( element, selectedPoint!, + elementsMap, ), ), Scene.getScene(element)!, @@ -425,15 +435,23 @@ export class LinearElementEditor { ) { return editorMidPointsCache.points; } - LinearElementEditor.updateEditorMidPointsCache(element, appState); + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); return editorMidPointsCache.points!; }; static updateEditorMidPointsCache = ( element: NonDeleted, + elementsMap: ElementsMap, appState: InteractiveCanvasAppState, ) => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let index = 0; const midpoints: (Point | null)[] = []; @@ -455,6 +473,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); midpoints.push(segmentMidPoint); index++; @@ -477,6 +496,7 @@ export class LinearElementEditor { } const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -484,7 +504,10 @@ export class LinearElementEditor { if (clickedPointIndex >= 0) { return null; } - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length >= 3 && !appState.editingLinearElement) { return null; } @@ -550,6 +573,7 @@ export class LinearElementEditor { startPoint: Point, endPoint: Point, endPointIndex: number, + elementsMap: ElementsMap, ) { let segmentMidPoint = centerPoint(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { @@ -574,6 +598,7 @@ export class LinearElementEditor { segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( element, [tx, ty], + elementsMap, ); } } @@ -658,6 +683,7 @@ export class LinearElementEditor { ...element.points, LinearElementEditor.createPointAt( element, + elementsMap, scenePointer.x, scenePointer.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -693,6 +719,7 @@ export class LinearElementEditor { const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -713,11 +740,12 @@ export class LinearElementEditor { element, startBindingElement, endBindingElement, + elementsMap, ); } } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const targetPoint = @@ -779,6 +807,7 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, appState: AppState, + elementsMap: ElementsMap, ): LinearElementEditor | null { if (!appState.editingLinearElement) { return null; @@ -809,6 +838,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, lastCommittedPoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -821,6 +851,7 @@ export class LinearElementEditor { } else { newPoint = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerY - appState.editingLinearElement.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -847,8 +878,9 @@ export class LinearElementEditor { static getPointGlobalCoordinates( element: NonDeleted, point: Point, + elementsMap: ElementsMap, ) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -860,8 +892,9 @@ export class LinearElementEditor { /** scene coords */ static getPointsGlobalCoordinates( element: NonDeleted, + elementsMap: ElementsMap, ): Point[] { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return element.points.map((point) => { @@ -873,13 +906,15 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, + indexMaybeFromEnd: number, // -1 for last element + elementsMap: ElementsMap, ): Point { const index = indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd : indexMaybeFromEnd; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -893,8 +928,9 @@ export class LinearElementEditor { static pointFromAbsoluteCoords( element: NonDeleted, absoluteCoords: Point, + elementsMap: ElementsMap, ): Point { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [x, y] = rotate( @@ -909,12 +945,15 @@ export class LinearElementEditor { static getPointIndexUnderCursor( element: NonDeleted, + elementsMap: ElementsMap, zoom: AppState["zoom"], x: number, y: number, ) { - const pointHandles = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointHandles = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let idx = pointHandles.length; // loop from right to left because points on the right are rendered over // points on the left, thus should take precedence when clicking, if they @@ -934,12 +973,13 @@ export class LinearElementEditor { static createPointAt( element: NonDeleted, + elementsMap: ElementsMap, scenePointerX: number, scenePointerY: number, gridSize: number | null, ): Point { const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [rotatedX, rotatedY] = rotate( @@ -1190,6 +1230,7 @@ export class LinearElementEditor { pointerCoords: PointerCoords, appState: AppState, snapToGrid: boolean, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, @@ -1208,6 +1249,7 @@ export class LinearElementEditor { const midpoint = LinearElementEditor.createPointAt( element, + elementsMap, pointerCoords.x, pointerCoords.y, snapToGrid ? appState.gridSize : null, @@ -1260,6 +1302,7 @@ export class LinearElementEditor { private static _getShiftLockedDelta( element: NonDeleted, + elementsMap: ElementsMap, referencePoint: Point, scenePointer: Point, gridSize: number | null, @@ -1267,6 +1310,7 @@ export class LinearElementEditor { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( element, referencePoint, + elementsMap, ); const [gridX, gridY] = getGridPoint( @@ -1288,8 +1332,12 @@ export class LinearElementEditor { static getBoundTextElementPosition = ( element: ExcalidrawLinearElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ): { x: number; y: number } => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length < 2) { mutateElement(boundTextElement, { isDeleted: true }); } @@ -1300,6 +1348,7 @@ export class LinearElementEditor { const midPoint = LinearElementEditor.getPointGlobalCoordinates( element, element.points[index], + elementsMap, ); x = midPoint[0] - boundTextElement.width / 2; y = midPoint[1] - boundTextElement.height / 2; @@ -1319,6 +1368,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); } x = midSegmentMidpoint[0] - boundTextElement.width / 2; @@ -1329,6 +1379,7 @@ export class LinearElementEditor { static getMinMaxXYWithBoundText = ( element: ExcalidrawLinearElement, + elementsMap: ElementsMap, elementBounds: Bounds, boundTextElement: ExcalidrawTextElementWithContainer, ): [number, number, number, number, number, number] => { @@ -1339,6 +1390,7 @@ export class LinearElementEditor { LinearElementEditor.getBoundTextElementPosition( element, boundTextElement, + elementsMap, ); const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextY2 = boundTextY1 + boundTextElement.height; @@ -1479,6 +1531,7 @@ export class LinearElementEditor { if (boundTextElement) { coords = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x1, y1, x2, y2], boundTextElement, ); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index f1e0d8093..076f64722 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -16,6 +16,7 @@ import { ExcalidrawEmbeddableElement, ExcalidrawMagicFrameElement, ExcalidrawIframeElement, + ElementsMap, } from "./types"; import { arrayToMap, @@ -260,6 +261,7 @@ export const newTextElement = ( const getAdjustedDimensions = ( element: ExcalidrawTextElement, + elementsMap: ElementsMap, nextText: string, ): { x: number; @@ -294,7 +296,7 @@ const getAdjustedDimensions = ( x = element.x - offsets.x; y = element.y - offsets.y; } else { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( element, @@ -335,6 +337,7 @@ const getAdjustedDimensions = ( export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, text = textElement.text, ) => { if (textElement.isDeleted) { @@ -347,13 +350,14 @@ export const refreshTextDimensions = ( getBoundTextMaxWidth(container, textElement), ); } - const dimensions = getAdjustedDimensions(textElement, text); + const dimensions = getAdjustedDimensions(textElement, elementsMap, text); return { text, ...dimensions }; }; export const updateTextElement = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, { text, isDeleted, @@ -367,7 +371,7 @@ export const updateTextElement = ( return newElementWith(textElement, { originalText, isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, container, originalText), + ...refreshTextDimensions(textElement, container, elementsMap, originalText), }); }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index deb5fead3..49724d9eb 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -86,11 +86,12 @@ export const transformElements = ( if (transformHandleType === "rotation") { rotateSingleElement( element, + elementsMap, pointerX, pointerY, shouldRotateWithDiscreteAngle, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if ( isTextElement(element) && (transformHandleType === "nw" || @@ -106,7 +107,7 @@ export const transformElements = ( pointerX, pointerY, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if (transformHandleType) { resizeSingleElement( originalElements, @@ -157,11 +158,12 @@ export const transformElements = ( const rotateSingleElement = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; let angle: number; @@ -266,7 +268,7 @@ const resizeSingleTextElement = ( pointerX: number, pointerY: number, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; // rotation pointer with reverse angle @@ -629,7 +631,7 @@ export const resizeSingleElement = ( ) { mutateElement(element, resizedElement); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { newSize: { width: resizedElement.width, height: resizedElement.height }, }); @@ -696,7 +698,11 @@ export const resizeMultipleElements = ( if (!isBoundToContainer(text)) { return acc; } - const xy = LinearElementEditor.getBoundTextElementPosition(orig, text); + const xy = LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ); return [...acc, { ...text, ...xy }]; }, [] as ExcalidrawTextElementWithContainer[]); @@ -879,7 +885,7 @@ export const resizeMultipleElements = ( mutateElement(element, update, false); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { simultaneouslyUpdated: elementsToUpdate, newSize: { width, height }, }); @@ -921,7 +927,7 @@ const rotateMultipleElements = ( elements .filter((element) => !isFrameLikeElement(element)) .forEach((element) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const origAngle = @@ -942,7 +948,9 @@ const rotateMultipleElements = ( }, false, ); - updateBoundElements(element, { simultaneouslyUpdated: elements }); + updateBoundElements(element, elementsMap, { + simultaneouslyUpdated: elements, + }); const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { @@ -964,12 +972,13 @@ const rotateMultipleElements = ( export const getResizeOffsetXY = ( transformHandleType: MaybeTransformHandleType, selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, x: number, y: number, ): [number, number] => { const [x1, y1, x2, y2] = selectedElements.length === 1 - ? getElementAbsoluteCoords(selectedElements[0]) + ? getElementAbsoluteCoords(selectedElements[0], elementsMap) : getCommonBounds(selectedElements); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 9947a6082..2e01f94d9 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType, NonDeletedExcalidrawElement, + ElementsMap, } from "./types"; import { @@ -27,6 +28,7 @@ const isInsideTransformHandle = ( export const resizeTest = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, x: number, y: number, @@ -38,7 +40,7 @@ export const resizeTest = ( } const { rotation: rotationTransformHandle, ...transformHandles } = - getTransformHandles(element, zoom, pointerType); + getTransformHandles(element, zoom, elementsMap, pointerType); if ( rotationTransformHandle && @@ -70,6 +72,7 @@ export const getElementWithTransformHandleType = ( scenePointerY: number, zoom: Zoom, pointerType: PointerType, + elementsMap: ElementsMap, ) => { return elements.reduce((result, element) => { if (result) { @@ -77,6 +80,7 @@ export const getElementWithTransformHandleType = ( } const transformHandleType = resizeTest( element, + elementsMap, appState, scenePointerX, scenePointerY, diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index fc4c15f2d..4aa0868d7 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -53,6 +53,7 @@ const splitIntoLines = (text: string) => { export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, + elementsMap: ElementsMap, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -110,7 +111,11 @@ export const redrawTextBoundingBox = ( ...textElement, ...boundTextUpdates, } as ExcalidrawTextElementWithContainer; - const { x, y } = computeBoundTextPosition(container, updatedTextElement); + const { x, y } = computeBoundTextPosition( + container, + updatedTextElement, + elementsMap, + ); boundTextUpdates.x = x; boundTextUpdates.y = y; } @@ -119,11 +124,11 @@ export const redrawTextBoundingBox = ( }; export const bindTextToShapeAfterDuplication = ( - sceneElements: ExcalidrawElement[], + newElements: ExcalidrawElement[], oldElements: ExcalidrawElement[], oldIdToDuplicatedId: Map, ): void => { - const sceneElementMap = arrayToMap(sceneElements) as Map< + const newElementsMap = arrayToMap(newElements) as Map< ExcalidrawElement["id"], ExcalidrawElement >; @@ -134,7 +139,7 @@ export const bindTextToShapeAfterDuplication = ( if (boundTextElementId) { const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId); if (newTextElementId) { - const newContainer = sceneElementMap.get(newElementId); + const newContainer = newElementsMap.get(newElementId); if (newContainer) { mutateElement(newContainer, { boundElements: (element.boundElements || []) @@ -149,7 +154,7 @@ export const bindTextToShapeAfterDuplication = ( }), }); } - const newTextElement = sceneElementMap.get(newTextElementId); + const newTextElement = newElementsMap.get(newTextElementId); if (newTextElement && isTextElement(newTextElement)) { mutateElement(newTextElement, { containerId: newContainer ? newElementId : null, @@ -236,7 +241,7 @@ export const handleBindTextResize = ( if (!isArrowElement(container)) { mutateElement( textElement, - computeBoundTextPosition(container, textElement), + computeBoundTextPosition(container, textElement, elementsMap), ); } } @@ -245,11 +250,13 @@ export const handleBindTextResize = ( export const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } const containerCoords = getContainerCoords(container); @@ -698,12 +705,16 @@ export const getContainerCenter = ( y: container.y + container.height / 2, }; } - const points = LinearElementEditor.getPointsGlobalCoordinates(container); + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); if (points.length % 2 === 1) { const index = Math.floor(container.points.length / 2); const midPoint = LinearElementEditor.getPointGlobalCoordinates( container, container.points[index], + elementsMap, ); return { x: midPoint[0], y: midPoint[1] }; } @@ -719,6 +730,7 @@ export const getContainerCenter = ( points[index], points[index + 1], index + 1, + elementsMap, ); } return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; @@ -757,11 +769,13 @@ export const getTextElementAngle = ( export const getBoundTextElementPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } }; @@ -804,6 +818,7 @@ export const getTextBindableContainerAtPosition = ( appState: AppState, x: number, y: number, + elementsMap: ElementsMap, ): ExcalidrawTextContainer | null => { const selectedElements = getSelectedElements(elements, appState); if (selectedElements.length === 1) { @@ -817,7 +832,10 @@ export const getTextBindableContainerAtPosition = ( if (elements[index].isDeleted) { continue; } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + elements[index], + elementsMap, + ); if ( isArrowElement(elements[index]) && isHittingElementNotConsideringBoundingBox( @@ -825,6 +843,7 @@ export const getTextBindableContainerAtPosition = ( appState, null, [x, y], + elementsMap, ) ) { hitElement = elements[index]; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 1a628dd46..ae30be4e9 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -121,13 +121,13 @@ export const textWysiwyg = ({ return; } const { textAlign, verticalAlign } = updatedTextElement; - + const elementsMap = app.scene.getNonDeletedElementsMap(); if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; const container = getContainerElement( updatedTextElement, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); let maxWidth = updatedTextElement.width; @@ -143,6 +143,7 @@ export const textWysiwyg = ({ LinearElementEditor.getBoundTextElementPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordX = boundTextCoords.x; coordY = boundTextCoords.y; @@ -200,6 +201,7 @@ export const textWysiwyg = ({ const { y } = computeBoundTextPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordY = y; } @@ -326,7 +328,7 @@ export const textWysiwyg = ({ } const container = getContainerElement( element, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); const font = getFontString({ @@ -513,7 +515,7 @@ export const textWysiwyg = ({ let text = editable.value; const container = getContainerElement( updateElement, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); if (container) { @@ -541,7 +543,11 @@ export const textWysiwyg = ({ ), }); } - redrawTextBoundingBox(updateElement, container); + redrawTextBoundingBox( + updateElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } onSubmit({ diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 19c60a93f..aee745530 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -1,4 +1,5 @@ import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, PointerType, @@ -230,6 +231,8 @@ export const getTransformHandlesFromCoords = ( export const getTransformHandles = ( element: ExcalidrawElement, zoom: Zoom, + elementsMap: ElementsMap, + pointerType: PointerType = "mouse", ): TransformHandles => { // so that when locked element is selected (especially when you toggle lock @@ -267,7 +270,7 @@ export const getTransformHandles = ( ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 : DEFAULT_TRANSFORM_HANDLE_SPACING; return getTransformHandlesFromCoords( - getElementAbsoluteCoords(element, true), + getElementAbsoluteCoords(element, elementsMap, true), element.angle, zoom, pointerType, diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index c4a5a259d..8f550e86a 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -65,10 +65,11 @@ export const bindElementsToFramesAfterDuplication = ( export function isElementIntersectingFrame( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) { - const frameLineSegments = getElementLineSegments(frame); + const frameLineSegments = getElementLineSegments(frame, elementsMap); - const elementLineSegments = getElementLineSegments(element); + const elementLineSegments = getElementLineSegments(element, elementsMap); const intersecting = frameLineSegments.some((frameLineSegment) => elementLineSegments.some((elementLineSegment) => @@ -82,9 +83,10 @@ export function isElementIntersectingFrame( export const getElementsCompletelyInFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => omitGroupsContainingFrameLikes( - getElementsWithinSelection(elements, frame, false), + getElementsWithinSelection(elements, frame, elementsMap, false), ).filter( (element) => (!isFrameLikeElement(element) && !element.frameId) || @@ -95,8 +97,9 @@ export const isElementContainingFrame = ( elements: readonly ExcalidrawElement[], element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - return getElementsWithinSelection(elements, element).some( + return getElementsWithinSelection(elements, element, elementsMap).some( (e) => e.id === frame.id, ); }; @@ -104,13 +107,22 @@ export const isElementContainingFrame = ( export const getElementsIntersectingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => elements.filter((element) => isElementIntersectingFrame(element, frame)); +) => { + const elementsMap = arrayToMap(elements); + return elements.filter((element) => + isElementIntersectingFrame(element, frame, elementsMap), + ); +}; export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords(frame); + const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords( + frame, + elementsMap, + ); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(elements); @@ -126,11 +138,12 @@ export const elementsAreInFrameBounds = ( export const elementOverlapsWithFrame = ( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return ( - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame) || - isElementContainingFrame([frame], element, frame) + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap) || + isElementContainingFrame([frame], element, frame, elementsMap) ); }; @@ -140,8 +153,9 @@ export const isCursorInFrame = ( y: number; }, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); + const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap); return isPointWithinBounds( [fx1, fy1], @@ -155,6 +169,7 @@ export const groupsAreAtLeastIntersectingTheFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -165,8 +180,8 @@ export const groupsAreAtLeastIntersectingTheFrame = ( return !!elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ); }; @@ -175,6 +190,7 @@ export const groupsAreCompletelyOutOfFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -186,8 +202,8 @@ export const groupsAreCompletelyOutOfFrame = ( return ( elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ) === undefined ); }; @@ -258,14 +274,15 @@ export const getElementsInResizingFrame = ( allElements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, appState: AppState, + elementsMap: ElementsMap, ): ExcalidrawElement[] => { const prevElementsInFrame = getFrameChildren(allElements, frame.id); const nextElementsInFrame = new Set(prevElementsInFrame); const elementsCompletelyInFrame = new Set([ - ...getElementsCompletelyInFrame(allElements, frame), + ...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...prevElementsInFrame.filter((element) => - isElementContainingFrame(allElements, element, frame), + isElementContainingFrame(allElements, element, frame, elementsMap), ), ]); @@ -283,7 +300,7 @@ export const getElementsInResizingFrame = ( ); for (const element of elementsNotCompletelyInFrame) { - if (!isElementIntersectingFrame(element, frame)) { + if (!isElementIntersectingFrame(element, frame, elementsMap)) { if (element.groupIds.length === 0) { nextElementsInFrame.delete(element); } @@ -334,7 +351,7 @@ export const getElementsInResizingFrame = ( if (isSelected) { const elementsInGroup = getElementsInGroup(allElements, id); - if (elementsAreInFrameBounds(elementsInGroup, frame)) { + if (elementsAreInFrameBounds(elementsInGroup, frame, elementsMap)) { for (const element of elementsInGroup) { nextElementsInFrame.add(element); } @@ -348,12 +365,13 @@ export const getElementsInResizingFrame = ( }; export const getElementsInNewFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, + elements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return omitGroupsContainingFrameLikes( - allElements, - getElementsCompletelyInFrame(allElements, frame), + elements, + getElementsCompletelyInFrame(elements, frame, elementsMap), ); }; @@ -388,7 +406,7 @@ export const filterElementsEligibleAsFrameChildren = ( frame: ExcalidrawFrameLikeElement, ) => { const otherFrames = new Set(); - + const elementsMap = arrayToMap(elements); elements = omitGroupsContainingFrameLikes(elements); for (const element of elements) { @@ -415,14 +433,18 @@ export const filterElementsEligibleAsFrameChildren = ( if (!processedGroups.has(shallowestGroupId)) { processedGroups.add(shallowestGroupId); const groupElements = getElementsInGroup(elements, shallowestGroupId); - if (groupElements.some((el) => elementOverlapsWithFrame(el, frame))) { + if ( + groupElements.some((el) => + elementOverlapsWithFrame(el, frame, elementsMap), + ) + ) { for (const child of groupElements) { eligibleElements.push(child); } } } } else { - const overlaps = elementOverlapsWithFrame(element, frame); + const overlaps = elementOverlapsWithFrame(element, frame, elementsMap); if (overlaps) { eligibleElements.push(element); } @@ -682,12 +704,12 @@ export const getTargetFrame = ( // given an element, return if the element is in some frame export const isElementInFrame = ( element: ExcalidrawElement, - allElements: ElementsMap, + allElementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { - const frame = getTargetFrame(element, allElements, appState); + const frame = getTargetFrame(element, allElementsMap, appState); const _element = isTextElement(element) - ? getContainerElement(element, allElements) || element + ? getContainerElement(element, allElementsMap) || element : element; if (frame) { @@ -703,16 +725,18 @@ export const isElementInFrame = ( } if (_element.groupIds.length === 0) { - return elementOverlapsWithFrame(_element, frame); + return elementOverlapsWithFrame(_element, frame, allElementsMap); } const allElementsInGroup = new Set( - _element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)), + _element.groupIds.flatMap((gid) => + getElementsInGroup(allElementsMap, gid), + ), ); if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { const selectedElements = new Set( - getSelectedElements(allElements, appState), + getSelectedElements(allElementsMap, appState), ); const editingGroupOverlapsFrame = appState.frameToHighlight !== null; @@ -733,7 +757,7 @@ export const isElementInFrame = ( } for (const elementInGroup of allElementsInGroup) { - if (elementOverlapsWithFrame(elementInGroup, frame)) { + if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) { return true; } } diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index de4bcfe53..a0b8228c9 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -7,6 +7,7 @@ import { ExcalidrawTextElementWithContainer, ExcalidrawFrameLikeElement, NonDeletedSceneElementsMap, + ElementsMap, } from "../element/types"; import { isTextElement, @@ -137,6 +138,7 @@ export interface ExcalidrawElementWithCanvas { const cappedElementCanvasSize = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, zoom: Zoom, ): { width: number; @@ -155,7 +157,7 @@ const cappedElementCanvasSize = ( const padding = getCanvasPadding(element); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementWidth = isLinearElement(element) || isFreeDrawElement(element) ? distance(x1, x2) @@ -200,7 +202,11 @@ const generateElementCanvas = ( const context = canvas.getContext("2d")!; const padding = getCanvasPadding(element); - const { width, height, scale } = cappedElementCanvasSize(element, zoom); + const { width, height, scale } = cappedElementCanvasSize( + element, + elementsMap, + zoom, + ); canvas.width = width; canvas.height = height; @@ -209,7 +215,7 @@ const generateElementCanvas = ( let canvasOffsetY = 0; if (isLinearElement(element) || isFreeDrawElement(element)) { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); canvasOffsetX = element.x > x1 @@ -468,7 +474,7 @@ const drawElementFromCanvas = ( const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; - let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); // Free draw elements will otherwise "shuffle" as the min x and y change if (isFreeDrawElement(element)) { @@ -513,8 +519,10 @@ const drawElementFromCanvas = ( elementWithCanvas.canvas.height, ); - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + allElementsMap, + ); tempCanvasContext.rotate(-element.angle); @@ -694,7 +702,7 @@ export const renderElement = ( ShapeCache.generateElementShape(element, null); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); @@ -737,7 +745,7 @@ export const renderElement = ( // rely on existing shapes ShapeCache.generateElementShape(element, renderConfig); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; let shiftX = (x2 - x1) / 2 - (element.x - x1); @@ -749,6 +757,7 @@ export const renderElement = ( LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); @@ -804,8 +813,10 @@ export const renderElement = ( tempCanvasContext.rotate(-element.angle); // Shift the canvas to center of bound text - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); @@ -939,17 +950,18 @@ export const renderElementToSvg = ( renderConfig: SVGRenderConfig, ) => { const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); let cx = (x2 - x1) / 2 - (element.x - x1); let cy = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); @@ -1151,6 +1163,7 @@ export const renderElementToSvg = ( const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( element, boundText, + elementsMap, ); const maskX = offsetX + boundTextCoords.x - element.x; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index d31d69650..d80540fd0 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -17,6 +17,7 @@ import { GroupId, ExcalidrawBindableElement, ExcalidrawFrameLikeElement, + ElementsMap, } from "../element/types"; import { getElementAbsoluteCoords, @@ -256,7 +257,10 @@ const renderLinearPointHandles = ( context.save(); context.translate(appState.scrollX, appState.scrollY); context.lineWidth = 1 / appState.zoom.value; - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); const { POINT_HANDLE_SIZE } = LinearElementEditor; const radius = appState.editingLinearElement @@ -340,6 +344,7 @@ const highlightPoint = ( const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, ) => { const { elementId, hoverPointIndex } = appState.selectedLinearElement!; if ( @@ -356,6 +361,7 @@ const renderLinearElementPointHighlight = ( const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, hoverPointIndex, + elementsMap, ); context.save(); context.translate(appState.scrollX, appState.scrollY); @@ -510,12 +516,22 @@ const _renderInteractiveScene = ({ appState.suggestedBindings .filter((binding) => binding != null) .forEach((suggestedBinding) => { - renderBindingHighlight(context, appState, suggestedBinding!); + renderBindingHighlight( + context, + appState, + suggestedBinding!, + elementsMap, + ); }); } if (appState.frameToHighlight) { - renderFrameHighlight(context, appState, appState.frameToHighlight); + renderFrameHighlight( + context, + appState, + appState.frameToHighlight, + elementsMap, + ); } if (appState.elementsToHighlight) { @@ -545,7 +561,7 @@ const _renderInteractiveScene = ({ appState.selectedLinearElement && appState.selectedLinearElement.hoverPointIndex >= 0 ) { - renderLinearElementPointHighlight(context, appState); + renderLinearElementPointHighlight(context, appState, elementsMap); } // Paint selected elements if (!appState.multiElement && !appState.editingLinearElement) { @@ -608,7 +624,7 @@ const _renderInteractiveScene = ({ if (selectionColors.length) { const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, true); + getElementAbsoluteCoords(element, elementsMap, true); selections.push({ angle: element.angle, elementX1, @@ -666,7 +682,8 @@ const _renderInteractiveScene = ({ const transformHandles = getTransformHandles( selectedElements[0], appState.zoom, - "mouse", // when we render we don't know which pointer type so use mouse + elementsMap, + "mouse", // when we render we don't know which pointer type so use mouse, ); if (!appState.viewModeEnabled && showBoundingBox) { renderTransformHandles( @@ -953,7 +970,11 @@ const _renderStaticScene = ({ element.groupIds.length > 0 && appState.frameToHighlight && appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame(element, appState.frameToHighlight) || + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) ) { element.groupIds.forEach((groupId) => @@ -1004,7 +1025,7 @@ const _renderStaticScene = ({ ); } if (!isExporting) { - renderLinkIcon(element, context, appState); + renderLinkIcon(element, context, appState, elementsMap); } } catch (error: any) { console.error(error); @@ -1048,7 +1069,7 @@ const _renderStaticScene = ({ ); } if (!isExporting) { - renderLinkIcon(element, context, appState); + renderLinkIcon(element, context, appState, elementsMap); } }; // - when exporting the whole canvas, we DO NOT apply clipping @@ -1247,6 +1268,7 @@ const renderBindingHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, ) => { const renderHighlight = Array.isArray(suggestedBinding) ? renderBindingHighlightForSuggestedPointBinding @@ -1254,7 +1276,7 @@ const renderBindingHighlight = ( context.save(); context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any); + renderHighlight(context, suggestedBinding as any, elementsMap); context.restore(); }; @@ -1262,8 +1284,9 @@ const renderBindingHighlight = ( const renderBindingHighlightForBindableElement = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const width = x2 - x1; const height = y2 - y1; const threshold = maxBindingGap(element, width, height); @@ -1323,8 +1346,9 @@ const renderFrameHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const width = x2 - x1; const height = y2 - y1; @@ -1398,6 +1422,7 @@ const renderElementsBoxHighlight = ( const renderBindingHighlightForSuggestedPointBinding = ( context: CanvasRenderingContext2D, suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, ) => { const [element, startOrEnd, bindableElement] = suggestedBinding; @@ -1416,6 +1441,7 @@ const renderBindingHighlightForSuggestedPointBinding = ( const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, index, + elementsMap, ); fillCircle(context, x, y, threshold); }); @@ -1426,9 +1452,10 @@ const renderLinkIcon = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, appState: StaticCanvasAppState, + elementsMap: ElementsMap, ) => { if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y, width, height] = getLinkHandleFromCoords( [x1, y1, x2, y2], element.angle, diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index 1a97c06e0..6691e90be 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -60,10 +60,8 @@ export class Fonts { return newElementWith(element, { ...refreshTextDimensions( element, - getContainerElement( - element, - this.scene.getElementsMapIncludingDeleted(), - ), + getContainerElement(element, this.scene.getNonDeletedElementsMap()), + this.scene.getNonDeletedElementsMap(), ), }); } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index d463e2597..a8d08c900 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -392,8 +392,9 @@ export const exportToSvg = async ( const frameElements = getFrameLikeElements(elements); let exportingFrameClipPath = ""; + const elementsMap = arrayToMap(elements); for (const frame of frameElements) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const cx = (x2 - x1) / 2 - (frame.x - x1); const cy = (y2 - y1) / 2 - (frame.y - y1); diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index ae021f6aa..27d4db1c9 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -1,4 +1,5 @@ import { + ElementsMap, ElementsMapOrArray, ExcalidrawElement, NonDeletedExcalidrawElement, @@ -44,10 +45,11 @@ export const excludeElementsInFramesFromSelection = < export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], selection: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, excludeElementsInFrames: boolean = true, ) => { const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(selection); + getElementAbsoluteCoords(selection, elementsMap); let elementsInSelection = elements.filter((element) => { let [elementX1, elementY1, elementX2, elementY2] = @@ -82,7 +84,7 @@ export const getElementsWithinSelection = ( const containingFrame = getContainingFrame(element); if (containingFrame) { - return elementOverlapsWithFrame(element, containingFrame); + return elementOverlapsWithFrame(element, containingFrame, elementsMap); } return true; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 7557145ae..3061c02d4 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -8,15 +8,18 @@ import { import { MaybeTransformHandleType } from "./element/transformHandles"; import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks"; import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; -import { getVisibleAndNonSelectedElements } from "./scene/selection"; +import { + getSelectedElements, + getVisibleAndNonSelectedElements, +} from "./scene/selection"; import { AppState, KeyboardModifiersObject, Point } from "./types"; -import { arrayToMap } from "./utils"; const SNAP_DISTANCE = 8; @@ -167,6 +170,7 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { export const getElementsCorners = ( elements: ExcalidrawElement[], + elementsMap: ElementsMap, { omitCenter, boundingBoxCorners, @@ -185,7 +189,10 @@ export const getElementsCorners = ( if (elements.length === 1) { const element = elements[0]; - let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (dragOffset) { x1 += dragOffset.x; @@ -280,6 +287,7 @@ export const getVisibleGaps = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements: ExcalidrawElement[] = getReferenceElements( elements, @@ -287,10 +295,7 @@ export const getVisibleGaps = ( appState, ); - const referenceBounds = getMaximumGroups( - referenceElements, - arrayToMap(elements), - ) + const referenceBounds = getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), @@ -569,19 +574,19 @@ export const getReferenceSnapPoints = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements = getReferenceElements( elements, selectedElements, appState, ); - - return getMaximumGroups(referenceElements, arrayToMap(elements)) + return getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), ) - .flatMap((elementGroup) => getElementsCorners(elementGroup)); + .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); }; const getPointSnaps = ( @@ -641,11 +646,13 @@ const getPointSnaps = ( }; export const snapDraggedElements = ( - selectedElements: ExcalidrawElement[], + elements: ExcalidrawElement[], dragOffset: Vector2D, appState: AppState, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { + const selectedElements = getSelectedElements(elements, appState); if ( !isSnappingEnabled({ appState, event, selectedElements }) || selectedElements.length === 0 @@ -658,7 +665,6 @@ export const snapDraggedElements = ( snapLines: [], }; } - dragOffset.x = round(dragOffset.x); dragOffset.y = round(dragOffset.y); const nearestSnapsX: Snaps = []; @@ -669,7 +675,7 @@ export const snapDraggedElements = ( y: snapDistance, }; - const selectionPoints = getElementsCorners(selectedElements, { + const selectionPoints = getElementsCorners(selectedElements, elementsMap, { dragOffset, }); @@ -719,7 +725,7 @@ export const snapDraggedElements = ( getPointSnaps( selectedElements, - getElementsCorners(selectedElements, { + getElementsCorners(selectedElements, elementsMap, { dragOffset: newDragOffset, }), appState, @@ -1204,6 +1210,7 @@ export const snapNewElement = ( event: KeyboardModifiersObject, origin: Vector2D, dragOffset: Vector2D, + elementsMap: ElementsMap, ) => { if ( !isSnappingEnabled({ event, selectedElements: [draggingElement], appState }) @@ -1248,7 +1255,7 @@ export const snapNewElement = ( nearestSnapsX.length = 0; nearestSnapsY.length = 0; - const corners = getElementsCorners([draggingElement], { + const corners = getElementsCorners([draggingElement], elementsMap, { boundingBoxCorners: true, omitCenter: true, }); @@ -1276,6 +1283,7 @@ export const getSnapLinesAtPointer = ( appState: AppState, pointer: Vector2D, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { if (!isSnappingEnabled({ event, selectedElements: [], appState })) { return { @@ -1301,7 +1309,7 @@ export const getSnapLinesAtPointer = ( const verticalSnapLines: PointerSnapLine[] = []; for (const referenceElement of referenceElements) { - const corners = getElementsCorners([referenceElement]); + const corners = getElementsCorners([referenceElement], elementsMap); for (const corner of corners) { const offsetX = corner[0] - pointer.x; diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index cb2c4b340..9e074c2e5 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -5,6 +5,7 @@ import { getTransformHandles } from "../element/transformHandles"; import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; +import { arrayToMap } from "../utils"; const { h } = window; @@ -91,8 +92,12 @@ describe("element binding", () => { expect(arrow.startBinding?.elementId).toBe(rectLeft.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id); - const rotation = getTransformHandles(arrow, h.state.zoom, "mouse") - .rotation!; + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; const rotationHandleX = rotation[0] + rotation[2] / 2; const rotationHandleY = rotation[1] + rotation[3] / 2; mouse.down(rotationHandleX, rotationHandleY); diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 875e87752..bd141f6be 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -27,7 +27,7 @@ import * as blob from "../data/blob"; import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; -import { cloneJSON } from "../utils"; +import { arrayToMap, cloneJSON } from "../utils"; const { h } = window; const mouse = new Pointer("mouse"); @@ -194,9 +194,10 @@ const checkElementsBoundingBox = async ( element2: ExcalidrawElement, toleranceInPx: number = 0, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1); + const elementsMap = arrayToMap([element1, element2]); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1, elementsMap); - const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2); + const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2, elementsMap); await waitFor(() => { // Check if width and height did not change @@ -853,7 +854,11 @@ describe("mutliple elements", () => { h.app.actionManager.executeAction(actionFlipVertical); const arrowText = h.elements[1] as ExcalidrawTextElementWithContainer; - const arrowTextPos = getBoundTextElementPosition(arrow.get(), arrowText)!; + const arrowTextPos = getBoundTextElementPosition( + arrow.get(), + arrowText, + arrayToMap(h.elements), + )!; const rectText = h.elements[3] as ExcalidrawTextElementWithContainer; expect(arrow.x).toBeCloseTo(180); diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 58579fe93..42685b866 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -32,6 +32,7 @@ import { import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; +import { arrayToMap } from "../../utils"; const { h } = window; @@ -286,9 +287,12 @@ const transform = ( let handleCoords: TransformHandle | undefined; if (elements.length === 1) { - handleCoords = getTransformHandles(elements[0], h.state.zoom, "mouse")[ - handle - ]; + handleCoords = getTransformHandles( + elements[0], + h.state.zoom, + arrayToMap(h.elements), + "mouse", + )[handle]; } else { const [x1, y1, x2, y2] = getCommonBounds(elements); const isFrameSelected = elements.some(isFrameLikeElement); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index ce0e1c856..6c01987c9 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -343,6 +343,8 @@ describe("Test Linear Elements", () => { }); it("should update all the midpoints when element position changed", async () => { + const elementsMap = arrayToMap(h.elements); + createThreePointerLinearElement("line", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); @@ -351,7 +353,10 @@ describe("Test Linear Elements", () => { expect(line.points.length).toEqual(3); enterLineEditingMode(line); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([line.x, line.y]).toEqual(points[0]); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -465,7 +470,11 @@ describe("Test Linear Elements", () => { }); it("should update only the first segment midpoint when its point is dragged", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -482,7 +491,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, @@ -499,7 +511,11 @@ describe("Test Linear Elements", () => { }); it("should hide midpoints in the segment when points moved close", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -516,7 +532,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, @@ -535,7 +554,10 @@ describe("Test Linear Elements", () => { it("should remove the midpoint when one of the points in the segment is deleted", async () => { const line = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(line); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + arrayToMap(h.elements), + ); // dragging line from last segment midpoint drag(lastSegmentMidpoint, [ @@ -637,7 +659,11 @@ describe("Test Linear Elements", () => { }); it("should update all the midpoints when its point is dragged", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -649,7 +675,10 @@ describe("Test Linear Elements", () => { // Drag from first point drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, @@ -678,7 +707,11 @@ describe("Test Linear Elements", () => { }); it("should hide midpoints in the segment when points moved close", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -695,7 +728,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, @@ -712,6 +748,8 @@ 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, @@ -723,7 +761,10 @@ describe("Test Linear Elements", () => { h.app.scene.getNonDeletedElementsMap(), h.state, ); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); // delete 3rd point deletePoint(points[2]); @@ -837,6 +878,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -859,6 +901,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -893,6 +936,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -1012,8 +1056,13 @@ describe("Test Linear Elements", () => { ); expect(container.width).toBe(70); expect(container.height).toBe(50); - expect(getBoundTextElementPosition(container, textElement)) - .toMatchInlineSnapshot(` + expect( + getBoundTextElementPosition( + container, + textElement, + arrayToMap(h.elements), + ), + ).toMatchInlineSnapshot(` { "x": 75, "y": 60, @@ -1051,8 +1100,13 @@ describe("Test Linear Elements", () => { } `); - expect(getBoundTextElementPosition(container, textElement)) - .toMatchInlineSnapshot(` + expect( + getBoundTextElementPosition( + container, + textElement, + arrayToMap(h.elements), + ), + ).toMatchInlineSnapshot(` { "x": 271.11716195150507, "y": 45, @@ -1090,7 +1144,8 @@ describe("Test Linear Elements", () => { arrow, ); expect(container.width).toBe(40); - expect(getBoundTextElementPosition(container, textElement)) + const elementsMap = arrayToMap(h.elements); + expect(getBoundTextElementPosition(container, textElement, elementsMap)) .toMatchInlineSnapshot(` { "x": 25, @@ -1102,7 +1157,10 @@ describe("Test Linear Elements", () => { collaboration made easy" `); - const points = LinearElementEditor.getPointsGlobalCoordinates(container); + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); // Drag from last point drag(points[1], [points[1][0] + 300, points[1][1]]); @@ -1115,7 +1173,7 @@ describe("Test Linear Elements", () => { } `); - expect(getBoundTextElementPosition(container, textElement)) + expect(getBoundTextElementPosition(container, textElement, elementsMap)) .toMatchInlineSnapshot(` { "x": 75, diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 22d828ee9..625175700 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -13,6 +13,7 @@ import { import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; import { vi } from "vitest"; +import { arrayToMap } from "../utils"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -75,12 +76,13 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const line = UI.createElement("line", { x: 110, y: 50, size: 80 }); - + const elementsMap = arrayToMap(h.elements); // bind line to two rectangles bindOrUnbindLinearElement( line.get() as NonDeleted, rectA.get() as ExcalidrawRectangleElement, rectB.get() as ExcalidrawRectangleElement, + elementsMap, ); // select the second rectangles diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 43e84d0ce..d4d1e7673 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -13,6 +13,7 @@ import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; +import { arrayToMap } from "../utils"; ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -301,10 +302,12 @@ describe("arrow element", () => { ], }); const label = await UI.editText(arrow, "Hello"); + const elementsMap = arrayToMap(h.elements); UI.resize(arrow, "se", [50, 30]); let labelPos = LinearElementEditor.getBoundTextElementPosition( arrow, label, + elementsMap, ); expect(labelPos.x + label.width / 2).toBeCloseTo( @@ -317,7 +320,11 @@ describe("arrow element", () => { expect(label.fontSize).toEqual(20); UI.resize(arrow, "w", [20, 0]); - labelPos = LinearElementEditor.getBoundTextElementPosition(arrow, label); + labelPos = LinearElementEditor.getBoundTextElementPosition( + arrow, + label, + elementsMap, + ); expect(labelPos.x + label.width / 2).toBeCloseTo( arrow.x + arrow.points[2][0], @@ -743,15 +750,17 @@ describe("multiple selection", () => { const selectionTop = 20 - topArrowLabel.height / 2; const move = [80, 0] as [number, number]; const scale = move[0] / selectionWidth + 1; - + const elementsMap = arrayToMap(h.elements); UI.resize([topArrow.get(), bottomArrow.get()], "se", move); const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( topArrow, topArrowLabel, + elementsMap, ); const bottomArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( bottomArrow, bottomArrowLabel, + elementsMap, ); expect(topArrow.x).toBeCloseTo(0); @@ -944,12 +953,13 @@ describe("multiple selection", () => { const scaleX = move[0] / selectionWidth + 1; const scaleY = -scaleX; const lineOrigBounds = getBoundsFromPoints(line); - + const elementsMap = arrayToMap(h.elements); UI.resize([line, image, rectangle, boundArrow], "se", move); const lineNewBounds = getBoundsFromPoints(line); const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition( boundArrow, arrowLabel, + elementsMap, ); expect(line.x).toBeCloseTo(60 * scaleX); From 9013c84524448202ace9038d006bcecb24cd16ff Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 19 Feb 2024 11:49:01 +0530 Subject: [PATCH 069/112] fix: make LinearElementEditor independent of scene (#7670) * fix: make LinearElementEditor independent of scene * more fixes * pass elements and elementsMap to maybeBindBindableElement,getHoveredElementForBinding,bindingBorderTest,getElligibleElementsForBindableElementAndWhere,isLinearElementEligibleForNewBindingByBindable * replace `ElementsMap` with `NonDeletedSceneElementsMap` & remove unused params * fix lint --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .../excalidraw/actions/actionClipboard.tsx | 2 +- .../actions/actionDeleteSelected.tsx | 5 +- .../actions/actionDuplicateSelection.tsx | 8 +- .../excalidraw/actions/actionFinalize.tsx | 6 +- packages/excalidraw/actions/actionFlip.ts | 13 ++- .../excalidraw/actions/actionLinearEditor.ts | 2 +- .../excalidraw/actions/actionSelectAll.ts | 2 +- packages/excalidraw/components/App.tsx | 80 +++++++------ packages/excalidraw/data/transform.ts | 11 +- packages/excalidraw/element/binding.ts | 110 +++++++++++------- .../excalidraw/element/linearElementEditor.ts | 49 +++++--- packages/excalidraw/renderer/renderScene.ts | 3 +- packages/excalidraw/tests/move.test.tsx | 4 +- 13 files changed, 172 insertions(+), 123 deletions(-) diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index b2457341d..b9634886b 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -107,7 +107,7 @@ export const actionCut = register({ trackEvent: { category: "element" }, perform: (elements, appState, event: ClipboardEvent | null, app) => { actionCopy.perform(elements, appState, event, app); - return actionDeleteSelected.perform(elements, appState); + return actionDeleteSelected.perform(elements, appState, null, app); }, contextItemLabel: "labels.cut", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index de25ed898..65f751d93 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -73,7 +73,7 @@ const handleGroupEditingState = ( export const actionDeleteSelected = register({ name: "deleteSelectedElements", trackEvent: { category: "element", action: "delete" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { if (appState.editingLinearElement) { const { elementId, @@ -81,7 +81,8 @@ export const actionDeleteSelected = register({ startBindingElement, endBindingElement, } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const elementsMap = app.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 7126f549e..86391f9e3 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -35,10 +35,14 @@ import { export const actionDuplicateSelection = register({ name: "duplicateSelection", trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints(appState); + const ret = LinearElementEditor.duplicateSelectedPoints( + appState, + elementsMap, + ); if (!ret) { return false; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 623876d58..9dad4ef91 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -26,12 +26,12 @@ export const actionFinalize = register({ _, { interactiveCanvas, focusContainer, scene }, ) => { - const elementsMap = arrayToMap(elements); + const elementsMap = scene.getNonDeletedElementsMap(); if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { if (isBindingElement(element)) { @@ -191,7 +191,7 @@ export const actionFinalize = register({ // To select the linear element when user has finished mutipoint editing selectedLinearElement: multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor(multiPointElement, scene) + ? new LinearElementEditor(multiPointElement) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 70fbe026d..ee4a6f0f5 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -4,7 +4,6 @@ import { getNonDeletedElements } from "../element"; import { ExcalidrawElement, NonDeleted, - NonDeletedElementsMap, NonDeletedSceneElementsMap, } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; @@ -68,7 +67,7 @@ export const actionFlipVertical = register({ const flipSelectedElements = ( elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elementsMap: NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", ) => { @@ -83,6 +82,7 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, + elements, elementsMap, appState, flipDirection, @@ -97,7 +97,8 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { @@ -113,9 +114,9 @@ const flipElements = ( flipDirection === "horizontal" ? minY : maxY, ); - (isBindingEnabled(appState) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(selectedElements, elementsMap); + isBindingEnabled(appState) + ? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap) + : unbindLinearElements(selectedElements, elementsMap); return selectedElements; }; diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 83611b027..5f1e672cb 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -24,7 +24,7 @@ export const actionToggleLinearEditor = register({ const editingLinearElement = appState.editingLinearElement?.elementId === selectedElement.id ? null - : new LinearElementEditor(selectedElement, app.scene); + : new LinearElementEditor(selectedElement); return { appState: { ...appState, diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 49f5072ce..398416f0c 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -43,7 +43,7 @@ export const actionSelectAll = register({ // single linear element selected Object.keys(selectedElementIds).length === 1 && isLinearElement(elements[0]) - ? new LinearElementEditor(elements[0], app.scene) + ? new LinearElementEditor(elements[0]) : null, }, commitToHistory: true, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b4410ab2b..1f21de9cf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2603,7 +2603,7 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); - const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const elementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -3860,7 +3860,6 @@ class App extends React.Component { this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, - this.scene, ), }); } @@ -4013,7 +4012,11 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements, elementsMap) + ? bindOrUnbindSelectedElements( + selectedElements, + this.scene.getNonDeletedElements(), + elementsMap, + ) : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } @@ -4578,10 +4581,7 @@ class App extends React.Component { ) { this.history.resumeRecording(); this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElements[0], - this.scene, - ), + editingLinearElement: new LinearElementEditor(selectedElements[0]), }); return; } else if ( @@ -5305,10 +5305,12 @@ class App extends React.Component { scenePointerX: number, scenePointerY: number, ) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); - const elementsMap = this.scene.getNonDeletedElementsMap(); const boundTextElement = getBoundTextElement(element, elementsMap); if (!element) { @@ -6122,7 +6124,8 @@ class App extends React.Component { this.history, pointerDownState.origin, linearElementEditor, - this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), + elementsMap, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6459,7 +6462,8 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.scene.addNewElement(element); this.setState({ @@ -6727,7 +6731,8 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.scene.addNewElement(element); @@ -6997,6 +7002,7 @@ class App extends React.Component { return true; } } + const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { const linearElementEditor = @@ -7007,6 +7013,7 @@ class App extends React.Component { this.state.selectedLinearElement, pointerCoords, this.state, + elementsMap, ) ) { const ret = LinearElementEditor.addMidpoint( @@ -7014,7 +7021,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], - this.scene.getNonDeletedElementsMap(), + elementsMap, ); if (!ret) { return; @@ -7435,10 +7442,7 @@ class App extends React.Component { selectedLinearElement: elementsWithinSelection.length === 1 && isLinearElement(elementsWithinSelection[0]) - ? new LinearElementEditor( - elementsWithinSelection[0], - this.scene, - ) + ? new LinearElementEditor(elementsWithinSelection[0]) : null, showHyperlinkPopup: elementsWithinSelection.length === 1 && @@ -7539,6 +7543,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + this.scene.getNonDeletedElements(), elementsMap, ); if (editingLinearElement !== this.state.editingLinearElement) { @@ -7563,6 +7568,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + this.scene.getNonDeletedElements(), elementsMap, ); @@ -7732,10 +7738,7 @@ class App extends React.Component { }, prevState, ), - selectedLinearElement: new LinearElementEditor( - draggingElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(draggingElement), })); } else { this.setState((prevState) => ({ @@ -7975,10 +7978,7 @@ class App extends React.Component { // the one we've hit if (selectedELements.length === 1) { this.setState({ - selectedLinearElement: new LinearElementEditor( - hitElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(hitElement), }); } } @@ -8091,10 +8091,7 @@ class App extends React.Component { selectedLinearElement: newSelectedElements.length === 1 && isLinearElement(newSelectedElements[0]) - ? new LinearElementEditor( - newSelectedElements[0], - this.scene, - ) + ? new LinearElementEditor(newSelectedElements[0]) : prevState.selectedLinearElement, }; }); @@ -8168,7 +8165,7 @@ class App extends React.Component { // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1. // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized prevState.selectedLinearElement?.elementId !== hitElement.id - ? new LinearElementEditor(hitElement, this.scene) + ? new LinearElementEditor(hitElement) : prevState.selectedLinearElement, })); } @@ -8232,12 +8229,16 @@ class App extends React.Component { } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { - (isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements - : unbindLinearElements)( - this.scene.getSelectedElements(this.state), - elementsMap, - ); + isBindingEnabled(this.state) + ? bindOrUnbindSelectedElements( + this.scene.getSelectedElements(this.state), + this.scene.getNonDeletedElements(), + elementsMap, + ) + : unbindLinearElements( + this.scene.getSelectedElements(this.state), + elementsMap, + ); } if (activeTool.type === "laser") { @@ -8714,7 +8715,8 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.setState({ suggestedBindings: @@ -8741,7 +8743,8 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); if ( hoveredBindableElement != null && @@ -8769,6 +8772,7 @@ class App extends React.Component { } const suggestedBindings = getEligibleElementsForBinding( selectedElements, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); this.setState({ suggestedBindings }); @@ -9037,7 +9041,7 @@ class App extends React.Component { this, ), selectedLinearElement: isLinearElement(element) - ? new LinearElementEditor(element, this.scene) + ? new LinearElementEditor(element) : null, } : this.state), diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 8d5b63a19..936272f07 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -39,11 +39,12 @@ import { ExcalidrawTextElement, FileId, FontFamilyValues, + NonDeletedSceneElementsMap, TextAlign, VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; -import { arrayToMap, assertNever, cloneJSON, getFontString } from "../utils"; +import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; @@ -231,7 +232,7 @@ const bindLinearElementToElement = ( start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { linearElement: ExcalidrawLinearElement; startBoundElement?: ExcalidrawElement; @@ -460,6 +461,10 @@ class ElementStore { return Array.from(this.excalidrawElements.values()); }; + getElementsMap = () => { + return toBrandedType(this.excalidrawElements); + }; + getElement = (id: string) => { return this.excalidrawElements.get(id); }; @@ -615,7 +620,7 @@ export const convertToExcalidrawElements = ( } } - const elementsMap = arrayToMap(elementStore.getElements()); + const elementsMap = elementStore.getElementsMap(); // Add labels and arrow bindings for (const [id, element] of elementsWithIds) { const excalidrawElement = elementStore.getElement(id)!; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index be766e33f..8d3959bc7 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -6,6 +6,7 @@ import { PointBinding, ExcalidrawElement, ElementsMap, + NonDeletedSceneElementsMap, } from "./types"; import { getElementAtPosition } from "../scene"; import { AppState } from "../types"; @@ -67,7 +68,7 @@ export const bindOrUnbindLinearElement = ( linearElement: NonDeleted, startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -115,7 +116,7 @@ const bindOrUnbindLinearElementEdge = ( boundToElementIds: Set, // Is mutated unboundFromElementIds: Set, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (bindableElement !== "keep") { if (bindableElement != null) { @@ -151,7 +152,8 @@ const bindOrUnbindLinearElementEdge = ( export const bindOrUnbindSelectedElements = ( selectedElements: NonDeleted[], - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): void => { selectedElements.forEach((selectedElement) => { if (isBindingElement(selectedElement)) { @@ -160,11 +162,13 @@ export const bindOrUnbindSelectedElements = ( getElligibleElementForBindingElement( selectedElement, "start", + elements, elementsMap, ), getElligibleElementForBindingElement( selectedElement, "end", + elements, elementsMap, ), elementsMap, @@ -177,16 +181,18 @@ export const bindOrUnbindSelectedElements = ( const maybeBindBindableElement = ( bindableElement: NonDeleted, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { - getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( - ([linearElement, where]) => - bindOrUnbindLinearElement( - linearElement, - where === "end" ? "keep" : bindableElement, - where === "start" ? "keep" : bindableElement, - elementsMap, - ), + getElligibleElementsForBindableElementAndWhere( + bindableElement, + elementsMap, + ).forEach(([linearElement, where]) => + bindOrUnbindLinearElement( + linearElement, + where === "end" ? "keep" : bindableElement, + where === "start" ? "keep" : bindableElement, + elementsMap, + ), ); }; @@ -195,7 +201,7 @@ export const maybeBindLinearElement = ( appState: AppState, scene: Scene, pointerCoords: { x: number; y: number }, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (appState.startBoundElement != null) { bindLinearElement( @@ -205,7 +211,11 @@ export const maybeBindLinearElement = ( elementsMap, ); } - const hoveredElement = getHoveredElementForBinding(pointerCoords, scene); + const hoveredElement = getHoveredElementForBinding( + pointerCoords, + scene.getNonDeletedElements(), + elementsMap, + ); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -222,7 +232,7 @@ export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { @@ -274,7 +284,7 @@ export const isLinearElementSimpleAndAlreadyBound = ( export const unbindLinearElements = ( elements: NonDeleted[], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { elements.forEach((element) => { if (isBindingElement(element)) { @@ -301,17 +311,14 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - scene: Scene, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - scene.getNonDeletedElements(), + elements, (element) => isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - scene.getNonDeletedElementsMap(), - ), + bindingBorderTest(element, pointerCoords, elementsMap), ); return hoveredElement as NonDeleted | null; }; @@ -320,7 +327,7 @@ const calculateFocusAndGap = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; @@ -539,33 +546,47 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( - elements: NonDeleted[], - elementsMap: ElementsMap, + selectedElements: NonDeleted[], + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): SuggestedBinding[] => { - const includedElementIds = new Set(elements.map(({ id }) => id)); - return elements.flatMap((element) => - isBindingElement(element, false) + const includedElementIds = new Set(selectedElements.map(({ id }) => id)); + return selectedElements.flatMap((selectedElement) => + isBindingElement(selectedElement, false) ? (getElligibleElementsForBindingElement( - element as NonDeleted, + selectedElement as NonDeleted, + elements, elementsMap, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) - : isBindableElement(element, false) - ? getElligibleElementsForBindableElementAndWhere(element).filter( - (binding) => !includedElementIds.has(binding[0].id), - ) + : isBindableElement(selectedElement, false) + ? getElligibleElementsForBindableElementAndWhere( + selectedElement, + elementsMap, + ).filter((binding) => !includedElementIds.has(binding[0].id)) : [], ); }; const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement(linearElement, "start", elementsMap), - getElligibleElementForBindingElement(linearElement, "end", elementsMap), + getElligibleElementForBindingElement( + linearElement, + "start", + elements, + elementsMap, + ), + getElligibleElementForBindingElement( + linearElement, + "end", + elements, + elementsMap, + ), ].filter( (element): element is NonDeleted => element != null, @@ -575,18 +596,20 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - Scene.getScene(linearElement)!, + elements, + elementsMap, ); }; const getLinearElementEdgeCoors = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { x: number; y: number } => { const index = startOrEnd === "start" ? 0 : -1; return tupleToCoors( @@ -600,6 +623,7 @@ const getLinearElementEdgeCoors = ( const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, + elementsMap: NonDeletedSceneElementsMap, ): SuggestedPointBinding[] => { const scene = Scene.getScene(bindableElement)!; return scene @@ -612,13 +636,13 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, - scene.getNonDeletedElementsMap(), + elementsMap, ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, - scene.getNonDeletedElementsMap(), + elementsMap, ); if (!canBindStart && !canBindEnd) { return null; @@ -636,7 +660,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( linearElement: NonDeleted, startOrEnd: "start" | "end", bindableElement: NonDeleted, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 85483b3d7..d493f1fbd 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -6,6 +6,8 @@ import { ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "./types"; import { distance2d, @@ -36,7 +38,6 @@ import { import { mutateElement } from "./mutateElement"; import History from "../history"; -import Scene from "../scene/Scene"; import { bindOrUnbindLinearElement, getHoveredElementForBinding, @@ -86,11 +87,10 @@ export class LinearElementEditor { public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: Point | null; - constructor(element: NonDeleted, scene: Scene) { + constructor(element: NonDeleted) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - Scene.mapElementToScene(this.elementId, scene); LinearElementEditor.normalizePoints(element); this.selectedPointsIndices = null; @@ -123,8 +123,11 @@ export class LinearElementEditor { * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) */ - static getElement(id: InstanceType["elementId"]) { - const element = Scene.getScene(id)?.getNonDeletedElement(id); + static getElement( + id: InstanceType["elementId"], + elementsMap: ElementsMap, + ) { + const element = elementsMap.get(id); if (element) { return element as NonDeleted; } @@ -135,7 +138,7 @@ export class LinearElementEditor { event: PointerEvent, appState: AppState, setState: React.Component["setState"], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ) { if ( !appState.editingLinearElement || @@ -146,7 +149,7 @@ export class LinearElementEditor { const { editingLinearElement } = appState; const { selectedPointsIndices, elementId } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -197,13 +200,13 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): boolean { if (!linearElementEditor) { return false; } const { selectedPointsIndices, elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -331,11 +334,12 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, - elementsMap: ElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): LinearElementEditor { const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return editingLinearElement; } @@ -376,7 +380,8 @@ export class LinearElementEditor { elementsMap, ), ), - Scene.getScene(element)!, + elements, + elementsMap, ) : null; @@ -490,7 +495,7 @@ export class LinearElementEditor { elementsMap: ElementsMap, ) => { const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return null; } @@ -614,6 +619,7 @@ export class LinearElementEditor { ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return -1; @@ -639,7 +645,8 @@ export class LinearElementEditor { history: History, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, - elementsMap: ElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): { didAddPoint: boolean; hitElement: NonDeleted | null; @@ -656,7 +663,7 @@ export class LinearElementEditor { } const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return ret; @@ -709,7 +716,8 @@ export class LinearElementEditor { lastUncommittedPoint: null, endBindingElement: getHoveredElementForBinding( scenePointer, - Scene.getScene(element)!, + elements, + elementsMap, ), }; @@ -813,7 +821,7 @@ export class LinearElementEditor { return null; } const { elementId, lastUncommittedPoint } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return appState.editingLinearElement; } @@ -1020,14 +1028,14 @@ export class LinearElementEditor { mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); } - static duplicateSelectedPoints(appState: AppState) { + static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) { if (!appState.editingLinearElement) { return false; } const { selectedPointsIndices, elementId } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element || selectedPointsIndices === null) { return false; @@ -1189,9 +1197,11 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, pointerCoords: PointerCoords, appState: AppState, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { @@ -1234,6 +1244,7 @@ export class LinearElementEditor { ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index d80540fd0..0e9fce164 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -354,7 +354,8 @@ const renderLinearElementPointHighlight = ( ) { return; } - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { return; } diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 625175700..06086f119 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOM from "react-dom"; import { render, fireEvent } from "./test-utils"; import { Excalidraw } from "../index"; @@ -13,7 +12,6 @@ import { import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; import { vi } from "vitest"; -import { arrayToMap } from "../utils"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -76,7 +74,7 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const line = UI.createElement("line", { x: 110, y: 50, size: 80 }); - const elementsMap = arrayToMap(h.elements); + const elementsMap = h.app.scene.getNonDeletedElementsMap(); // bind line to two rectangles bindOrUnbindLinearElement( line.get() as NonDeleted, From 79d9dc2f8f86b38d1784519eb765d1a13416fdab Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 19 Feb 2024 19:39:14 +0530 Subject: [PATCH 070/112] fix: make bounds independent of scene (#7679) * fix: make bounds independent of scene * pass only elements to getCommonBounds * lint * pass elementsMap to getVisibleAndNonSelectedElements --- packages/excalidraw/components/App.tsx | 2 + packages/excalidraw/element/bounds.test.ts | 45 +++++++++++++----- packages/excalidraw/element/bounds.ts | 50 +++++++++----------- packages/excalidraw/element/sizeHelpers.ts | 5 +- packages/excalidraw/renderer/renderScene.ts | 2 +- packages/excalidraw/scene/Renderer.ts | 20 +++++--- packages/excalidraw/scene/scrollbars.ts | 7 +-- packages/excalidraw/scene/selection.ts | 13 +++-- packages/excalidraw/snapping.ts | 5 ++ packages/excalidraw/tests/clipboard.test.tsx | 13 +++-- packages/utils/withinBounds.ts | 3 +- 11 files changed, 106 insertions(+), 59 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1f21de9cf..9f585d2e1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -953,6 +953,7 @@ class App extends React.Component { normalizedWidth, normalizedHeight, this.state, + this.scene.getNonDeletedElementsMap(), ); const hasBeenInitialized = this.initializedEmbeds.has(el.id); @@ -1287,6 +1288,7 @@ class App extends React.Component { scrollY: this.state.scrollY, zoom: this.state.zoom, }, + this.scene.getNonDeletedElementsMap(), ) ) { // if frame not visible, don't render its name diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 253137b07..e495343f7 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -62,9 +62,15 @@ describe("getElementAbsoluteCoords", () => { describe("getElementBounds", () => { it("rectangle", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "rectangle" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "rectangle", + }); + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(39.39339828220179); expect(y1).toEqual(24.393398282201787); expect(x2).toEqual(60.60660171779821); @@ -72,9 +78,17 @@ describe("getElementBounds", () => { }); it("diamond", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "diamond" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "diamond", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(42.928932188134524); expect(y1).toEqual(27.928932188134524); expect(x2).toEqual(57.071067811865476); @@ -82,9 +96,16 @@ describe("getElementBounds", () => { }); it("ellipse", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "ellipse" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "ellipse", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(42.09430584957905); expect(y1).toEqual(27.09430584957905); expect(x2).toEqual(57.90569415042095); @@ -92,7 +113,7 @@ describe("getElementBounds", () => { }); it("curved line", () => { - const [x1, y1, x2, y2] = getElementBounds({ + const element = { ..._ce({ t: "line", x: 449.58203125, @@ -106,7 +127,9 @@ describe("getElementBounds", () => { [67.33984375, 92.48828125] as [number, number], [-102.7890625, 52.15625] as [number, number], ], - } as ExcalidrawLinearElement); + } as ExcalidrawLinearElement; + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(360.3176068760539); expect(y1).toEqual(185.90654264413516); expect(x2).toEqual(480.87005902729743); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 7eb7fa48a..e7c6f7fb3 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -5,7 +5,6 @@ import { ExcalidrawFreeDrawElement, NonDeleted, ExcalidrawTextElementWithContainer, - ElementsMapOrArray, ElementsMap, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; @@ -25,7 +24,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; -import Scene from "../scene/Scene"; +import { arrayToMap } from "../utils"; export type RectangleBox = { x: number; @@ -63,7 +62,7 @@ export class ElementBounds { } >(); - static getBounds(element: ExcalidrawElement) { + static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { const cachedBounds = ElementBounds.boundsCache.get(element); if ( @@ -75,23 +74,12 @@ export class ElementBounds { ) { return cachedBounds.bounds; } - const scene = Scene.getScene(element); - const bounds = ElementBounds.calculateBounds( - element, - scene?.getNonDeletedElementsMap() || new Map(), - ); + const bounds = ElementBounds.calculateBounds(element, elementsMap); - // hack to ensure that downstream checks could retrieve element Scene - // so as to have correctly calculated bounds - // FIXME remove when we get rid of all the id:Scene / element:Scene mapping - const shouldCache = !!scene; - - if (shouldCache) { - ElementBounds.boundsCache.set(element, { - version: element.version, - bounds, - }); - } + ElementBounds.boundsCache.set(element, { + version: element.version, + bounds, + }); return bounds; } @@ -748,11 +736,17 @@ const getLinearElementRotatedBounds = ( return coords; }; -export const getElementBounds = (element: ExcalidrawElement): Bounds => { - return ElementBounds.getBounds(element); +export const getElementBounds = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): Bounds => { + return ElementBounds.getBounds(element, elementsMap); }; -export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { - if ("size" in elements ? !elements.size : !elements.length) { + +export const getCommonBounds = ( + elements: readonly ExcalidrawElement[], +): Bounds => { + if (!elements.length) { return [0, 0, 0, 0]; } @@ -761,8 +755,10 @@ export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { let minY = Infinity; let maxY = -Infinity; + const elementsMap = arrayToMap(elements); + elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); minX = Math.min(minX, x1); minY = Math.min(minY, y1); maxX = Math.max(maxX, x2); @@ -868,9 +864,9 @@ export const getClosestElementBounds = ( let minDistance = Infinity; let closestElement = elements[0]; - + const elementsMap = arrayToMap(elements); elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); if (distance < minDistance) { @@ -879,7 +875,7 @@ export const getClosestElementBounds = ( } }); - return getElementBounds(closestElement); + return getElementBounds(closestElement, elementsMap); }; export interface BoundingBox { diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index 1b69ca0bc..e30ea9877 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./types"; +import { ElementsMap, ExcalidrawElement } from "./types"; import { mutateElement } from "./mutateElement"; import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { SHIFT_LOCKING_ANGLE } from "../constants"; @@ -26,8 +26,9 @@ export const isElementInViewport = ( scrollX: number; scrollY: number; }, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates const topLeftSceneCoords = viewportCoordsToSceneCoords( { clientX: viewTransformations.offsetLeft, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 0e9fce164..62c59b6f8 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -886,7 +886,7 @@ const _renderInteractiveScene = ({ let scrollBars; if (renderConfig.renderScrollbars) { scrollBars = getScrollBars( - elementsMap, + visibleElements, normalizedWidth, normalizedHeight, appState, diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 1593d6d2e..0875b9f05 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -40,13 +40,19 @@ export class Renderer { const visibleElements: NonDeletedExcalidrawElement[] = []; for (const element of elementsMap.values()) { if ( - isElementInViewport(element, width, height, { - zoom, - offsetLeft, - offsetTop, - scrollX, - scrollY, - }) + isElementInViewport( + element, + width, + height, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }, + elementsMap, + ) ) { visibleElements.push(element); } diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts index 14009588b..0e0e6d0ab 100644 --- a/packages/excalidraw/scene/scrollbars.ts +++ b/packages/excalidraw/scene/scrollbars.ts @@ -1,20 +1,21 @@ import { getCommonBounds } from "../element"; import { InteractiveCanvasAppState } from "../types"; -import { RenderableElementsMap, ScrollBars } from "./types"; +import { ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; +import { ExcalidrawElement } from "../element/types"; export const SCROLLBAR_MARGIN = 4; export const SCROLLBAR_WIDTH = 6; export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; export const getScrollBars = ( - elements: RenderableElementsMap, + elements: readonly ExcalidrawElement[], viewportWidth: number, viewportHeight: number, appState: InteractiveCanvasAppState, ): ScrollBars => { - if (!elements.size) { + if (!elements.length) { return { horizontal: null, vertical: null, diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 27d4db1c9..3c3df898e 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -52,12 +52,17 @@ export const getElementsWithinSelection = ( getElementAbsoluteCoords(selection, elementsMap); let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = - getElementBounds(element); + let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( + element, + elementsMap, + ); const containingFrame = getContainingFrame(element); if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame); + const [fx1, fy1, fx2, fy2] = getElementBounds( + containingFrame, + elementsMap, + ); elementX1 = Math.max(fx1, elementX1); elementY1 = Math.max(fy1, elementY1); @@ -97,6 +102,7 @@ export const getVisibleAndNonSelectedElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedElementsSet = new Set( selectedElements.map((element) => element.id), @@ -107,6 +113,7 @@ export const getVisibleAndNonSelectedElements = ( appState.width, appState.height, appState, + elementsMap, ); return !selectedElementsSet.has(element.id) && isVisible; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 3061c02d4..bc83d0057 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -269,6 +269,7 @@ const getReferenceElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedFrames = selectedElements .filter((element) => isFrameLikeElement(element)) @@ -278,6 +279,7 @@ const getReferenceElements = ( elements, selectedElements, appState, + elementsMap, ).filter( (element) => !(element.frameId && selectedFrames.includes(element.frameId)), ); @@ -293,6 +295,7 @@ export const getVisibleGaps = ( elements, selectedElements, appState, + elementsMap, ); const referenceBounds = getMaximumGroups(referenceElements, elementsMap) @@ -580,6 +583,7 @@ export const getReferenceSnapPoints = ( elements, selectedElements, appState, + elementsMap, ); return getMaximumGroups(referenceElements, elementsMap) .filter( @@ -1296,6 +1300,7 @@ export const getSnapLinesAtPointer = ( elements, [], appState, + elementsMap, ); const snapDistance = getSnapDistance(appState.zoom.value); diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 149ebcd1e..1a6fe47be 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -12,6 +12,7 @@ import { getElementBounds } from "../element"; import { NormalizedZoomValue } from "../types"; import { API } from "./helpers/api"; import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; +import { arrayToMap } from "../utils"; const { h } = window; @@ -138,6 +139,8 @@ describe("paste text as single lines", () => { }); it("should space items correctly", async () => { + const elementsMap = arrayToMap(h.elements); + const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; const lineHeightPx = getLineHeightInPx( @@ -149,16 +152,17 @@ describe("paste text as single lines", () => { pasteWithCtrlCmdV(text); await waitFor(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, firstElY] = getElementBounds(h.elements[0]); + const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap); for (let i = 1; i < h.elements.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, elY] = getElementBounds(h.elements[i]); + const [fx, elY] = getElementBounds(h.elements[i], elementsMap); expect(elY).toEqual(firstElY + lineHeightPx * i); } }); }); it("should leave a space for blank new lines", async () => { + const elementsMap = arrayToMap(h.elements); const text = "hkhkjhki\n\njgkjhffjh"; const lineHeightPx = getLineHeightInPx( @@ -168,11 +172,12 @@ describe("paste text as single lines", () => { 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); pasteWithCtrlCmdV(text); + await waitFor(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, firstElY] = getElementBounds(h.elements[0]); + const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [lx, lastElY] = getElementBounds(h.elements[1]); + const [lx, lastElY] = getElementBounds(h.elements[1], elementsMap); expect(lastElY).toEqual(firstElY + lineHeightPx * 2); }); }); diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts index 637cab3e1..6a380d9c3 100644 --- a/packages/utils/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -14,6 +14,7 @@ import { import { isValueInRange, rotatePoint } from "../excalidraw/math"; import type { Point } from "../excalidraw/types"; import { Bounds, getElementBounds } from "../excalidraw/element/bounds"; +import { arrayToMap } from "../excalidraw/utils"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; @@ -158,7 +159,7 @@ export const elementsOverlappingBBox = ({ type: "overlap" | "contain" | "inside"; }) => { if (isExcalidrawElement(bounds)) { - bounds = getElementBounds(bounds); + bounds = getElementBounds(bounds, arrayToMap(elements)); } const adjustedBBox: Bounds = [ bounds[0] - errorMargin, From 2e719ff6712750de50a83b873897ea3557283605 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 20 Feb 2024 20:59:01 +0530 Subject: [PATCH 071/112] fix: decouple pure functions from hyperlink to prevent mermaid bundling (#7710) * move hyperlink code into its folder * move pure js functions to hyperlink/helpers and move actionLink to actions * fix tests * fix --- packages/excalidraw/actions/actionLink.tsx | 54 ++++++ packages/excalidraw/actions/index.ts | 2 +- packages/excalidraw/components/App.tsx | 38 ++-- .../hyperlink}/Hyperlink.scss | 2 +- .../hyperlink}/Hyperlink.tsx | 175 ++---------------- .../components/hyperlink/helpers.ts | 93 ++++++++++ packages/excalidraw/renderer/renderScene.ts | 2 +- packages/excalidraw/tests/helpers/api.ts | 3 + packages/excalidraw/tests/helpers/ui.ts | 5 +- 9 files changed, 198 insertions(+), 176 deletions(-) create mode 100644 packages/excalidraw/actions/actionLink.tsx rename packages/excalidraw/{element => components/hyperlink}/Hyperlink.scss (96%) rename packages/excalidraw/{element => components/hyperlink}/Hyperlink.tsx (70%) create mode 100644 packages/excalidraw/components/hyperlink/helpers.ts diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 000000000..f7710874e --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -0,0 +1,54 @@ +import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; +import { LinkIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { isEmbeddableElement } from "../element/typeChecks"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const actionLink = register({ + name: "hyperlink", + perform: (elements, appState) => { + if (appState.showHyperlinkPopup === "editor") { + return false; + } + + return { + elements, + appState: { + ...appState, + showHyperlinkPopup: "editor", + openMenu: null, + }, + commitToHistory: true, + }; + }, + trackEvent: { category: "hyperlink", action: "click" }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, + contextItemLabel: (elements, appState) => + getContextMenuLabel(elements, appState), + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return selectedElements.length === 1; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index b4551acf5..092060425 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; -export { actionLink } from "../element/Hyperlink"; +export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9f585d2e1..7b310ca38 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -326,9 +326,7 @@ import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, - isPointHittingLink, - isPointHittingLinkIcon, -} from "../element/Hyperlink"; +} from "../components/hyperlink/Hyperlink"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; @@ -410,6 +408,10 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; +import { + isPointHittingLink, + isPointHittingLinkIcon, +} from "./hyperlink/helpers"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -9571,7 +9573,6 @@ class App extends React.Component { // ----------------------------------------------------------------------------- // TEST HOOKS // ----------------------------------------------------------------------------- - declare global { interface Window { h: { @@ -9584,20 +9585,23 @@ declare global { } } -if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - window.h = window.h || ({} as Window["h"]); +export const createTestHook = () => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + window.h = window.h || ({} as Window["h"]); - Object.defineProperties(window.h, { - elements: { - configurable: true, - get() { - return this.app?.scene.getElementsIncludingDeleted(); + Object.defineProperties(window.h, { + elements: { + configurable: true, + get() { + return this.app?.scene.getElementsIncludingDeleted(); + }, + set(elements: ExcalidrawElement[]) { + return this.app?.scene.replaceAllElements(elements); + }, }, - set(elements: ExcalidrawElement[]) { - return this.app?.scene.replaceAllElements(elements); - }, - }, - }); -} + }); + } +}; +createTestHook(); export default App; diff --git a/packages/excalidraw/element/Hyperlink.scss b/packages/excalidraw/components/hyperlink/Hyperlink.scss similarity index 96% rename from packages/excalidraw/element/Hyperlink.scss rename to packages/excalidraw/components/hyperlink/Hyperlink.scss index ba7e86373..6a5db325a 100644 --- a/packages/excalidraw/element/Hyperlink.scss +++ b/packages/excalidraw/components/hyperlink/Hyperlink.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@import "../../css/variables.module.scss"; .excalidraw-hyperlinkContainer { display: flex; diff --git a/packages/excalidraw/element/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx similarity index 70% rename from packages/excalidraw/element/Hyperlink.tsx rename to packages/excalidraw/components/hyperlink/Hyperlink.tsx index 29b76d31d..c87ff773c 100644 --- a/packages/excalidraw/element/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -1,22 +1,20 @@ -import { AppState, ExcalidrawProps, Point, UIAppState } from "../types"; +import { AppState, ExcalidrawProps, Point } from "../../types"; import { - getShortcutKey, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, wrapEvent, -} from "../utils"; -import { getEmbedLink, embeddableURLValidator } from "./embeddable"; -import { mutateElement } from "./mutateElement"; +} from "../../utils"; +import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable"; +import { mutateElement } from "../../element/mutateElement"; import { ElementsMap, ExcalidrawEmbeddableElement, NonDeletedExcalidrawElement, -} from "./types"; +} from "../../element/types"; -import { register } from "../actions/register"; -import { ToolButton } from "../components/ToolButton"; -import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons"; -import { t } from "../i18n"; +import { ToolButton } from "../ToolButton"; +import { FreedrawIcon, TrashIcon } from "../icons"; +import { t } from "../../i18n"; import { useCallback, useEffect, @@ -25,21 +23,19 @@ import { useState, } from "react"; import clsx from "clsx"; -import { KEYS } from "../keys"; -import { DEFAULT_LINK_SIZE } from "../renderer/renderElement"; -import { rotate } from "../math"; -import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants"; -import { Bounds } from "./bounds"; -import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip"; -import { getSelectedElements } from "../scene"; -import { isPointHittingElementBoundingBox } from "./collision"; -import { getElementAbsoluteCoords } from "."; -import { isLocalLink, normalizeLink } from "../data/url"; +import { KEYS } from "../../keys"; +import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants"; +import { getElementAbsoluteCoords } from "../../element/bounds"; +import { getTooltipDiv, updateTooltipPosition } from "../Tooltip"; +import { getSelectedElements } from "../../scene"; +import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { isLocalLink, normalizeLink } from "../../data/url"; import "./Hyperlink.scss"; -import { trackEvent } from "../analytics"; -import { useAppProps, useExcalidrawAppState } from "../components/App"; -import { isEmbeddableElement } from "./typeChecks"; +import { trackEvent } from "../../analytics"; +import { useAppProps, useExcalidrawAppState } from "../App"; +import { isEmbeddableElement } from "../../element/typeChecks"; +import { getLinkHandleFromCoords } from "./helpers"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -47,11 +43,6 @@ const CONTAINER_PADDING = 5; const CONTAINER_HEIGHT = 42; const AUTO_HIDE_TIMEOUT = 500; -export const EXTERNAL_LINK_IMG = document.createElement("img"); -EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( - ``, -)}`; - let IS_HYPERLINK_TOOLTIP_VISIBLE = false; const embeddableLinkCache = new Map< @@ -339,51 +330,6 @@ const getCoordsForPopover = ( return { x, y }; }; -export const actionLink = register({ - name: "hyperlink", - perform: (elements, appState) => { - if (appState.showHyperlinkPopup === "editor") { - return false; - } - - return { - elements, - appState: { - ...appState, - showHyperlinkPopup: "editor", - openMenu: null, - }, - commitToHistory: true, - }; - }, - trackEvent: { category: "hyperlink", action: "click" }, - keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, - contextItemLabel: (elements, appState) => - getContextMenuLabel(elements, appState), - predicate: (elements, appState) => { - const selectedElements = getSelectedElements(elements, appState); - return selectedElements.length === 1; - }, - PanelComponent: ({ elements, appState, updateData }) => { - const selectedElements = getSelectedElements(elements, appState); - - return ( - updateData(null)} - selected={selectedElements.length === 1 && !!selectedElements[0].link} - /> - ); - }, -}); - export const getContextMenuLabel = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -399,87 +345,6 @@ export const getContextMenuLabel = ( return label; }; -export const getLinkHandleFromCoords = ( - [x1, y1, x2, y2]: Bounds, - angle: number, - appState: Pick, -): Bounds => { - const size = DEFAULT_LINK_SIZE; - const linkWidth = size / appState.zoom.value; - const linkHeight = size / appState.zoom.value; - const linkMarginY = size / appState.zoom.value; - const centerX = (x1 + x2) / 2; - const centerY = (y1 + y2) / 2; - const centeringOffset = (size - 8) / (2 * appState.zoom.value); - const dashedLineMargin = 4 / appState.zoom.value; - - // Same as `ne` resize handle - const x = x2 + dashedLineMargin - centeringOffset; - const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; - - const [rotatedX, rotatedY] = rotate( - x + linkWidth / 2, - y + linkHeight / 2, - centerX, - centerY, - angle, - ); - return [ - rotatedX - linkWidth / 2, - rotatedY - linkHeight / 2, - linkWidth, - linkHeight, - ]; -}; - -export const isPointHittingLinkIcon = ( - element: NonDeletedExcalidrawElement, - elementsMap: ElementsMap, - appState: AppState, - [x, y]: Point, -) => { - const threshold = 4 / appState.zoom.value; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const hitLink = - x > linkX - threshold && - x < linkX + threshold + linkWidth && - y > linkY - threshold && - y < linkY + linkHeight + threshold; - return hitLink; -}; - -export const isPointHittingLink = ( - element: NonDeletedExcalidrawElement, - elementsMap: ElementsMap, - appState: AppState, - [x, y]: Point, - isMobile: boolean, -) => { - if (!element.link || appState.selectedElementIds[element.id]) { - return false; - } - const threshold = 4 / appState.zoom.value; - if ( - !isMobile && - appState.viewModeEnabled && - isPointHittingElementBoundingBox( - element, - elementsMap, - [x, y], - threshold, - null, - ) - ) { - return true; - } - return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); -}; - let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null; export const showHyperlinkTooltip = ( element: NonDeletedExcalidrawElement, @@ -547,7 +412,7 @@ export const hideHyperlinkToolip = () => { } }; -export const shouldHideLinkPopup = ( +const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts new file mode 100644 index 000000000..9b7da3d76 --- /dev/null +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -0,0 +1,93 @@ +import { MIME_TYPES } from "../../constants"; +import { Bounds, getElementAbsoluteCoords } from "../../element/bounds"; +import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types"; +import { rotate } from "../../math"; +import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; +import { AppState, Point, UIAppState } from "../../types"; + +export const EXTERNAL_LINK_IMG = document.createElement("img"); +EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( + ``, +)}`; + +export const getLinkHandleFromCoords = ( + [x1, y1, x2, y2]: Bounds, + angle: number, + appState: Pick, +): Bounds => { + const size = DEFAULT_LINK_SIZE; + const linkWidth = size / appState.zoom.value; + const linkHeight = size / appState.zoom.value; + const linkMarginY = size / appState.zoom.value; + const centerX = (x1 + x2) / 2; + const centerY = (y1 + y2) / 2; + const centeringOffset = (size - 8) / (2 * appState.zoom.value); + const dashedLineMargin = 4 / appState.zoom.value; + + // Same as `ne` resize handle + const x = x2 + dashedLineMargin - centeringOffset; + const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; + + const [rotatedX, rotatedY] = rotate( + x + linkWidth / 2, + y + linkHeight / 2, + centerX, + centerY, + angle, + ); + return [ + rotatedX - linkWidth / 2, + rotatedY - linkHeight / 2, + linkWidth, + linkHeight, + ]; +}; + +export const isPointHittingLinkIcon = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + appState: AppState, + [x, y]: Point, +) => { + const threshold = 4 / appState.zoom.value; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const hitLink = + x > linkX - threshold && + x < linkX + threshold + linkWidth && + y > linkY - threshold && + y < linkY + linkHeight + threshold; + return hitLink; +}; + +export const isPointHittingLink = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + appState: AppState, + [x, y]: Point, + isMobile: boolean, +) => { + if (!element.link || appState.selectedElementIds[element.id]) { + return false; + } + const threshold = 4 / appState.zoom.value; + if ( + !isMobile && + appState.viewModeEnabled && + isPointHittingElementBoundingBox( + element, + elementsMap, + [x, y], + threshold, + null, + ) + ) { + return true; + } + return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 62c59b6f8..69926b72d 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -73,7 +73,7 @@ import { import { EXTERNAL_LINK_IMG, getLinkHandleFromCoords, -} from "../element/Hyperlink"; +} from "../components/hyperlink/helpers"; import { renderSnaps } from "./renderSnaps"; import { isEmbeddableElement, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index d22d3f221..503ebfc01 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -31,8 +31,11 @@ import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; import { Mutable } from "../../utility-types"; import { assertNever } from "../../utils"; +import { createTestHook } from "../../components/App"; const readFile = util.promisify(fs.readFile); +// so that window.h is available when App.tsx is not imported as well. +createTestHook(); const { h } = window; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 42685b866..c03b889df 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -33,6 +33,10 @@ import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; import { arrayToMap } from "../../utils"; +import { createTestHook } from "../../components/App"; + +// so that window.h is available when App.tsx is not imported as well. +createTestHook(); const { h } = window; @@ -460,7 +464,6 @@ export class UI { mouse.reset(); mouse.up(x + width, y + height); } - const origElement = h.elements[h.elements.length - 1] as any; if (angle !== 0) { From 361a9449bb53beaf8a36a0c15338b648aff1cf22 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Feb 2024 16:34:20 +0530 Subject: [PATCH 072/112] fix: remove scene hack from export.ts & remove pass elementsMap to getContainingFrame (#7713) * fix: remove scene hack from export.ts as its not needed anymore * remove * pass elementsMap to getContainingFrame * remove unused `mapElementIds` param --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/clipboard.ts | 7 ++-- packages/excalidraw/components/App.tsx | 6 +-- packages/excalidraw/frame.ts | 26 ++++-------- packages/excalidraw/renderer/renderElement.ts | 20 ++++++--- packages/excalidraw/scene/Scene.ts | 25 +++-------- packages/excalidraw/scene/export.ts | 42 +------------------ packages/excalidraw/scene/selection.ts | 4 +- 7 files changed, 38 insertions(+), 92 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index a88402d69..c6b6082ff 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -16,7 +16,7 @@ import { import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; -import { isMemberOf, isPromiseLike } from "./utils"; +import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; import { t } from "./i18n"; type ElementsClipboard = { @@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({ elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; }) => { + const elementsMap = arrayToMap(elements); const framesToCopy = new Set( elements.filter((element) => isFrameLikeElement(element)), ); @@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({ type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: elements.map((element) => { if ( - getContainingFrame(element) && - !framesToCopy.has(getContainingFrame(element)!) + getContainingFrame(element, elementsMap) && + !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); mutateElement(copiedElement, { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7b310ca38..c9985c88d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1131,7 +1131,7 @@ class App extends React.Component { display: isVisible ? "block" : "none", opacity: getRenderOpacity( el, - getContainingFrame(el), + getContainingFrame(el, this.scene.getNonDeletedElementsMap()), this.elementsPendingErasure, ), ["--embeddable-radius" as string]: `${getCornerRadius( @@ -4399,7 +4399,7 @@ class App extends React.Component { ), ).filter((element) => { // hitting a frame's element from outside the frame is not considered a hit - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); return containingFrame && this.state.frameRendering.enabled && this.state.frameRendering.clip @@ -7789,7 +7789,7 @@ class App extends React.Component { ); if (linearElement?.frameId) { - const frame = getContainingFrame(linearElement); + const frame = getContainingFrame(linearElement, elementsMap); if (frame && linearElement) { if ( diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 8f550e86a..cc80531ee 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -21,7 +21,7 @@ import { mutateElement } from "./element/mutateElement"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; -import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; +import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; import { doLineSegmentsIntersect, @@ -377,25 +377,13 @@ export const getElementsInNewFrame = ( export const getContainingFrame = ( element: ExcalidrawElement, - /** - * Optionally an elements map, in case the elements aren't in the Scene yet. - * Takes precedence over Scene elements, even if the element exists - * in Scene elements and not the supplied elements map. - */ - elementsMap?: Map, + elementsMap: ElementsMap, ) => { - if (element.frameId) { - if (elementsMap) { - return (elementsMap.get(element.frameId) || - null) as null | ExcalidrawFrameLikeElement; - } - return ( - (Scene.getScene(element)?.getElement( - element.frameId, - ) as ExcalidrawFrameLikeElement) || null - ); + if (!element.frameId) { + return null; } - return null; + return (elementsMap.get(element.frameId) || + null) as null | ExcalidrawFrameLikeElement; }; // --------------------------- Frame Operations ------------------------------- @@ -697,7 +685,7 @@ export const getTargetFrame = ( return appState.selectedElementIds[_element.id] && appState.selectedElementsAreBeingDragged ? appState.frameToHighlight - : getContainingFrame(_element); + : getContainingFrame(_element, elementsMap); }; // TODO: this a huge bottleneck for large scenes, optimise diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a0b8228c9..637a9fe1e 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -257,7 +257,8 @@ const generateElementCanvas = ( canvasOffsetY, boundTextElementVersion: getBoundTextElement(element, elementsMap)?.version || null, - containingFrameOpacity: getContainingFrame(element)?.opacity || 100, + containingFrameOpacity: + getContainingFrame(element, elementsMap)?.opacity || 100, }; }; @@ -440,7 +441,8 @@ const generateElementWithCanvas = ( const boundTextElementVersion = getBoundTextElement(element, elementsMap)?.version || null; - const containingFrameOpacity = getContainingFrame(element)?.opacity || 100; + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; if ( !prevElementWithCanvas || @@ -652,7 +654,7 @@ export const renderElement = ( ) => { context.globalAlpha = getRenderOpacity( element, - getContainingFrame(element), + getContainingFrame(element, elementsMap), renderConfig.elementsPendingErasure, ); @@ -924,11 +926,12 @@ const maybeWrapNodesInFrameClipPath = ( root: SVGElement, nodes: SVGElement[], frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, ) => { if (!frameRendering.enabled || !frameRendering.clip) { return null; } - const frame = getContainingFrame(element); + const frame = getContainingFrame(element, elementsMap); if (frame) { const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); @@ -990,7 +993,9 @@ export const renderElementToSvg = ( }; const opacity = - ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; switch (element.type) { case "selection": { @@ -1024,6 +1029,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); @@ -1215,6 +1221,7 @@ export const renderElementToSvg = ( root, [group, maskPath], renderConfig.frameRendering, + elementsMap, ); if (g) { addToRoot(g, element); @@ -1258,6 +1265,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); @@ -1355,6 +1363,7 @@ export const renderElementToSvg = ( root, [g], renderConfig.frameRendering, + elementsMap, ); addToRoot(clipG || g, element); } @@ -1442,6 +1451,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 88c3d8996..c76b81a82 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -80,29 +80,16 @@ class Scene { private static sceneMapByElement = new WeakMap(); private static sceneMapById = new Map(); - static mapElementToScene( - elementKey: ElementKey, - scene: Scene, - /** - * needed because of frame exporting hack. - * elementId:Scene mapping will be removed completely, soon. - */ - mapElementIds = true, - ) { + static mapElementToScene(elementKey: ElementKey, scene: Scene) { if (isIdKey(elementKey)) { - if (!mapElementIds) { - return; - } // for cases where we don't have access to the element object // (e.g. restore serialized appState with id references) this.sceneMapById.set(elementKey, scene); } else { this.sceneMapByElement.set(elementKey, scene); - if (!mapElementIds) { - // if mapping element objects, also cache the id string when later - // looking up by id alone - this.sceneMapById.set(elementKey.id, scene); - } + // if mapping element objects, also cache the id string when later + // looking up by id alone + this.sceneMapById.set(elementKey.id, scene); } } @@ -256,7 +243,7 @@ class Scene { return didChange; } - replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) { + replaceAllElements(nextElements: ElementsMapOrArray) { this.elements = // ts doesn't like `Array.isArray` of `instanceof Map` nextElements instanceof Array @@ -269,7 +256,7 @@ class Scene { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); - Scene.mapElementToScene(element, this, mapElementIds); + Scene.mapElementToScene(element, this); }); const nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = nonDeletedElements.elements; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index a8d08c900..42a417cc8 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -12,13 +12,7 @@ import { getElementAbsoluteCoords, } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { - arrayToMap, - cloneJSON, - distance, - getFontString, - toBrandedType, -} from "../utils"; +import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -42,35 +36,11 @@ import { import { newTextElement } from "../element"; import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import Scene from "./Scene"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { RenderableElementsMap } from "./types"; const SVG_EXPORT_TAG = ``; -// getContainerElement and getBoundTextElement and potentially other helpers -// depend on `Scene` which will not be available when these pure utils are -// called outside initialized Excalidraw editor instance or even if called -// from inside Excalidraw if the elements were never cached by Scene (e.g. -// for library elements). -// -// As such, before passing the elements down, we need to initialize a custom -// Scene instance and assign them to it. -// -// FIXME This is a super hacky workaround and we'll need to rewrite this soon. -const __createSceneForElementsHack__ = ( - elements: readonly ExcalidrawElement[], -) => { - const scene = new Scene(); - // we can't duplicate elements to regenerate ids because we need the - // orig ids when embedding. So we do another hack of not mapping element - // ids to Scene instances so that we don't override the editor elements - // mapping. - // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements), false); - return scene; -}; - const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { if (element.width <= maxWidth) { return element; @@ -213,9 +183,6 @@ export const exportToCanvas = async ( return { canvas, scale: appState.exportScale }; }, ) => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, appState.frameRendering ?? null, @@ -281,8 +248,6 @@ export const exportToCanvas = async ( }, }); - tempScene.destroy(); - return canvas; }; @@ -306,9 +271,6 @@ export const exportToSvg = async ( exportingFrame?: ExcalidrawFrameLikeElement | null; }, ): Promise => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( opts?.exportingFrame ?? null, appState.frameRendering ?? null, @@ -470,8 +432,6 @@ export const exportToSvg = async ( }, ); - tempScene.destroy(); - return svgRoot; }; diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 3c3df898e..deec19406 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -57,7 +57,7 @@ export const getElementsWithinSelection = ( elementsMap, ); - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { const [fx1, fy1, fx2, fy2] = getElementBounds( containingFrame, @@ -86,7 +86,7 @@ export const getElementsWithinSelection = ( : elementsInSelection; elementsInSelection = elementsInSelection.filter((element) => { - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { return elementOverlapsWithFrame(element, containingFrame, elementsMap); From f5ab3e4e12e96c25fe2c3aff9990b6220937169b Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Feb 2024 19:45:33 +0530 Subject: [PATCH 073/112] fix: remove dependency of t from clipboard and image (#7712) * fix: remove dependency of t from clipboard and image * pass errorMessage to copyTextToSystemClipboard where needed * wrap copyTextToSystemClipboard and rethrow translated error in caller * review fix * typo --- excalidraw-app/collab/RoomDialog.tsx | 23 +++++++++--------- excalidraw-app/share/ShareDialog.tsx | 24 +++++++++---------- .../excalidraw/actions/actionClipboard.tsx | 6 ++++- packages/excalidraw/clipboard.ts | 3 +-- .../components/ShareableLinkDialog.tsx | 23 +++++++++--------- packages/excalidraw/data/index.ts | 9 ++++--- packages/excalidraw/element/image.ts | 3 +-- packages/excalidraw/locales/en.json | 3 +-- 8 files changed, 48 insertions(+), 46 deletions(-) diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx index f2614674d..74266d3d9 100644 --- a/excalidraw-app/collab/RoomDialog.tsx +++ b/excalidraw-app/collab/RoomDialog.tsx @@ -65,19 +65,18 @@ export const RoomModal = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 2fa92dff8..85e500dae 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -69,20 +69,20 @@ const ActiveRoomDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - collabAPI.setErrorMessage(error.message); + } catch (e) { + collabAPI.setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); + ref.current?.select(); }; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index b9634886b..dbc3c8751 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -238,7 +238,11 @@ export const copyText = register({ return acc; }, []) .join("\n\n"); - copyTextToSystemClipboard(text); + try { + copyTextToSystemClipboard(text); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return { commitToHistory: false, }; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index c6b6082ff..e24961c64 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -17,7 +17,6 @@ import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; -import { t } from "./i18n"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -435,7 +434,7 @@ export const copyTextToSystemClipboard = async ( // (3) if that fails, use document.execCommand if (!copyTextViaExecCommand(text)) { - throw new Error(t("errors.copyToSystemClipboardFailed")); + throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/components/ShareableLinkDialog.tsx b/packages/excalidraw/components/ShareableLinkDialog.tsx index cb8ba4cef..145cc21b5 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.tsx +++ b/packages/excalidraw/components/ShareableLinkDialog.tsx @@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(link); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 51446921f..ac3975696 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -133,9 +133,12 @@ export const exportCanvas = async ( }, ); } else if (type === "clipboard-svg") { - await copyTextToSystemClipboard( - await svgPromise.then((svg) => svg.outerHTML), - ); + const svg = await svgPromise.then((svg) => svg.outerHTML); + try { + await copyTextToSystemClipboard(svg); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return; } } diff --git a/packages/excalidraw/element/image.ts b/packages/excalidraw/element/image.ts index bd9bcd627..ad94c51e0 100644 --- a/packages/excalidraw/element/image.ts +++ b/packages/excalidraw/element/image.ts @@ -3,7 +3,6 @@ // ----------------------------------------------------------------------------- import { MIME_TYPES, SVG_NS } from "../constants"; -import { t } from "../i18n"; import { AppClassProperties, DataURL, BinaryFiles } from "../types"; import { isInitializedImageElement } from "./typeChecks"; import { @@ -100,7 +99,7 @@ export const normalizeSVG = async (SVGString: string) => { const svg = doc.querySelector("svg"); const errorNode = doc.querySelector("parsererror"); if (errorNode || !isHTMLSVGElement(svg)) { - throw new Error(t("errors.invalidSVGString")); + throw new Error("Invalid SVG"); } else { if (!svg.hasAttribute("xmlns")) { svg.setAttribute("xmlns", SVG_NS); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b983586bb..924d5c8ae 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -214,7 +214,6 @@ "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.", "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.", "failedToFetchImage": "Failed to fetch image.", - "invalidSVGString": "Invalid SVG.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", @@ -248,7 +247,7 @@ "library": "Library", "lock": "Keep selected tool active after drawing", "penMode": "Pen mode - prevent touch", - "link": "Add/ Update link for a selected shape", + "link": "Add / Update link for a selected shape", "eraser": "Eraser", "frame": "Frame tool", "magicframe": "Wireframe to code", From f639d44a954385e846edda12ca66916d7e47aaa6 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 23 Feb 2024 15:05:46 +0530 Subject: [PATCH 074/112] fix: remove dependency of t in blob.ts (#7717) * remove dependency of t in blob.ts * fix --- packages/excalidraw/components/App.tsx | 75 +++++++++++++------ .../components/ImageExportDialog.tsx | 13 +++- .../excalidraw/components/TTDDialog/common.ts | 10 ++- packages/excalidraw/data/blob.ts | 30 ++++---- packages/excalidraw/data/index.ts | 2 +- 5 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c9985c88d..97ce14662 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3214,7 +3214,13 @@ class App extends React.Component { try { return { file: await ImageURLToFile(url) }; } catch (error: any) { - return { errorMessage: error.message as string }; + let errorMessage = error.message; + if (error.cause === "FETCH_ERROR") { + errorMessage = t("errors.failedToFetchImage"); + } else if (error.cause === "UNSUPPORTED") { + errorMessage = t("errors.unsupportedFileType"); + } + return { errorMessage }; } }), ); @@ -8478,10 +8484,18 @@ class App extends React.Component { // mustn't be larger than 128 px // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property const cursorImageSizePx = 96; + let imagePreview; - const imagePreview = await resizeImageFile(imageFile, { - maxWidthOrHeight: cursorImageSizePx, - }); + try { + imagePreview = await resizeImageFile(imageFile, { + maxWidthOrHeight: cursorImageSizePx, + }); + } catch (e: any) { + if (e.cause === "UNSUPPORTED") { + throw new Error(t("errors.unsupportedFileType")); + } + throw e; + } let previewDataURL = await getDataURL(imagePreview); @@ -8870,8 +8884,9 @@ class App extends React.Component { }); return; } catch (error: any) { + // Don't throw for image scene daa if (error.name !== "EncodingError") { - throw error; + throw new Error(t("alerts.couldNotLoadInvalidFile")); } } } @@ -8945,12 +8960,39 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { - const ret = await loadSceneOrLibraryFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); + let ret; + try { + ret = await loadSceneOrLibraryFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + } catch (error: any) { + const imageSceneDataError = error instanceof ImageSceneDataError; + if ( + imageSceneDataError && + error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && + !this.isToolSupported("image") + ) { + this.setState({ + isLoading: false, + errorMessage: t("errors.imageToolNotSupported"), + }); + return; + } + const errorMessage = imageSceneDataError + ? t("alerts.cannotRestoreFromImage") + : t("alerts.couldNotLoadInvalidFile"); + this.setState({ + isLoading: false, + errorMessage, + }); + } + if (!ret) { + return; + } + if (ret.type === MIME_TYPES.excalidraw) { this.setState({ isLoading: true }); this.syncActionResult({ @@ -8975,17 +9017,6 @@ class App extends React.Component { }); } } catch (error: any) { - if ( - error instanceof ImageSceneDataError && - error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && - !this.isToolSupported("image") - ) { - this.setState({ - isLoading: false, - errorMessage: t("errors.imageToolNotSupported"), - }); - return; - } this.setState({ isLoading: false, errorMessage: error.message }); } }; diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index cecdfa79a..73c9a0def 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -124,9 +124,16 @@ const ImageExportModal = ({ setRenderError(null); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - return canvasToBlob(canvas).then(() => { - previewNode.replaceChildren(canvas); - }); + return canvasToBlob(canvas) + .then(() => { + previewNode.replaceChildren(canvas); + }) + .catch((e) => { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + }); }) .catch((error) => { console.error(error); diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 636d160a8..2389b841e 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -10,6 +10,7 @@ import { NonDeletedExcalidrawElement } from "../../element/types"; import { AppClassProperties, BinaryFiles } from "../../types"; import { canvasToBlob } from "../../data/blob"; import { EditorLocalStorage } from "../../data/EditorLocalStorage"; +import { t } from "../../i18n"; const resetPreview = ({ canvasRef, @@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({ }); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - await canvasToBlob(canvas); + try { + await canvasToBlob(canvas); + } catch (e: any) { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + } parent.style.background = "var(--default-bg-color)"; canvasNode.replaceChildren(canvas); } catch (err: any) { diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 2f8c0db96..527f1c0ea 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -4,7 +4,6 @@ import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; import { ExcalidrawElement, FileId } from "../element/types"; import { CanvasError, ImageSceneDataError } from "../errors"; -import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; import { ValueOf } from "../utility-types"; @@ -23,11 +22,11 @@ const parseFileContents = async (blob: Blob | File) => { } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } else { @@ -54,11 +53,11 @@ const parseFileContents = async (blob: Blob | File) => { } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } @@ -130,7 +129,7 @@ export const loadSceneOrLibraryFromBlob = async ( } catch (error: any) { if (isSupportedImageFile(blob)) { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } @@ -163,12 +162,12 @@ export const loadSceneOrLibraryFromBlob = async ( data, }; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } catch (error: any) { if (error instanceof ImageSceneDataError) { throw error; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } }; @@ -187,7 +186,7 @@ export const loadFromBlob = async ( fileHandle, ); if (ret.type !== MIME_TYPES.excalidraw) { - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } return ret.data; }; @@ -222,10 +221,7 @@ export const canvasToBlob = async ( canvas.toBlob((blob) => { if (!blob) { return reject( - new CanvasError( - t("canvasError.canvasTooBig"), - "CANVAS_POSSIBLY_TOO_BIG", - ), + new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"), ); } resolve(blob); @@ -314,7 +310,7 @@ export const resizeImageFile = async ( } if (!isSupportedImageFile(file)) { - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); } return new File( @@ -340,11 +336,11 @@ export const ImageURLToFile = async ( try { response = await fetch(imageUrl); } catch (error: any) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } if (!response.ok) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } const blob = await response.blob(); @@ -354,7 +350,7 @@ export const ImageURLToFile = async ( return new File([blob], name, { type: blob.type }); } - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; export const getFileFromEvent = async ( diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index ac3975696..3d0555e10 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -179,7 +179,7 @@ export const exportCanvas = async ( } catch (error: any) { console.warn(error); if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { - throw error; + throw new Error(t("canvasError.canvasTooBig")); } // TypeError *probably* suggests ClipboardItem not defined, which // people on Firefox can enable through a flag, so let's tell them. From dd8529743a0236fd1e7faf5f187ff29440270f56 Mon Sep 17 00:00:00 2001 From: Aashman Verma <111674354+aashmanVerma@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:54:27 +0530 Subject: [PATCH 075/112] docs: type mistake in integration of excalidraw (#7723) --- dev-docs/docs/@excalidraw/excalidraw/integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index d6bf3fd0d..b9edda725 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor ```jsx showLineNumbers "use client"; - import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw"; + import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw"; import "@excalidraw/excalidraw/index.css"; From b09b5cb5f4b7c33c9a5d9bb73ba196f51dfffe42 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 27 Feb 2024 10:37:44 +0530 Subject: [PATCH 076/112] fix: split renderScene so that locales aren't imported unnecessarily (#7718) * fix: split renderScene so that locales aren't imported unnecessarily * lint * split export code * rename renderScene to helpers.ts * add helpers * fix typo * fixes * move renderElementToSvg to export * lint * rename export to staticSvgScene * fix --- .../components/canvases/InteractiveCanvas.tsx | 2 +- .../components/canvases/StaticCanvas.tsx | 2 +- packages/excalidraw/renderer/helpers.ts | 75 + .../{renderScene.ts => interactiveScene.ts} | 1250 +++++------------ packages/excalidraw/renderer/renderElement.ts | 583 +------- packages/excalidraw/renderer/staticScene.ts | 370 +++++ .../excalidraw/renderer/staticSvgScene.ts | 653 +++++++++ packages/excalidraw/scene/Renderer.ts | 7 +- packages/excalidraw/scene/export.ts | 3 +- packages/excalidraw/tests/App.test.tsx | 4 +- .../excalidraw/tests/contextmenu.test.tsx | 4 +- packages/excalidraw/tests/dragCreate.test.tsx | 10 +- .../tests/linearElementEditor.test.tsx | 11 +- packages/excalidraw/tests/move.test.tsx | 10 +- .../tests/multiPointCreate.test.tsx | 10 +- .../excalidraw/tests/regressionTests.test.tsx | 4 +- packages/excalidraw/tests/selection.test.tsx | 10 +- 17 files changed, 1528 insertions(+), 1480 deletions(-) create mode 100644 packages/excalidraw/renderer/helpers.ts rename packages/excalidraw/renderer/{renderScene.ts => interactiveScene.ts} (67%) create mode 100644 packages/excalidraw/renderer/staticScene.ts create mode 100644 packages/excalidraw/renderer/staticSvgScene.ts diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 0782b92b9..e76d8ae68 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef } from "react"; -import { renderInteractiveScene } from "../../renderer/renderScene"; import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils"; import { CURSOR_TYPE } from "../../constants"; import { t } from "../../i18n"; @@ -12,6 +11,7 @@ import type { } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; +import { renderInteractiveScene } from "../../renderer/interactiveScene"; type InteractiveCanvasProps = { containerRef: React.RefObject; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index bfdb669e6..f5cc3dfe5 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; -import { renderStaticScene } from "../../renderer/renderScene"; +import { renderStaticScene } from "../../renderer/staticScene"; import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; import type { diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts new file mode 100644 index 000000000..9ab85975f --- /dev/null +++ b/packages/excalidraw/renderer/helpers.ts @@ -0,0 +1,75 @@ +import { StaticCanvasAppState, AppState } from "../types"; + +import { StaticCanvasRenderConfig } from "../scene/types"; + +import { THEME_FILTER } from "../constants"; + +export const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + stroke = true, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); + context.fill(); + if (stroke) { + context.stroke(); + } +}; + +export const getNormalizedCanvasDimensions = ( + canvas: HTMLCanvasElement, + scale: number, +): [number, number] => { + // When doing calculations based on canvas width we should used normalized one + return [canvas.width / scale, canvas.height / scale]; +}; + +export const bootstrapCanvas = ({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme, + isExporting, + viewBackgroundColor, +}: { + canvas: HTMLCanvasElement; + scale: number; + normalizedWidth: number; + normalizedHeight: number; + theme?: AppState["theme"]; + isExporting?: StaticCanvasRenderConfig["isExporting"]; + viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; +}): CanvasRenderingContext2D => { + const context = canvas.getContext("2d")!; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.scale(scale, scale); + + if (isExporting && theme === "dark") { + context.filter = THEME_FILTER; + } + + // Paint background + if (typeof viewBackgroundColor === "string") { + const hasTransparence = + viewBackgroundColor === "transparent" || + viewBackgroundColor.length === 5 || // #RGBA + viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + context.save(); + context.fillStyle = viewBackgroundColor; + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + + return context; +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/interactiveScene.ts similarity index 67% rename from packages/excalidraw/renderer/renderScene.ts rename to packages/excalidraw/renderer/interactiveScene.ts index 69926b72d..a6d997770 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,24 +1,3 @@ -import { RoughSVG } from "roughjs/bin/svg"; -import oc from "open-color"; - -import { - InteractiveCanvasAppState, - StaticCanvasAppState, - BinaryFiles, - Point, - Zoom, - AppState, -} from "../types"; -import { - ExcalidrawElement, - NonDeletedExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, - GroupId, - ExcalidrawBindableElement, - ExcalidrawFrameLikeElement, - ElementsMap, -} from "../element/types"; import { getElementAbsoluteCoords, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -27,36 +6,22 @@ import { getCommonBounds, } from "../element"; -import { roundRect } from "./roundRect"; -import { - InteractiveCanvasRenderConfig, - InteractiveSceneRenderConfig, - SVGRenderConfig, - StaticCanvasRenderConfig, - StaticSceneRenderConfig, - RenderableElementsMap, -} from "../scene/types"; +import { roundRect } from "../renderer/roundRect"; + import { getScrollBars, SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - renderElement, - renderElementToSvg, - renderSelectionElement, -} from "./renderElement"; +import { renderSelectionElement } from "../renderer/renderElement"; import { getClientColor } from "../clients"; -import { LinearElementEditor } from "../element/linearElementEditor"; import { isSelectedViaGroup, getSelectedGroupIds, getElementsInGroup, selectGroupsFromGivenElements, } from "../groups"; -import { maxBindingGap } from "../element/collision"; -import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; import { OMIT_SIDES_FOR_FRAME, shouldShowBoundingBox, @@ -64,29 +29,81 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; + +import { renderSnaps } from "../renderer/renderSnaps"; + +import { maxBindingGap } from "../element/collision"; +import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; import { - DEFAULT_TRANSFORM_HANDLE_SPACING, - FRAME_STYLE, - THEME_FILTER, -} from "../constants"; + bootstrapCanvas, + fillCircle, + getNormalizedCanvasDimensions, +} from "./helpers"; +import oc from "open-color"; +import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; import { - EXTERNAL_LINK_IMG, - getLinkHandleFromCoords, -} from "../components/hyperlink/helpers"; -import { renderSnaps } from "./renderSnaps"; + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + ExcalidrawLinearElement, + GroupId, + NonDeleted, +} from "../element/types"; import { - isEmbeddableElement, - isFrameLikeElement, - isIframeLikeElement, - isLinearElement, -} from "../element/typeChecks"; -import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; -import { - elementOverlapsWithFrame, - getTargetFrame, - isElementInFrame, -} from "../frame"; + InteractiveCanvasRenderConfig, + InteractiveSceneRenderConfig, + RenderableElementsMap, +} from "../scene/types"; + +const renderLinearElementPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, +) => { + const { elementId, hoverPointIndex } = appState.selectedLinearElement!; + if ( + appState.editingLinearElement?.selectedPointsIndices?.includes( + hoverPointIndex, + ) + ) { + return; + } + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return; + } + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + hoverPointIndex, + elementsMap, + ); + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(point, context, appState); + context.restore(); +}; + +const highlightPoint = ( + point: Point, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + context.fillStyle = "rgba(105, 101, 219, 0.4)"; + + fillCircle( + context, + point[0], + point[1], + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, + false, + ); +}; const strokeRectWithRotation = ( context: CanvasRenderingContext2D, @@ -139,86 +156,6 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - -const fillCircle = ( - context: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, - stroke = true, -) => { - context.beginPath(); - context.arc(cx, cy, radius, 0, Math.PI * 2); - context.fill(); - if (stroke) { - context.stroke(); - } -}; - -const strokeGrid = ( - context: CanvasRenderingContext2D, - gridSize: number, - scrollX: number, - scrollY: number, - zoom: Zoom, - width: number, - height: number, -) => { - const BOLD_LINE_FREQUENCY = 5; - - enum GridLineColor { - Bold = "#cccccc", - Regular = "#e5e5e5", - } - - const offsetX = - -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); - const offsetY = - -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); - - const lineWidth = Math.min(1 / zoom.value, 1); - - const spaceWidth = 1 / zoom.value; - const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; - - context.save(); - context.lineWidth = lineWidth; - - for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { - const isBold = - Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(x, offsetY - gridSize); - context.lineTo(x, offsetY + height + gridSize * 2); - context.stroke(); - } - for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { - const isBold = - Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(offsetX - gridSize, y); - context.lineTo(offsetX + width + gridSize * 2, y); - context.stroke(); - } - context.restore(); -}; - const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -245,6 +182,266 @@ const renderSingleLinearPoint = ( ); }; +const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +const renderBindingHighlightForBindableElement = ( + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + const threshold = maxBindingGap(element, width, height); + + // So that we don't overlap the element itself + const strokeOffset = 4; + context.strokeStyle = "rgba(0,0,0,.05)"; + context.lineWidth = threshold - strokeOffset; + const padding = strokeOffset / 2 + threshold / 2; + + switch (element.type) { + case "rectangle": + case "text": + case "image": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + strokeRectWithRotation( + context, + x1 - padding, + y1 - padding, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "diamond": + const side = Math.hypot(width, height); + const wPadding = (padding * side) / height; + const hPadding = (padding * side) / width; + strokeDiamondWithRotation( + context, + width + wPadding * 2, + height + hPadding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "ellipse": + strokeEllipseWithRotation( + context, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + } +}; + +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + elementsMap, + ); + fillCircle(context, x, y, threshold); + }); +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementProperties: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }, + padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, +) => { + const { + angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + cx, + cy, + dashed, + activeEmbeddable, + } = elementProperties; + const elementWidth = elementX2 - elementX1; + const elementHeight = elementY2 - elementY1; + + const linePadding = padding / appState.zoom.value; + const lineWidth = 8 / appState.zoom.value; + const spaceWidth = 4 / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; + + const count = selectionColors.length; + for (let index = 0; index < count; ++index) { + context.strokeStyle = selectionColors[index]; + if (dashed) { + context.setLineDash([ + lineWidth, + spaceWidth + (lineWidth + spaceWidth) * (count - 1), + ]); + } + context.lineDashOffset = (lineWidth + spaceWidth) * index; + strokeRectWithRotation( + context, + elementX1 - linePadding, + elementY1 - linePadding, + elementWidth + linePadding * 2, + elementHeight + linePadding * 2, + cx, + cy, + angle, + ); + } + context.restore(); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, +) => { + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderHighlight(context, suggestedBinding as any, elementsMap); + + context.restore(); +}; + +const renderFrameHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + frame: NonDeleted, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgb(0,118,255)"; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + strokeRectWithRotation( + context, + x1, + y1, + width, + height, + x1 + width / 2, + y1 + height / 2, + frame.angle, + false, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.restore(); +}; + +const renderElementsBoxHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elements: NonDeleted[], +) => { + const individualElements = elements.filter( + (element) => element.groupIds.length === 0, + ); + + const elementsInGroups = elements.filter( + (element) => element.groupIds.length > 0, + ); + + const getSelectionFromElements = (elements: ExcalidrawElement[]) => { + const [elementX1, elementY1, elementX2, elementY2] = + getCommonBounds(elements); + return { + angle: 0, + elementX1, + elementX2, + elementY1, + elementY2, + selectionColors: ["rgb(0,118,255)"], + dashed: false, + cx: elementX1 + (elementX2 - elementX1) / 2, + cy: elementY1 + (elementY2 - elementY1) / 2, + activeEmbeddable: false, + }; + }; + + const getSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elements, groupId); + return getSelectionFromElements(groupElements); + }; + + Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) + .filter(([id, isSelected]) => isSelected) + .map(([id, isSelected]) => id) + .map((groupId) => getSelectionForGroupId(groupId)) + .concat( + individualElements.map((element) => getSelectionFromElements([element])), + ) + .forEach((selection) => + renderSelectionBorder(context, appState, selection), + ); +}; + const renderLinearPointHandles = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -326,130 +523,47 @@ const renderLinearPointHandles = ( context.restore(); }; -const highlightPoint = ( - point: Point, +const renderTransformHandles = ( context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, appState: InteractiveCanvasAppState, -) => { - context.fillStyle = "rgba(105, 101, 219, 0.4)"; + transformHandles: TransformHandles, + angle: number, +): void => { + Object.keys(transformHandles).forEach((key) => { + const transformHandle = transformHandles[key as TransformHandleType]; + if (transformHandle !== undefined) { + const [x, y, width, height] = transformHandle; - fillCircle( - context, - point[0], - point[1], - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, - false, - ); -}; -const renderLinearElementPointHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementsMap: ElementsMap, -) => { - const { elementId, hoverPointIndex } = appState.selectedLinearElement!; - if ( - appState.editingLinearElement?.selectedPointsIndices?.includes( - hoverPointIndex, - ) - ) { - return; - } - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (!element) { - return; - } - const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - hoverPointIndex, - elementsMap, - ); - context.save(); - context.translate(appState.scrollX, appState.scrollY); - - highlightPoint(point, context, appState); - context.restore(); -}; - -const frameClip = ( - frame: ExcalidrawFrameLikeElement, - context: CanvasRenderingContext2D, - renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, -) => { - context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); - context.beginPath(); - if (context.roundRect) { - context.roundRect( - 0, - 0, - frame.width, - frame.height, - FRAME_STYLE.radius / appState.zoom.value, - ); - } else { - context.rect(0, 0, frame.width, frame.height); - } - context.clip(); - context.translate( - -(frame.x + appState.scrollX), - -(frame.y + appState.scrollY), - ); -}; - -const getNormalizedCanvasDimensions = ( - canvas: HTMLCanvasElement, - scale: number, -): [number, number] => { - // When doing calculations based on canvas width we should used normalized one - return [canvas.width / scale, canvas.height / scale]; -}; - -const bootstrapCanvas = ({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme, - isExporting, - viewBackgroundColor, -}: { - canvas: HTMLCanvasElement; - scale: number; - normalizedWidth: number; - normalizedHeight: number; - theme?: AppState["theme"]; - isExporting?: StaticCanvasRenderConfig["isExporting"]; - viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; -}): CanvasRenderingContext2D => { - const context = canvas.getContext("2d")!; - - context.setTransform(1, 0, 0, 1, 0, 0); - context.scale(scale, scale); - - if (isExporting && theme === "dark") { - context.filter = THEME_FILTER; - } - - // Paint background - if (typeof viewBackgroundColor === "string") { - const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); - if (hasTransparence) { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); + context.save(); + context.lineWidth = 1 / appState.zoom.value; + if (renderConfig.selectionColor) { + context.strokeStyle = renderConfig.selectionColor; + } + if (key === "rotation") { + fillCircle(context, x + width / 2, y + height / 2, width / 2); + // prefer round corners if roundRect API is available + } else if (context.roundRect) { + context.beginPath(); + context.roundRect(x, y, width, height, 2 / appState.zoom.value); + context.fill(); + context.stroke(); + } else { + strokeRectWithRotation( + context, + x, + y, + width, + height, + x + width / 2, + y + height / 2, + angle, + true, // fill before stroke + ); + } + context.restore(); } - context.save(); - context.fillStyle = viewBackgroundColor; - context.fillRect(0, 0, normalizedWidth, normalizedHeight); - context.restore(); - } else { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); - } - - return context; + }); }; const _renderInteractiveScene = ({ @@ -917,192 +1031,8 @@ const _renderInteractiveScene = ({ }; }; -const _renderStaticScene = ({ - canvas, - rc, - elementsMap, - allElementsMap, - visibleElements, - scale, - appState, - renderConfig, -}: StaticSceneRenderConfig) => { - if (canvas === null) { - return; - } - - const { renderGrid = true, isExporting } = renderConfig; - - const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( - canvas, - scale, - ); - - const context = bootstrapCanvas({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme: appState.theme, - isExporting, - viewBackgroundColor: appState.viewBackgroundColor, - }); - - // Apply zoom - context.scale(appState.zoom.value, appState.zoom.value); - - // Grid - if (renderGrid && appState.gridSize) { - strokeGrid( - context, - appState.gridSize, - appState.scrollX, - appState.scrollY, - appState.zoom, - normalizedWidth / appState.zoom.value, - normalizedHeight / appState.zoom.value, - ); - } - - const groupsToBeAddedToFrame = new Set(); - - visibleElements.forEach((element) => { - if ( - element.groupIds.length > 0 && - appState.frameToHighlight && - appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame( - element, - appState.frameToHighlight, - elementsMap, - ) || - element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) - ) { - element.groupIds.forEach((groupId) => - groupsToBeAddedToFrame.add(groupId), - ); - } - }); - - // Paint visible elements - visibleElements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - try { - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - context.restore(); - } else { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - } catch (error: any) { - console.error(error); - } - }); - - // render embeddables on top - visibleElements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - try { - const render = () => { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - - if ( - isIframeLikeElement(element) && - (isExporting || - (isEmbeddableElement(element) && - renderConfig.embedsValidationStatus.get(element.id) !== - true)) && - element.width && - element.height - ) { - const label = createPlaceholderEmbeddableLabel(element); - renderElement( - label, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - }; - // - when exporting the whole canvas, we DO NOT apply clipping - // - when we are exporting a particular frame, apply clipping - // if the containing frame is not selected, apply clipping - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - render(); - context.restore(); - } else { - render(); - } - } catch (error: any) { - console.error(error); - } - }); -}; - /** throttled to animation framerate */ -const renderInteractiveSceneThrottled = throttleRAF( +export const renderInteractiveSceneThrottled = throttleRAF( (config: InteractiveSceneRenderConfig) => { const ret = _renderInteractiveScene(config); config.callback?.(ret); @@ -1111,7 +1041,7 @@ const renderInteractiveSceneThrottled = throttleRAF( ); /** - * Interactive scene is the ui-canvas where we render boundinb boxes, selections + * Interactive scene is the ui-canvas where we render bounding boxes, selections * and other ui stuff. */ export const renderInteractiveScene = < @@ -1129,435 +1059,3 @@ export const renderInteractiveScene = < renderConfig.callback(ret); return ret as T extends true ? void : ReturnType; }; - -/** throttled to animation framerate */ -const renderStaticSceneThrottled = throttleRAF( - (config: StaticSceneRenderConfig) => { - _renderStaticScene(config); - }, - { trailing: true }, -); - -/** - * Static scene is the non-ui canvas where we render elements. - */ -export const renderStaticScene = ( - renderConfig: StaticSceneRenderConfig, - throttle?: boolean, -) => { - if (throttle) { - renderStaticSceneThrottled(renderConfig); - return; - } - - _renderStaticScene(renderConfig); -}; - -export const cancelRender = () => { - renderInteractiveSceneThrottled.cancel(); - renderStaticSceneThrottled.cancel(); -}; - -const renderTransformHandles = ( - context: CanvasRenderingContext2D, - renderConfig: InteractiveCanvasRenderConfig, - appState: InteractiveCanvasAppState, - transformHandles: TransformHandles, - angle: number, -): void => { - Object.keys(transformHandles).forEach((key) => { - const transformHandle = transformHandles[key as TransformHandleType]; - if (transformHandle !== undefined) { - const [x, y, width, height] = transformHandle; - - context.save(); - context.lineWidth = 1 / appState.zoom.value; - if (renderConfig.selectionColor) { - context.strokeStyle = renderConfig.selectionColor; - } - if (key === "rotation") { - fillCircle(context, x + width / 2, y + height / 2, width / 2); - // prefer round corners if roundRect API is available - } else if (context.roundRect) { - context.beginPath(); - context.roundRect(x, y, width, height, 2 / appState.zoom.value); - context.fill(); - context.stroke(); - } else { - strokeRectWithRotation( - context, - x, - y, - width, - height, - x + width / 2, - y + height / 2, - angle, - true, // fill before stroke - ); - } - context.restore(); - } - }); -}; - -const renderSelectionBorder = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementProperties: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }, - padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, -) => { - const { - angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - cx, - cy, - dashed, - activeEmbeddable, - } = elementProperties; - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - - const linePadding = padding / appState.zoom.value; - const lineWidth = 8 / appState.zoom.value; - const spaceWidth = 4 / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; - - const count = selectionColors.length; - for (let index = 0; index < count; ++index) { - context.strokeStyle = selectionColors[index]; - if (dashed) { - context.setLineDash([ - lineWidth, - spaceWidth + (lineWidth + spaceWidth) * (count - 1), - ]); - } - context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( - context, - elementX1 - linePadding, - elementY1 - linePadding, - elementWidth + linePadding * 2, - elementHeight + linePadding * 2, - cx, - cy, - angle, - ); - } - context.restore(); -}; - -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, - elementsMap: ElementsMap, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any, elementsMap); - - context.restore(); -}; - -const renderBindingHighlightForBindableElement = ( - context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - const threshold = maxBindingGap(element, width, height); - - // So that we don't overlap the element itself - const strokeOffset = 4; - context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = threshold - strokeOffset; - const padding = strokeOffset / 2 + threshold / 2; - - switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - strokeRectWithRotation( - context, - x1 - padding, - y1 - padding, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "diamond": - const side = Math.hypot(width, height); - const wPadding = (padding * side) / height; - const hPadding = (padding * side) / width; - strokeDiamondWithRotation( - context, - width + wPadding * 2, - height + hPadding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "ellipse": - strokeEllipseWithRotation( - context, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - } -}; - -const renderFrameHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - frame: NonDeleted, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - - context.strokeStyle = "rgb(0,118,255)"; - context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - strokeRectWithRotation( - context, - x1, - y1, - width, - height, - x1 + width / 2, - y1 + height / 2, - frame.angle, - false, - FRAME_STYLE.radius / appState.zoom.value, - ); - context.restore(); -}; - -const renderElementsBoxHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elements: NonDeleted[], -) => { - const individualElements = elements.filter( - (element) => element.groupIds.length === 0, - ); - - const elementsInGroups = elements.filter( - (element) => element.groupIds.length > 0, - ); - - const getSelectionFromElements = (elements: ExcalidrawElement[]) => { - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(elements); - return { - angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, - selectionColors: ["rgb(0,118,255)"], - dashed: false, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, - activeEmbeddable: false, - }; - }; - - const getSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); - return getSelectionFromElements(groupElements); - }; - - Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) - .filter(([id, isSelected]) => isSelected) - .map(([id, isSelected]) => id) - .map((groupId) => getSelectionForGroupId(groupId)) - .concat( - individualElements.map((element) => getSelectionFromElements([element])), - ) - .forEach((selection) => - renderSelectionBorder(context, appState, selection), - ); -}; - -const renderBindingHighlightForSuggestedPointBinding = ( - context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, -) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; - - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - ); - - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - elementsMap, - ); - fillCircle(context, x, y, threshold); - }); -}; - -let linkCanvasCache: any; -const renderLinkIcon = ( - element: NonDeletedExcalidrawElement, - context: CanvasRenderingContext2D, - appState: StaticCanvasAppState, - elementsMap: ElementsMap, -) => { - if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const [x, y, width, height] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const centerX = x + width / 2; - const centerY = y + height / 2; - context.save(); - context.translate(appState.scrollX + centerX, appState.scrollY + centerY); - context.rotate(element.angle); - - if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { - linkCanvasCache = document.createElement("canvas"); - linkCanvasCache.zoom = appState.zoom.value; - linkCanvasCache.width = - width * window.devicePixelRatio * appState.zoom.value; - linkCanvasCache.height = - height * window.devicePixelRatio * appState.zoom.value; - const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; - linkCanvasCacheContext.scale( - window.devicePixelRatio * appState.zoom.value, - window.devicePixelRatio * appState.zoom.value, - ); - linkCanvasCacheContext.fillStyle = "#fff"; - linkCanvasCacheContext.fillRect(0, 0, width, height); - linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); - linkCanvasCacheContext.restore(); - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } else { - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } - context.restore(); - } -}; - -// This should be only called for exporting purposes -export const renderSceneToSvg = ( - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - renderConfig: SVGRenderConfig, -) => { - if (!svgRoot) { - return; - } - - // render elements - elements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); - - // render embeddables on top - elements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); -}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 637a9fe1e..a40e3d398 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -20,27 +20,17 @@ import { } from "../element/typeChecks"; import { getElementAbsoluteCoords } from "../element/bounds"; import type { RoughCanvas } from "roughjs/bin/canvas"; -import type { Drawable } from "roughjs/bin/core"; -import type { RoughSVG } from "roughjs/bin/svg"; import { - SVGRenderConfig, StaticCanvasRenderConfig, RenderableElementsMap, } from "../scene/types"; -import { - distance, - getFontString, - getFontFamilyString, - isRTL, - isTestEnv, -} from "../utils"; -import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; +import { distance, getFontString, isRTL } from "../utils"; +import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import { AppState, StaticCanvasAppState, - BinaryFiles, Zoom, InteractiveCanvasAppState, ElementsPendingErasure, @@ -50,9 +40,7 @@ import { BOUND_TEXT_PADDING, ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, - MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, - SVG_NS, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { @@ -64,19 +52,16 @@ import { getBoundTextMaxWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { - createPlaceholderEmbeddableLabel, - getEmbedLink, -} from "../element/embeddable"; + import { getContainingFrame } from "../frame"; -import { normalizeLink, toValidURL } from "../data/url"; import { ShapeCache } from "../scene/ShapeCache"; // 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 // color scheme (it's still not quite there and the colors look slightly // desatured, alas...) -const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; +export const IMAGE_INVERT_FILTER = + "invert(100%) hue-rotate(180deg) saturate(1.25)"; const defaultAppState = getDefaultAppState(); @@ -905,564 +890,6 @@ export const renderElement = ( context.globalAlpha = 1; }; -const roughSVGDrawWithPrecision = ( - rsvg: RoughSVG, - drawable: Drawable, - precision?: number, -) => { - if (typeof precision === "undefined") { - return rsvg.draw(drawable); - } - const pshape: Drawable = { - sets: drawable.sets, - shape: drawable.shape, - options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, - }; - return rsvg.draw(pshape); -}; - -const maybeWrapNodesInFrameClipPath = ( - element: NonDeletedExcalidrawElement, - root: SVGElement, - nodes: SVGElement[], - frameRendering: AppState["frameRendering"], - elementsMap: RenderableElementsMap, -) => { - if (!frameRendering.enabled || !frameRendering.clip) { - return null; - } - const frame = getContainingFrame(element, elementsMap); - if (frame) { - const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); - g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); - nodes.forEach((node) => g.appendChild(node)); - return g; - } - - return null; -}; - -export const renderElementToSvg = ( - element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - offsetX: number, - offsetY: number, - renderConfig: SVGRenderConfig, -) => { - const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - let cx = (x2 - x1) / 2 - (element.x - x1); - let cy = (y2 - y1) / 2 - (element.y - y1); - if (isTextElement(element)) { - const container = getContainerElement(element, elementsMap); - if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); - - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - container, - element as ExcalidrawTextElementWithContainer, - elementsMap, - ); - cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); - cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); - offsetX = offsetX + boundTextCoords.x - element.x; - offsetY = offsetY + boundTextCoords.y - element.y; - } - } - const degree = (180 * element.angle) / Math.PI; - - // element to append node to, most of the time svgRoot - let root = svgRoot; - - // if the element has a link, create an anchor tag and make that the new root - if (element.link) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link)); - root.appendChild(anchorTag); - root = anchorTag; - } - - const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { - if (isTestEnv()) { - node.setAttribute("data-id", element.id); - } - root.appendChild(node); - }; - - const opacity = - ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * - element.opacity) / - 10000; - - switch (element.type) { - case "selection": { - // Since this is used only during editing experience, which is canvas based, - // this should not happen - throw new Error("Selection rendering is not supported for SVG"); - } - case "rectangle": - case "diamond": - case "ellipse": { - const shape = ShapeCache.generateElementShape(element, null); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "iframe": - case "embeddable": { - // render placeholder rectangle - const shape = ShapeCache.generateElementShape(element, renderConfig); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - const opacity = element.opacity / 100; - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - addToRoot(node, element); - - const label: ExcalidrawElement = - createPlaceholderEmbeddableLabel(element); - renderElementToSvg( - label, - elementsMap, - rsvg, - root, - files, - label.x + offset.x - element.x, - label.y + offset.y - element.y, - renderConfig, - ); - - // render embeddable element + iframe - const embeddableNode = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - embeddableNode.setAttribute("stroke-linecap", "round"); - embeddableNode.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - while (embeddableNode.firstChild) { - embeddableNode.removeChild(embeddableNode.firstChild); - } - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - - const embedLink = getEmbedLink(toValidURL(element.link || "")); - - // if rendering embeddables explicitly disabled or - // embedding documents via srcdoc (which doesn't seem to work for SVGs) - // replace with a link instead - if ( - renderConfig.renderEmbeddables === false || - embedLink?.type === "document" - ) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link || "")); - anchorTag.setAttribute("target", "_blank"); - anchorTag.setAttribute("rel", "noopener noreferrer"); - anchorTag.style.borderRadius = `${radius}px`; - - embeddableNode.appendChild(anchorTag); - } else { - const foreignObject = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "foreignObject", - ); - foreignObject.style.width = `${element.width}px`; - foreignObject.style.height = `${element.height}px`; - foreignObject.style.border = "none"; - const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); - div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); - div.style.width = "100%"; - div.style.height = "100%"; - const iframe = div.ownerDocument!.createElement("iframe"); - iframe.src = embedLink?.link ?? ""; - iframe.style.width = "100%"; - iframe.style.height = "100%"; - iframe.style.border = "none"; - iframe.style.borderRadius = `${radius}px`; - iframe.style.top = "0"; - iframe.style.left = "0"; - iframe.allowFullscreen = true; - div.appendChild(iframe); - foreignObject.appendChild(div); - - embeddableNode.appendChild(foreignObject); - } - addToRoot(embeddableNode, element); - break; - } - case "line": - case "arrow": { - const boundText = getBoundTextElement(element, elementsMap); - const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); - if (boundText) { - maskPath.setAttribute("id", `mask-${element.id}`); - const maskRectVisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - offsetX = offsetX || 0; - offsetY = offsetY || 0; - maskRectVisible.setAttribute("x", "0"); - maskRectVisible.setAttribute("y", "0"); - maskRectVisible.setAttribute("fill", "#fff"); - maskRectVisible.setAttribute( - "width", - `${element.width + 100 + offsetX}`, - ); - maskRectVisible.setAttribute( - "height", - `${element.height + 100 + offsetY}`, - ); - - maskPath.appendChild(maskRectVisible); - const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - element, - boundText, - elementsMap, - ); - - const maskX = offsetX + boundTextCoords.x - element.x; - const maskY = offsetY + boundTextCoords.y - element.y; - - maskRectInvisible.setAttribute("x", maskX.toString()); - maskRectInvisible.setAttribute("y", maskY.toString()); - maskRectInvisible.setAttribute("fill", "#000"); - maskRectInvisible.setAttribute("width", `${boundText.width}`); - maskRectInvisible.setAttribute("height", `${boundText.height}`); - maskRectInvisible.setAttribute("opacity", "1"); - maskPath.appendChild(maskRectInvisible); - } - const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (boundText) { - group.setAttribute("mask", `url(#mask-${element.id})`); - } - group.setAttribute("stroke-linecap", "round"); - - const shapes = ShapeCache.generateElementShape(element, renderConfig); - shapes.forEach((shape) => { - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - if ( - element.type === "line" && - isPathALoop(element.points) && - element.backgroundColor !== "transparent" - ) { - node.setAttribute("fill-rule", "evenodd"); - } - group.appendChild(node); - }); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [group, maskPath], - renderConfig.frameRendering, - elementsMap, - ); - if (g) { - addToRoot(g, element); - root.appendChild(g); - } else { - addToRoot(group, element); - root.append(maskPath); - } - break; - } - case "freedraw": { - const backgroundFillShape = ShapeCache.generateElementShape( - element, - renderConfig, - ); - const node = backgroundFillShape - ? roughSVGDrawWithPrecision( - rsvg, - backgroundFillShape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ) - : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - node.setAttribute("stroke", "none"); - const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); - path.setAttribute("fill", element.strokeColor); - path.setAttribute("d", getFreeDrawSvgPath(element)); - node.appendChild(path); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "image": { - const width = Math.round(element.width); - const height = Math.round(element.height); - const fileData = - isInitializedImageElement(element) && files[element.fileId]; - if (fileData) { - const symbolId = `image-${fileData.id}`; - let symbol = svgRoot.querySelector(`#${symbolId}`); - if (!symbol) { - symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); - symbol.id = symbolId; - - const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); - - image.setAttribute("width", "100%"); - image.setAttribute("height", "100%"); - image.setAttribute("href", fileData.dataURL); - - symbol.appendChild(image); - - root.prepend(symbol); - } - - const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); - use.setAttribute("href", `#${symbolId}`); - - // in dark theme, revert the image color filter - if ( - renderConfig.exportWithDarkMode && - fileData.mimeType !== MIME_TYPES.svg - ) { - use.setAttribute("filter", IMAGE_INVERT_FILTER); - } - - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); - use.setAttribute("opacity", `${opacity}`); - - // We first apply `scale` transforms (horizontal/vertical mirroring) - // on the element, then apply translation and rotation - // on the element which wraps the . - // Doing this separately is a quick hack to to work around compositing - // the transformations correctly (the transform-origin was not being - // applied correctly). - if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; - use.setAttribute( - "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, - ); - } - - const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - g.appendChild(use); - g.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - if (element.roundness) { - const clipPath = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "clipPath", - ); - clipPath.id = `image-clipPath-${element.id}`; - - const clipRect = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - clipRect.setAttribute("width", `${element.width}`); - clipRect.setAttribute("height", `${element.height}`); - clipRect.setAttribute("rx", `${radius}`); - clipRect.setAttribute("ry", `${radius}`); - clipPath.appendChild(clipRect); - addToRoot(clipPath, element); - - g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); - } - - const clipG = maybeWrapNodesInFrameClipPath( - element, - root, - [g], - renderConfig.frameRendering, - elementsMap, - ); - addToRoot(clipG || g, element); - } - break; - } - // frames are not rendered and only acts as a container - case "frame": - case "magicframe": { - if ( - renderConfig.frameRendering.enabled && - renderConfig.frameRendering.outline - ) { - const rect = document.createElementNS(SVG_NS, "rect"); - - rect.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - rect.setAttribute("width", `${element.width}px`); - rect.setAttribute("height", `${element.height}px`); - // Rounded corners - rect.setAttribute("rx", FRAME_STYLE.radius.toString()); - rect.setAttribute("ry", FRAME_STYLE.radius.toString()); - - rect.setAttribute("fill", "none"); - rect.setAttribute("stroke", FRAME_STYLE.strokeColor); - rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); - - addToRoot(rect, element); - } - break; - } - default: { - if (isTextElement(element)) { - const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeightPx = getLineHeightInPx( - element.fontSize, - element.lineHeight, - ); - const horizontalOffset = - element.textAlign === "center" - ? element.width / 2 - : element.textAlign === "right" - ? element.width - : 0; - const direction = isRTL(element.text) ? "rtl" : "ltr"; - const textAnchor = - element.textAlign === "center" - ? "middle" - : element.textAlign === "right" || direction === "rtl" - ? "end" - : "start"; - for (let i = 0; i < lines.length; i++) { - const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); - text.textContent = lines[i]; - text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); - text.setAttribute("font-family", getFontFamilyString(element)); - text.setAttribute("font-size", `${element.fontSize}px`); - text.setAttribute("fill", element.strokeColor); - text.setAttribute("text-anchor", textAnchor); - text.setAttribute("style", "white-space: pre;"); - text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); - node.appendChild(text); - } - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - } else { - // @ts-ignore - throw new Error(`Unimplemented type ${element.type}`); - } - } - } -}; - export const pathsCache = new WeakMap([]); export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts new file mode 100644 index 000000000..c036226a0 --- /dev/null +++ b/packages/excalidraw/renderer/staticScene.ts @@ -0,0 +1,370 @@ +import { FRAME_STYLE } from "../constants"; +import { getElementAbsoluteCoords } from "../element"; + +import { + elementOverlapsWithFrame, + getTargetFrame, + isElementInFrame, +} from "../frame"; +import { + isEmbeddableElement, + isIframeLikeElement, +} from "../element/typeChecks"; +import { renderElement } from "../renderer/renderElement"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; +import { StaticCanvasAppState, Zoom } from "../types"; +import { + ElementsMap, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { + StaticCanvasRenderConfig, + StaticSceneRenderConfig, +} from "../scene/types"; +import { + EXTERNAL_LINK_IMG, + getLinkHandleFromCoords, +} from "../components/hyperlink/helpers"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { throttleRAF } from "../utils"; + +const strokeGrid = ( + context: CanvasRenderingContext2D, + gridSize: number, + scrollX: number, + scrollY: number, + zoom: Zoom, + width: number, + height: number, +) => { + const BOLD_LINE_FREQUENCY = 5; + + enum GridLineColor { + Bold = "#cccccc", + Regular = "#e5e5e5", + } + + const offsetX = + -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); + const offsetY = + -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); + + const lineWidth = Math.min(1 / zoom.value, 1); + + const spaceWidth = 1 / zoom.value; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.save(); + context.lineWidth = lineWidth; + + for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { + const isBold = + Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(x, offsetY - gridSize); + context.lineTo(x, offsetY + height + gridSize * 2); + context.stroke(); + } + for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { + const isBold = + Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(offsetX - gridSize, y); + context.lineTo(offsetX + width + gridSize * 2, y); + context.stroke(); + } + context.restore(); +}; + +const frameClip = ( + frame: ExcalidrawFrameLikeElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.beginPath(); + if (context.roundRect) { + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(0, 0, frame.width, frame.height); + } + context.clip(); + context.translate( + -(frame.x + appState.scrollX), + -(frame.y + appState.scrollY), + ); +}; + +let linkCanvasCache: any; +const renderLinkIcon = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, + elementsMap: ElementsMap, +) => { + if (element.link && !appState.selectedElementIds[element.id]) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y, width, height] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const centerX = x + width / 2; + const centerY = y + height / 2; + context.save(); + context.translate(appState.scrollX + centerX, appState.scrollY + centerY); + context.rotate(element.angle); + + if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { + linkCanvasCache = document.createElement("canvas"); + linkCanvasCache.zoom = appState.zoom.value; + linkCanvasCache.width = + width * window.devicePixelRatio * appState.zoom.value; + linkCanvasCache.height = + height * window.devicePixelRatio * appState.zoom.value; + const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; + linkCanvasCacheContext.scale( + window.devicePixelRatio * appState.zoom.value, + window.devicePixelRatio * appState.zoom.value, + ); + linkCanvasCacheContext.fillStyle = "#fff"; + linkCanvasCacheContext.fillRect(0, 0, width, height); + linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); + linkCanvasCacheContext.restore(); + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } else { + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } + context.restore(); + } +}; +const _renderStaticScene = ({ + canvas, + rc, + elementsMap, + allElementsMap, + visibleElements, + scale, + appState, + renderConfig, +}: StaticSceneRenderConfig) => { + if (canvas === null) { + return; + } + + const { renderGrid = true, isExporting } = renderConfig; + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme: appState.theme, + isExporting, + viewBackgroundColor: appState.viewBackgroundColor, + }); + + // Apply zoom + context.scale(appState.zoom.value, appState.zoom.value); + + // Grid + if (renderGrid && appState.gridSize) { + strokeGrid( + context, + appState.gridSize, + appState.scrollX, + appState.scrollY, + appState.zoom, + normalizedWidth / appState.zoom.value, + normalizedHeight / appState.zoom.value, + ); + } + + const groupsToBeAddedToFrame = new Set(); + + visibleElements.forEach((element) => { + if ( + element.groupIds.length > 0 && + appState.frameToHighlight && + appState.selectedElementIds[element.id] && + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || + element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) + ) { + element.groupIds.forEach((groupId) => + groupsToBeAddedToFrame.add(groupId), + ); + } + }); + + // Paint visible elements + visibleElements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + try { + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + // TODO do we need to check isElementInFrame here? + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + context.restore(); + } else { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + } catch (error: any) { + console.error(error); + } + }); + + // render embeddables on top + visibleElements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + try { + const render = () => { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + + if ( + isIframeLikeElement(element) && + (isExporting || + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && + element.width && + element.height + ) { + const label = createPlaceholderEmbeddableLabel(element); + renderElement( + label, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + }; + // - when exporting the whole canvas, we DO NOT apply clipping + // - when we are exporting a particular frame, apply clipping + // if the containing frame is not selected, apply clipping + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + render(); + context.restore(); + } else { + render(); + } + } catch (error: any) { + console.error(error); + } + }); +}; + +/** throttled to animation framerate */ +export const renderStaticSceneThrottled = throttleRAF( + (config: StaticSceneRenderConfig) => { + _renderStaticScene(config); + }, + { trailing: true }, +); + +/** + * Static scene is the non-ui canvas where we render elements. + */ +export const renderStaticScene = ( + renderConfig: StaticSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderStaticSceneThrottled(renderConfig); + return; + } + + _renderStaticScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 000000000..de026300e --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,653 @@ +import { Drawable } from "roughjs/bin/core"; +import { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, + getLineHeightInPx, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { getCornerRadius, isPathALoop } from "../math"; +import { ShapeCache } from "../scene/ShapeCache"; +import { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; + +const roughSVGDrawWithPrecision = ( + rsvg: RoughSVG, + drawable: Drawable, + precision?: number, +) => { + if (typeof precision === "undefined") { + return rsvg.draw(drawable); + } + const pshape: Drawable = { + sets: drawable.sets, + shape: drawable.shape, + options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, + }; + return rsvg.draw(pshape); +}; + +const maybeWrapNodesInFrameClipPath = ( + element: NonDeletedExcalidrawElement, + root: SVGElement, + nodes: SVGElement[], + frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, +) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } + const frame = getContainingFrame(element, elementsMap); + if (frame) { + const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); + g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); + nodes.forEach((node) => g.appendChild(node)); + return g; + } + + return null; +}; + +const renderElementToSvg = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + offsetX: number, + offsetY: number, + renderConfig: SVGRenderConfig, +) => { + const offset = { x: offsetX, y: offsetY }; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } + const degree = (180 * element.angle) / Math.PI; + + // element to append node to, most of the time svgRoot + let root = svgRoot; + + // if the element has a link, create an anchor tag and make that the new root + if (element.link) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link)); + root.appendChild(anchorTag); + root = anchorTag; + } + + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + + const opacity = + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; + + switch (element.type) { + case "selection": { + // Since this is used only during editing experience, which is canvas based, + // this should not happen + throw new Error("Selection rendering is not supported for SVG"); + } + case "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "iframe": + case "embeddable": { + // render placeholder rectangle + const shape = ShapeCache.generateElementShape(element, renderConfig); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + const opacity = element.opacity / 100; + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + addToRoot(node, element); + + const label: ExcalidrawElement = + createPlaceholderEmbeddableLabel(element); + renderElementToSvg( + label, + elementsMap, + rsvg, + root, + files, + label.x + offset.x - element.x, + label.y + offset.y - element.y, + renderConfig, + ); + + // render embeddable element + iframe + const embeddableNode = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + embeddableNode.setAttribute("stroke-linecap", "round"); + embeddableNode.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + while (embeddableNode.firstChild) { + embeddableNode.removeChild(embeddableNode.firstChild); + } + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + const embedLink = getEmbedLink(toValidURL(element.link || "")); + + // if rendering embeddables explicitly disabled or + // embedding documents via srcdoc (which doesn't seem to work for SVGs) + // replace with a link instead + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link || "")); + anchorTag.setAttribute("target", "_blank"); + anchorTag.setAttribute("rel", "noopener noreferrer"); + anchorTag.style.borderRadius = `${radius}px`; + + embeddableNode.appendChild(anchorTag); + } else { + const foreignObject = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "foreignObject", + ); + foreignObject.style.width = `${element.width}px`; + foreignObject.style.height = `${element.height}px`; + foreignObject.style.border = "none"; + const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); + div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + div.style.width = "100%"; + div.style.height = "100%"; + const iframe = div.ownerDocument!.createElement("iframe"); + iframe.src = embedLink?.link ?? ""; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = `${radius}px`; + iframe.style.top = "0"; + iframe.style.left = "0"; + iframe.allowFullscreen = true; + div.appendChild(iframe); + foreignObject.appendChild(div); + + embeddableNode.appendChild(foreignObject); + } + addToRoot(embeddableNode, element); + break; + } + case "line": + case "arrow": { + const boundText = getBoundTextElement(element, elementsMap); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + elementsMap, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } + const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } + group.setAttribute("stroke-linecap", "round"); + + const shapes = ShapeCache.generateElementShape(element, renderConfig); + shapes.forEach((shape) => { + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + if ( + element.type === "line" && + isPathALoop(element.points) && + element.backgroundColor !== "transparent" + ) { + node.setAttribute("fill-rule", "evenodd"); + } + group.appendChild(node); + }); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [group, maskPath], + renderConfig.frameRendering, + elementsMap, + ); + if (g) { + addToRoot(g, element); + root.appendChild(g); + } else { + addToRoot(group, element); + root.append(maskPath); + } + break; + } + case "freedraw": { + const backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) + : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if (fileData) { + const symbolId = `image-${fileData.id}`; + let symbol = svgRoot.querySelector(`#${symbolId}`); + if (!symbol) { + symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); + symbol.id = symbolId; + + const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); + + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + image.setAttribute("href", fileData.dataURL); + + symbol.appendChild(image); + + root.prepend(symbol); + } + + const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); + use.setAttribute("href", `#${symbolId}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + use.setAttribute("width", `${width}`); + use.setAttribute("height", `${height}`); + use.setAttribute("opacity", `${opacity}`); + + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the element, then apply translation and rotation + // on the element which wraps the . + // Doing this separately is a quick hack to to work around compositing + // the transformations correctly (the transform-origin was not being + // applied correctly). + if (element.scale[0] !== 1 || element.scale[1] !== 1) { + const translateX = element.scale[0] !== 1 ? -width : 0; + const translateY = element.scale[1] !== 1 ? -height : 0; + use.setAttribute( + "transform", + `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + g.appendChild(use); + g.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + + const clipG = maybeWrapNodesInFrameClipPath( + element, + root, + [g], + renderConfig.frameRendering, + elementsMap, + ); + addToRoot(clipG || g, element); + } + break; + } + // frames are not rendered and only acts as a container + case "frame": + case "magicframe": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } + break; + } + default: { + if (isTextElement(element)) { + const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + const direction = isRTL(element.text) ? "rtl" : "ltr"; + const textAnchor = + element.textAlign === "center" + ? "middle" + : element.textAlign === "right" || direction === "rtl" + ? "end" + : "start"; + for (let i = 0; i < lines.length; i++) { + const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); + text.textContent = lines[i]; + text.setAttribute("x", `${horizontalOffset}`); + text.setAttribute("y", `${i * lineHeightPx}`); + text.setAttribute("font-family", getFontFamilyString(element)); + text.setAttribute("font-size", `${element.fontSize}px`); + text.setAttribute("fill", element.strokeColor); + text.setAttribute("text-anchor", textAnchor); + text.setAttribute("style", "white-space: pre;"); + text.setAttribute("direction", direction); + text.setAttribute("dominant-baseline", "text-before-edge"); + node.appendChild(text); + } + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + } else { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const renderSceneToSvg = ( + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + renderConfig: SVGRenderConfig, +) => { + if (!svgRoot) { + return; + } + + // render elements + elements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); + + // render embeddables on top + elements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); +}; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 0875b9f05..7970f8c1c 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -4,7 +4,9 @@ import { NonDeletedElementsMap, NonDeletedExcalidrawElement, } from "../element/types"; -import { cancelRender } from "../renderer/renderScene"; +import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; +import { renderStaticSceneThrottled } from "../renderer/staticScene"; + import { AppState } from "../types"; import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; @@ -147,7 +149,8 @@ export class Renderer { // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // safe to break TS contract here (for upstream cases) public destroy() { - cancelRender(); + renderInteractiveSceneThrottled.cancel(); + renderStaticSceneThrottled.cancel(); this.getRenderableElements.clear(); } } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 42a417cc8..8733c997e 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -11,7 +11,7 @@ import { getCommonBounds, getElementAbsoluteCoords, } from "../element/bounds"; -import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; +import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { @@ -38,6 +38,7 @@ import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { RenderableElementsMap } from "./types"; +import { renderStaticScene } from "../renderer/staticScene"; const SVG_EXPORT_TAG = ``; diff --git a/packages/excalidraw/tests/App.test.tsx b/packages/excalidraw/tests/App.test.tsx index 316d274ef..9fb055453 100644 --- a/packages/excalidraw/tests/App.test.tsx +++ b/packages/excalidraw/tests/App.test.tsx @@ -1,12 +1,12 @@ import ReactDOM from "react-dom"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { render, queryByTestId } from "../tests/test-utils"; import { Excalidraw } from "../index"; import { vi } from "vitest"; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); describe("Test ", () => { beforeEach(async () => { diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx index 8c413d003..f034dbd8c 100644 --- a/packages/excalidraw/tests/contextmenu.test.tsx +++ b/packages/excalidraw/tests/contextmenu.test.tsx @@ -12,7 +12,7 @@ import { togglePopover, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; @@ -39,7 +39,7 @@ const mouse = new Pointer("mouse"); // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); renderStaticScene.mockClear(); diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index a34696d81..7bde27b1c 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -1,6 +1,7 @@ import ReactDOM from "react-dom"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveScene from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { render, @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveScene, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 6c01987c9..551b79479 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -8,7 +8,9 @@ import { import { Excalidraw } from "../index"; import { centerPoint } from "../math"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +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"; @@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const { h } = window; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 06086f119..8a0e562be 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,7 +1,8 @@ import ReactDOM from "react-dom"; import { render, fireEvent } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { reseed } from "../random"; import { bindOrUnbindLinearElement } from "../element/binding"; import { @@ -16,8 +17,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index f462cfacf..bc8c7843d 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -6,7 +6,8 @@ import { restoreOriginalGetBoundingClientRect, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { ExcalidrawLinearElement } from "../element/types"; import { reseed } from "../random"; @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 22f2c0159..e15a12ed2 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types"; import { CODES, KEYS } from "../keys"; import { Excalidraw } from "../index"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { setDateTimeForTests } from "../utils"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; @@ -19,7 +19,7 @@ import { vi } from "vitest"; const { h } = window; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const mouse = new Pointer("mouse"); const finger1 = new Pointer("touch", 1); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 54f244ece..18e0dfe2a 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -7,7 +7,8 @@ import { assertSelectedElements, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { reseed } from "../random"; import { API } from "./helpers/api"; @@ -18,8 +19,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); From 36e56267c947319d8d69c17eeebc2f378c54f433 Mon Sep 17 00:00:00 2001 From: Wabweni Brian <115115387+WabweniBrian@users.noreply.github.com> Date: Tue, 27 Feb 2024 10:19:20 +0300 Subject: [PATCH 077/112] docs: add missing closing angle bracket in integration.mdx (#7729) Update integration.mdx: Fix missing closing angle bracket in code sample A closing angle bracket was missing in a code sample. Original code:
    Changes:
    --- dev-docs/docs/@excalidraw/excalidraw/integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index b9edda725..391b5800b 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor height: 141.9765625, },])); return ( -
    ); From af1a3d5b76b47af5d2ba8a6ea9684525576c2732 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 28 Feb 2024 11:14:57 +0530 Subject: [PATCH 078/112] fix: export utils from excalidraw package in excalidraw library (#7731) * fix: export utils from excalidraw package in excalidraw library * don't export utils utilities * fix import path * fix export * don't export export utilites * fix export paths * reexport utils from excalidraw package * add exports from withinBounds * fix path --- packages/excalidraw/frame.ts | 5 +---- packages/excalidraw/index.tsx | 19 +++++++++++-------- packages/utils/export.ts | 18 ------------------ packages/utils/index.js | 1 - packages/utils/index.ts | 3 +++ 5 files changed, 15 insertions(+), 31 deletions(-) delete mode 100644 packages/utils/index.js create mode 100644 packages/utils/index.ts diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index cc80531ee..d627fc4c9 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -23,10 +23,7 @@ import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; -import { - doLineSegmentsIntersect, - elementsOverlappingBBox, -} from "../utils/export"; +import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; import { ReadonlySetLike } from "./utility-types"; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f7be8affc..2dae37c6b 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -217,19 +217,22 @@ export { restoreElements, restoreLibraryItems, } from "./data/restore"; + export { exportToCanvas, exportToBlob, exportToSvg, - serializeAsJSON, - serializeLibraryAsJSON, - loadLibraryFromBlob, + exportToClipboard, +} from "../utils/export"; + +export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; +export { loadFromBlob, loadSceneOrLibraryFromBlob, - getFreeDrawSvgPath, - exportToClipboard, - mergeLibraryItems, -} from "../utils/export"; + loadLibraryFromBlob, +} from "./data/blob"; +export { getFreeDrawSvgPath } from "./renderer/renderElement"; +export { mergeLibraryItems } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; @@ -268,4 +271,4 @@ export { elementsOverlappingBBox, isElementInsideBBox, elementPartiallyOverlapsWithOrContainsBBox, -} from "../utils/export"; +} from "../utils/withinBounds"; diff --git a/packages/utils/export.ts b/packages/utils/export.ts index ceb733881..5bdddba4f 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -205,21 +205,3 @@ export const exportToClipboard = async ( throw new Error("Invalid export type"); } }; - -export * from "./bbox"; -export { - elementsOverlappingBBox, - isElementInsideBBox, - elementPartiallyOverlapsWithOrContainsBBox, -} from "./withinBounds"; -export { - serializeAsJSON, - serializeLibraryAsJSON, -} from "../excalidraw/data/json"; -export { - loadFromBlob, - loadSceneOrLibraryFromBlob, - loadLibraryFromBlob, -} from "../excalidraw/data/blob"; -export { getFreeDrawSvgPath } from "../excalidraw/renderer/renderElement"; -export { mergeLibraryItems } from "../excalidraw/data/library"; diff --git a/packages/utils/index.js b/packages/utils/index.js deleted file mode 100644 index ffea9c3cf..000000000 --- a/packages/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from "./export"; diff --git a/packages/utils/index.ts b/packages/utils/index.ts new file mode 100644 index 000000000..d199849eb --- /dev/null +++ b/packages/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./export"; +export * from "./withinBounds"; +export * from "./bbox"; From 99601baffc43cddf74bda9a0c6f50ce924e578ad Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 28 Feb 2024 19:33:47 +0530 Subject: [PATCH 079/112] =?UTF-8?q?build:=20create=20ESM=20build=20for=20u?= =?UTF-8?q?tils=20package=20=F0=9F=A5=B3=20(#7500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: create ESM build for utils package * add deps, exports and import.meta --- .gitignore | 3 +- packages/utils/package.json | 23 ++++++- scripts/buildUtils.js | 123 ++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 scripts/buildUtils.js diff --git a/.gitignore b/.gitignore index 21d2730a2..81b63339f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ packages/excalidraw/types coverage dev-dist html -examples/**/bundle.* \ No newline at end of file +examples/**/bundle.* +meta*.json \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json index 7375e8b58..cfa1c4375 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,15 @@ { "name": "@excalidraw/utils", "version": "0.1.2", - "main": "dist/excalidraw-utils.min.js", + "main": "./dist/prod/index.js", + "type": "module", + "module": "./dist/prod/index.js", + "exports": { + ".": { + "development": "./dist/dev/index.js", + "default": "./dist/prod/index.js" + } + }, "files": [ "dist/*" ], @@ -33,6 +41,18 @@ "last 1 safari version" ] }, + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "browser-fs-access": "0.29.1", + "open-color": "1.9.1", + "pako": "1.0.11", + "perfect-freehand": "1.2.0", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "roughjs": "4.6.4" + }, "devDependencies": { "@babel/core": "7.18.9", "@babel/plugin-transform-arrow-functions": "7.18.6", @@ -56,6 +76,7 @@ "repository": "https://github.com/excalidraw/excalidraw", "scripts": { "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", + "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js", "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/scripts/buildUtils.js b/scripts/buildUtils.js new file mode 100644 index 000000000..e910fc9f3 --- /dev/null +++ b/scripts/buildUtils.js @@ -0,0 +1,123 @@ +const { build } = require("esbuild"); +const { sassPlugin } = require("esbuild-sass-plugin"); + +const fs = require("fs"); + +const browserConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", + plugins: [sassPlugin()], +}; + +// 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", + plugins: [sassPlugin()], +}; + +// const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`; +// const filesinExcalidrawPackage = getFiles(`${BASE_PATH}/packages/utils`); + +// const filesToTransform = filesinExcalidrawPackage.filter((file) => { +// return !( +// file.includes("/__tests__/") || +// file.includes(".test.") || +// file.includes("/tests/") || +// file.includes("example") +// ); +// }); +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 f207bd0a1c99bfc746ec19f7a295162b35960636 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 29 Feb 2024 15:43:04 +0530 Subject: [PATCH 080/112] build: export types for @excalidraw/utils (#7736) * build: export types for @excalidraw/utils * fix * add types --- packages/utils/global.d.ts | 3 +++ packages/utils/package.json | 5 ++++- packages/utils/tsconfig.json | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/utils/global.d.ts create mode 100644 packages/utils/tsconfig.json diff --git a/packages/utils/global.d.ts b/packages/utils/global.d.ts new file mode 100644 index 000000000..faf1d1878 --- /dev/null +++ b/packages/utils/global.d.ts @@ -0,0 +1,3 @@ +/// +import "../excalidraw/global"; +import "../excalidraw/css"; diff --git a/packages/utils/package.json b/packages/utils/package.json index cfa1c4375..d8374f8b7 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,6 +10,7 @@ "default": "./dist/prod/index.js" } }, + "types": "./dist/utils/index.d.ts", "files": [ "dist/*" ], @@ -68,6 +69,7 @@ "file-loader": "6.2.0", "sass-loader": "13.0.2", "ts-loader": "9.3.1", + "typescript": "4.9.4", "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0" @@ -75,8 +77,9 @@ "bugs": "https://github.com/excalidraw/excalidraw/issues", "repository": "https://github.com/excalidraw/excalidraw", "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", + "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/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..f8aa631e9 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "strict": true, + "outDir": "dist", + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "exclude": ["**/*.test.*", "**/tests/*", "types", "dist"] +} From 160440b8607fd6c2502487814f3524716a1a5c52 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 4 Mar 2024 20:43:44 +0800 Subject: [PATCH 081/112] feat: improve collab error notification (#7741) * identify cause * toast after dialog for error messages in collab * remove comment * shake tooltip instead for repeating collab errors * clear collab error * empty commit * simplify & fix reset race condition --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 17 ++++--- excalidraw-app/collab/Collab.tsx | 64 +++++++++++++++++++----- excalidraw-app/collab/CollabError.scss | 35 +++++++++++++ excalidraw-app/collab/CollabError.tsx | 54 ++++++++++++++++++++ excalidraw-app/index.scss | 7 +++ excalidraw-app/share/ShareDialog.tsx | 2 +- packages/excalidraw/components/Toast.tsx | 5 +- packages/excalidraw/components/icons.tsx | 4 ++ 8 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 excalidraw-app/collab/CollabError.scss create mode 100644 excalidraw-app/collab/CollabError.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 972737b9d..7517bb379 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -104,6 +104,7 @@ import { openConfirmModal } from "../packages/excalidraw/components/OverwriteCon import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; polyfill(); @@ -310,6 +311,7 @@ const ExcalidrawWrapper = () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); + const collabError = useAtomValue(collabErrorIndicatorAtom); useHandleLibrary({ excalidrawAPI, @@ -748,12 +750,15 @@ const ExcalidrawWrapper = () => { return null; } return ( - - setShareDialogState({ isOpen: true, type: "share" }) - } - /> +
    + {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
    ); }} > diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 14538b674..f7879c64e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -81,6 +81,7 @@ import { appJotaiStore } from "../app-jotai"; import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; +import { collabErrorIndicatorAtom } from "./CollabError"; export const collabAPIAtom = atom(null); export const isCollaboratingAtom = atom(false); @@ -88,6 +89,8 @@ export const isOfflineAtom = atom(false); interface CollabState { errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; username: string; activeRoomLink: string | null; } @@ -107,7 +110,7 @@ export interface CollabAPI { setUsername: CollabInstance["setUsername"]; getUsername: CollabInstance["getUsername"]; getActiveRoomLink: CollabInstance["getActiveRoomLink"]; - setErrorMessage: CollabInstance["setErrorMessage"]; + setCollabError: CollabInstance["setErrorDialog"]; } interface CollabProps { @@ -129,6 +132,7 @@ class Collab extends PureComponent { super(props); this.state = { errorMessage: null, + dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", activeRoomLink: null, }; @@ -197,7 +201,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, getUsername: this.getUsername, getActiveRoomLink: this.getActiveRoomLink, - setErrorMessage: this.setErrorMessage, + setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -276,18 +280,35 @@ class Collab extends PureComponent { this.excalidrawAPI.getAppState(), ); + this.resetErrorIndicator(); + if (this.isCollaborating() && savedData && savedData.reconciledElements) { this.handleRemoteSceneUpdate( this.reconcileElements(savedData.reconciledElements), ); } } catch (error: any) { - this.setState({ - // firestore doesn't return a specific error code when size exceeded - errorMessage: /is longer than.*?bytes/.test(error.message) - ? t("errors.collabSaveFailed_sizeExceeded") - : t("errors.collabSaveFailed"), - }); + const errorMessage = /is longer than.*?bytes/.test(error.message) + ? t("errors.collabSaveFailed_sizeExceeded") + : t("errors.collabSaveFailed"); + + if ( + !this.state.dialogNotifiedErrors[errorMessage] || + !this.isCollaborating() + ) { + this.setErrorDialog(errorMessage); + this.setState({ + dialogNotifiedErrors: { + ...this.state.dialogNotifiedErrors, + [errorMessage]: true, + }, + }); + } + + if (this.isCollaborating()) { + this.setErrorIndicator(errorMessage); + } + console.error(error); } }; @@ -296,6 +317,7 @@ class Collab extends PureComponent { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( @@ -464,7 +486,7 @@ class Collab extends PureComponent { this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); - this.setState({ errorMessage: error.message }); + this.setErrorDialog(error.message); return null; } @@ -923,8 +945,26 @@ class Collab extends PureComponent { getActiveRoomLink = () => this.state.activeRoomLink; - setErrorMessage = (errorMessage: string | null) => { - this.setState({ errorMessage }); + setErrorIndicator = (errorMessage: string | null) => { + appJotaiStore.set(collabErrorIndicatorAtom, { + message: errorMessage, + nonce: Date.now(), + }); + }; + + resetErrorIndicator = (resetDialogNotifiedErrors = false) => { + appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); + if (resetDialogNotifiedErrors) { + this.setState({ + dialogNotifiedErrors: {}, + }); + } + }; + + setErrorDialog = (errorMessage: string | null) => { + this.setState({ + errorMessage, + }); }; render() { @@ -933,7 +973,7 @@ class Collab extends PureComponent { return ( <> {errorMessage != null && ( - this.setState({ errorMessage: null })}> + this.setErrorDialog(null)}> {errorMessage} )} diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss new file mode 100644 index 000000000..085dc5609 --- /dev/null +++ b/excalidraw-app/collab/CollabError.scss @@ -0,0 +1,35 @@ +@import "../../packages/excalidraw/css/variables.module.scss"; + +.excalidraw { + .collab-errors-button { + width: 26px; + height: 26px; + margin-inline-end: 1rem; + + color: var(--color-danger); + + flex-shrink: 0; + } + + .collab-errors-button-shake { + animation: strong-shake 0.15s 6; + } + + @keyframes strong-shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(10deg); + } + 50% { + transform: rotate(0eg); + } + 75% { + transform: rotate(-10deg); + } + 100% { + transform: rotate(0deg); + } + } +} diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx new file mode 100644 index 000000000..45a98ac8d --- /dev/null +++ b/excalidraw-app/collab/CollabError.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; +import { warning } from "../../packages/excalidraw/components/icons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +import "./CollabError.scss"; +import { atom } from "jotai"; + +type ErrorIndicator = { + message: string | null; + /** used to rerun the useEffect responsible for animation */ + nonce: number; +}; + +export const collabErrorIndicatorAtom = atom({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(); + + useEffect(() => { + setIsAnimating(true); + clearAnimationRef.current = setTimeout(() => { + setIsAnimating(false); + }, 1000); + + return () => { + clearTimeout(clearAnimationRef.current); + }; + }, [collabError.message, collabError.nonce]); + + if (!collabError.message) { + return null; + } + + return ( + +
    + {warning} +
    +
    + ); +}; + +CollabError.displayName = "CollabError"; + +export default CollabError; diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index d7ab79836..021442753 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -4,6 +4,13 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } + + .top-right-ui { + display: flex; + justify-content: center; + align-items: center; + } + .footer-center { justify-content: flex-end; margin-top: auto; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 85e500dae..68096417b 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -70,7 +70,7 @@ const ActiveRoomDialog = ({ try { await copyTextToSystemClipboard(activeRoomLink); } catch (e) { - collabAPI.setErrorMessage(t("errors.copyToSystemClipboardFailed")); + collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed")); } setJustCopied(true); diff --git a/packages/excalidraw/components/Toast.tsx b/packages/excalidraw/components/Toast.tsx index be0c46663..2f0852a5d 100644 --- a/packages/excalidraw/components/Toast.tsx +++ b/packages/excalidraw/components/Toast.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { CSSProperties, useCallback, useEffect, useRef } from "react"; import { CloseIcon } from "./icons"; import "./Toast.scss"; import { ToolButton } from "./ToolButton"; @@ -11,11 +11,13 @@ export const Toast = ({ closable = false, // To prevent autoclose, pass duration as Infinity duration = DEFAULT_TOAST_TIMEOUT, + style, }: { message: string; onClose: () => void; closable?: boolean; duration?: number; + style?: CSSProperties; }) => { const timerRef = useRef(0); const shouldAutoClose = duration !== Infinity; @@ -43,6 +45,7 @@ export const Toast = ({ className="Toast" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + style={style} >

    {message}

    {closable && ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index fcf8df4a6..967ae1976 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -604,6 +604,10 @@ export const share = createIcon( modifiedTablerIconProps, ); +export const warning = createIcon( + "M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z", +); + export const shareIOS = createIcon( "M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z", { width: 24, height: 24 }, From 7e471b55ebd45663c7708f3c2e29eebe43fe6d97 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 5 Mar 2024 19:33:27 +0000 Subject: [PATCH 082/112] feat: text measurements based on font metrics (#7693) * Introduced vertical offset based on harcoded font metrics * Unified usage of alphabetic baseline for both canvas & svg export * Removed baseline property * Removed font-size rounding on Safari * Removed artificial width offset --- packages/excalidraw/CHANGELOG.md | 2 + .../excalidraw/actions/actionBoundText.tsx | 3 +- .../data/__snapshots__/transform.test.ts.snap | 19 --- packages/excalidraw/data/restore.ts | 9 +- packages/excalidraw/element/newElement.ts | 13 +- packages/excalidraw/element/resizeElements.ts | 35 +----- packages/excalidraw/element/textElement.ts | 112 ++++++++---------- packages/excalidraw/element/textWysiwyg.tsx | 17 +-- packages/excalidraw/element/types.ts | 1 - packages/excalidraw/renderer/renderElement.ts | 12 +- .../excalidraw/renderer/staticSvgScene.ts | 10 +- .../linearElementEditor.test.tsx.snap | 2 +- .../data/__snapshots__/restore.test.ts.snap | 2 - 13 files changed, 83 insertions(+), 154 deletions(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 3759b44c4..d88bae8ab 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -27,6 +27,8 @@ Please add the latest change on the top under the correct section. - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. + - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed #### Bundler diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index e0ea95cd4..daefa5691 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -49,7 +49,7 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - const { width, height, baseline } = measureText( + const { width, height } = measureText( boundTextElement.originalText, getFontString(boundTextElement), boundTextElement.lineHeight, @@ -67,7 +67,6 @@ export const actionUnbindText = register({ containerId: null, width, height, - baseline, text: boundTextElement.originalText, x, y, diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 450fce7de..2ae7eced8 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -224,7 +224,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -269,7 +268,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -373,7 +371,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id48", "customData": undefined, @@ -472,7 +469,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id37", "customData": undefined, @@ -643,7 +639,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id41", "customData": undefined, @@ -683,7 +678,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -728,7 +722,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -1174,7 +1167,6 @@ exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, "customData": undefined, @@ -1214,7 +1206,6 @@ exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, "customData": undefined, @@ -1458,7 +1449,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id25", "customData": undefined, @@ -1498,7 +1488,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id26", "customData": undefined, @@ -1538,7 +1527,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id27", "customData": undefined, @@ -1579,7 +1567,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id28", "customData": undefined, @@ -1836,7 +1823,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id13", "customData": undefined, @@ -1876,7 +1862,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id14", "customData": undefined, @@ -1917,7 +1902,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id15", "customData": undefined, @@ -1960,7 +1944,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id16", "customData": undefined, @@ -2001,7 +1984,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id17", "customData": undefined, @@ -2043,7 +2025,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id18", "customData": undefined, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 022457f01..0ff0203dc 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -35,14 +35,13 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, getContainerElement, getDefaultLineHeight, - measureBaseline, } from "../element/textElement"; import { normalizeLink } from "./url"; @@ -207,11 +206,6 @@ const restoreElement = ( : // no element height likely means programmatic use, so default // to a fixed line height getDefaultLineHeight(element.fontFamily)); - const baseline = measureBaseline( - element.text, - getFontString(element), - lineHeight, - ); element = restoreElementWithProperties(element, { fontSize, fontFamily, @@ -222,7 +216,6 @@ const restoreElement = ( originalText: element.originalText || text, lineHeight, - baseline, }); // if empty text, mark as deleted. We keep in array diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 076f64722..967359c5a 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -249,7 +249,6 @@ export const newTextElement = ( y: opts.y - offsets.y, width: metrics.width, height: metrics.height, - baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, lineHeight, @@ -268,13 +267,12 @@ const getAdjustedDimensions = ( y: number; width: number; height: number; - baseline: number; } => { - const { - width: nextWidth, - height: nextHeight, - baseline: nextBaseline, - } = measureText(nextText, getFontString(element), element.lineHeight); + const { width: nextWidth, height: nextHeight } = measureText( + nextText, + getFontString(element), + element.lineHeight, + ); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -328,7 +326,6 @@ const getAdjustedDimensions = ( return { width: nextWidth, height: nextHeight, - baseline: nextBaseline, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 49724d9eb..e18a4ed25 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -52,8 +52,6 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, - measureText, - getBoundTextMaxHeight, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; @@ -213,8 +211,7 @@ const measureFontSizeFromWidth = ( element: NonDeleted, elementsMap: ElementsMap, nextWidth: number, - nextHeight: number, -): { size: number; baseline: number } | null => { +): { size: number } | null => { // We only use width to scale font on resize let width = element.width; @@ -229,14 +226,9 @@ const measureFontSizeFromWidth = ( if (nextFontSize < MIN_FONT_SIZE) { return null; } - const metrics = measureText( - element.text, - getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.lineHeight, - ); + return { size: nextFontSize, - baseline: metrics.baseline + (nextHeight - metrics.height), }; }; @@ -309,12 +301,7 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth( - element, - elementsMap, - nextWidth, - nextHeight, - ); + const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); if (metrics === null) { return; } @@ -342,7 +329,6 @@ const resizeSingleTextElement = ( fontSize: metrics.size, width: nextWidth, height: nextHeight, - baseline: metrics.baseline, x: nextElementX, y: nextElementY, }); @@ -396,7 +382,7 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number; baseline?: number } = {}; + let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(element, elementsMap); if (transformHandleDirection.includes("e")) { @@ -448,7 +434,6 @@ export const resizeSingleElement = ( if (stateOfBoundTextElementAtResize) { boundTextFont = { fontSize: stateOfBoundTextElementAtResize.fontSize, - baseline: stateOfBoundTextElementAtResize.baseline, }; } if (shouldMaintainAspectRatio) { @@ -462,14 +447,12 @@ export const resizeSingleElement = ( boundTextElement, elementsMap, getBoundTextMaxWidth(updatedElement, boundTextElement), - getBoundTextMaxHeight(updatedElement, boundTextElement), ); if (nextFont === null) { return; } boundTextFont = { fontSize: nextFont.size, - baseline: nextFont.baseline, }; } else { const minWidth = getApproxMinLineWidth( @@ -638,7 +621,6 @@ export const resizeSingleElement = ( if (boundTextElement && boundTextFont != null) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, - baseline: boundTextFont.baseline, }); } handleBindTextResize( @@ -769,7 +751,6 @@ export const resizeMultipleElements = ( > & { points?: ExcalidrawLinearElement["points"]; fontSize?: ExcalidrawTextElement["fontSize"]; - baseline?: ExcalidrawTextElement["baseline"]; scale?: ExcalidrawImageElement["scale"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"]; }; @@ -844,17 +825,11 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth( - orig, - elementsMap, - width, - height, - ); + const metrics = measureFontSizeFromWidth(orig, elementsMap, width); if (!metrics) { return; } update.fontSize = metrics.size; - update.baseline = metrics.baseline; } const boundTextElement = originalElements.get( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 4aa0868d7..102ed681c 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -18,7 +18,6 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, - isSafari, TEXT_ALIGN, VERTICAL_ALIGN, } from "../constants"; @@ -62,7 +61,6 @@ export const redrawTextBoundingBox = ( text: textElement.text, width: textElement.width, height: textElement.height, - baseline: textElement.baseline, }; boundTextUpdates.text = textElement.text; @@ -83,7 +81,6 @@ export const redrawTextBoundingBox = ( boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; - boundTextUpdates.baseline = metrics.baseline; if (container) { const maxContainerHeight = getBoundTextMaxHeight( @@ -188,7 +185,6 @@ export const handleBindTextResize = ( const maxWidth = getBoundTextMaxWidth(container, textElement); const maxHeight = getBoundTextMaxHeight(container, textElement); let containerHeight = container.height; - let nextBaseLine = textElement.baseline; if ( shouldMaintainAspectRatio || (transformHandleType !== "n" && transformHandleType !== "s") @@ -207,7 +203,6 @@ export const handleBindTextResize = ( ); nextHeight = metrics.height; nextWidth = metrics.width; - nextBaseLine = metrics.baseline; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { @@ -235,7 +230,6 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - baseline: nextBaseLine, }); if (!isArrowElement(container)) { @@ -285,8 +279,6 @@ export const computeBoundTextPosition = ( return { x, y }; }; -// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js - export const measureText = ( text: string, font: FontString, @@ -301,59 +293,7 @@ export const measureText = ( const fontSize = parseFloat(font); const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); - const baseline = measureBaseline(text, font, lineHeight); - return { width, height, baseline }; -}; - -export const measureBaseline = ( - text: string, - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], - wrapInContainer?: boolean, -) => { - const container = document.createElement("div"); - container.style.position = "absolute"; - container.style.whiteSpace = "pre"; - container.style.font = font; - container.style.minHeight = "1em"; - if (wrapInContainer) { - container.style.overflow = "hidden"; - container.style.wordBreak = "break-word"; - container.style.whiteSpace = "pre-wrap"; - } - - container.style.lineHeight = String(lineHeight); - - container.innerText = text; - - // Baseline is important for positioning text on canvas - document.body.appendChild(container); - - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - container.appendChild(span); - let baseline = span.offsetTop + span.offsetHeight; - const height = container.offsetHeight; - - if (isSafari) { - const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight); - const fontSize = parseFloat(font); - // In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs - // from the actual canvas height - const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight); - if (canvasHeight > height) { - baseline += canvasHeight - domHeight; - } - - if (height > canvasHeight) { - baseline -= domHeight - canvasHeight; - } - } - document.body.removeChild(container); - return baseline; + return { width, height }; }; /** @@ -378,6 +318,23 @@ export const getLineHeightInPx = ( return fontSize * lineHeight; }; +/** + * Calculates vertical offset for a text with alphabetic baseline. + */ +export const getVerticalOffset = ( + fontFamily: ExcalidrawTextElement["fontFamily"], + fontSize: ExcalidrawTextElement["fontSize"], + lineHeightPx: number, +) => { + const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily]; + + const fontSizeEm = fontSize / unitsPerEm; + const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; + + const verticalOffset = fontSizeEm * ascender + lineGap; + return verticalOffset; +}; + // FIXME rename to getApproxMinContainerHeight export const getApproxMinLineHeight = ( fontSize: ExcalidrawTextElement["fontSize"], @@ -964,13 +921,40 @@ const DEFAULT_LINE_HEIGHT = { // ~1.25 is the average for Virgil in WebKit and Blink. // Gecko (FF) uses ~1.28. [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], - // ~1.15 is the average for Virgil in WebKit and Blink. - // Gecko if all over the place. + // ~1.15 is the average for Helvetica in WebKit and Blink. [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], - // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too + // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; +type FontMetrics = { + unitsPerEm: number; // head.unitsPerEm + ascender: number; // sTypoAscender + descender: number; // sTypoDescender +}; + +/** + * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. + * For custom fonts, read these metrics on load and extend this object. + */ +const FONT_METRICS = { + [FONT_FAMILY.Virgil]: { + unitsPerEm: 1000, + ascender: 886, + descender: -374, + }, + [FONT_FAMILY.Helvetica]: { + unitsPerEm: 2048, + ascender: 1577, + descender: -471, + }, + [FONT_FAMILY.Cascadia]: { + unitsPerEm: 2048, + ascender: 1977, + descender: -480, + }, +} as Record; + export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { if (fontFamily in DEFAULT_LINE_HEIGHT) { return DEFAULT_LINE_HEIGHT[fontFamily]; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index ae30be4e9..7dfdbc615 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, isSafari } from "../constants"; +import { CLASSES } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -31,7 +31,6 @@ import { getBoundTextMaxHeight, getBoundTextMaxWidth, computeContainerDimensionForBoundText, - detectLineHeight, computeBoundTextPosition, getBoundTextElement, } from "./textElement"; @@ -227,18 +226,6 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); - } else { - textElementWidth += 0.5; - } - - let lineHeight = updatedTextElement.lineHeight; - - // In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size - if (isSafari) { - lineHeight = detectLineHeight({ - ...updatedTextElement, - fontSize: Math.round(updatedTextElement.fontSize), - }); } // Make sure text editor height doesn't go beyond viewport @@ -247,7 +234,7 @@ export const textWysiwyg = ({ Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ - lineHeight, + lineHeight: updatedTextElement.lineHeight, width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index aae0a8a30..85f42adcf 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -176,7 +176,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & fontSize: number; fontFamily: FontFamilyValues; text: string; - baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a40e3d398..df3e20efe 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -50,6 +50,7 @@ import { getLineHeightInPx, getBoundTextMaxHeight, getBoundTextMaxWidth, + getVerticalOffset, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -383,16 +384,23 @@ const drawElementOnCanvas = ( : element.textAlign === "right" ? element.width : 0; + const lineHeightPx = getLineHeightInPx( element.fontSize, element.lineHeight, ); - const verticalOffset = element.height - element.baseline; + + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeightPx - verticalOffset, + index * lineHeightPx + verticalOffset, ); } context.restore(); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index de026300e..7b3917d4e 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -17,6 +17,7 @@ import { getBoundTextElement, getContainerElement, getLineHeightInPx, + getVerticalOffset, } from "../element/textElement"; import { isArrowElement, @@ -556,6 +557,11 @@ const renderElementToSvg = ( : element.textAlign === "right" ? element.width : 0; + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); const direction = isRTL(element.text) ? "rtl" : "ltr"; const textAnchor = element.textAlign === "center" @@ -567,14 +573,14 @@ const renderElementToSvg = ( const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); + text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("fill", element.strokeColor); text.setAttribute("text-anchor", textAnchor); text.setAttribute("style", "white-space: pre;"); text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); + text.setAttribute("dominant-baseline", "alphabetic"); node.appendChild(text); } diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 41e9f2b12..1fd7106bd 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" /> diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index 457ed4f14..156e839a3 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -296,7 +296,6 @@ exports[`restoreElements > should restore text element correctly passing value f { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [], "containerId": null, "customData": undefined, @@ -338,7 +337,6 @@ exports[`restoreElements > should restore text element correctly with unknown fo { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [], "containerId": null, "customData": undefined, From a07f6e9e3a6c4c38b7955825bef28d9f90580bf6 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:22:25 +0100 Subject: [PATCH 083/112] feat: show ai badge for discovery (#7749) --- packages/excalidraw/components/Actions.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index c11d64d04..acff6aaa3 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -306,6 +306,25 @@ export const ShapesSwitcher = ({ title={t("toolBar.extraTools")} > {extraToolsIcon} + {app.props.aiEnabled !== false && ( +
    + AI +
    + )} setIsExtraToolsMenuOpen(false)} From a38e82f99902131982eae15bcaa16e406d3cbafa Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:22:34 +0100 Subject: [PATCH 084/112] feat: close dropdown on escape (#7750) --- .../dropdownMenu/DropdownMenu.test.tsx | 20 ++++++++++++++ .../dropdownMenu/DropdownMenuContent.tsx | 27 +++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx new file mode 100644 index 000000000..3aae1d0c7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx @@ -0,0 +1,20 @@ +import { Excalidraw } from "../../index"; +import { KEYS } from "../../keys"; +import { Keyboard } from "../../tests/helpers/ui"; +import { render, waitFor, getByTestId } from "../../tests/test-utils"; + +describe("Test ", () => { + it("should", async () => { + const { container } = await render(); + + expect(window.h.state.openMenu).toBe(null); + + getByTestId(container, "main-menu-trigger").click(); + expect(window.h.state.openMenu).toBe("canvas"); + + await waitFor(() => { + Keyboard.keyDown(KEYS.ESCAPE); + expect(window.h.state.openMenu).toBe(null); + }); + }); +}); diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index e266cecf4..374e5c05d 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -2,9 +2,12 @@ import { Island } from "../Island"; import { useDevice } from "../App"; import clsx from "clsx"; import Stack from "../Stack"; -import React, { useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { DropdownMenuContentPropsContext } from "./common"; import { useOutsideClick } from "../../hooks/useOutsideClick"; +import { KEYS } from "../../keys"; +import { EVENT } from "../../constants"; +import { useStable } from "../../hooks/useStable"; const MenuContent = ({ children, @@ -25,10 +28,30 @@ const MenuContent = ({ const device = useDevice(); const menuRef = useRef(null); + const callbacksRef = useStable({ onClickOutside }); + useOutsideClick(menuRef, () => { - onClickOutside?.(); + callbacksRef.onClickOutside?.(); }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === KEYS.ESCAPE) { + event.stopImmediatePropagation(); + callbacksRef.onClickOutside?.(); + } + }; + + document.addEventListener(EVENT.KEYDOWN, onKeyDown, { + // so that we can stop propagation of the event before it reaches + // event handlers that were bound before this one + capture: true, + }); + return () => { + document.removeEventListener(EVENT.KEYDOWN, onKeyDown); + }; + }, [callbacksRef]); + const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": device.editor.isMobile, }).trim(); From 68b1fdb20ebe09fdba8764fbd91b4b8e26d7d011 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:53:37 +0100 Subject: [PATCH 085/112] fix: add missing font metrics for Assistant (#7752) --- packages/excalidraw/element/textElement.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 102ed681c..630afd392 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -326,7 +326,8 @@ export const getVerticalOffset = ( fontSize: ExcalidrawTextElement["fontSize"], lineHeightPx: number, ) => { - const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily]; + const { unitsPerEm, ascender, descender } = + FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica]; const fontSizeEm = fontSize / unitsPerEm; const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; @@ -953,6 +954,11 @@ const FONT_METRICS = { ascender: 1977, descender: -480, }, + [FONT_FAMILY.Assistant]: { + unitsPerEm: 1000, + ascender: 1050, + descender: -500, + }, } as Record; export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { From 480572f89350fa6b596ebbfb2e2fbdd00c07a9cf Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 7 Mar 2024 15:54:36 +0000 Subject: [PATCH 086/112] fix: correcting Assistant metrics (#7758) * Changed Assistant metrics to the corrrect ones from OS/2 table * Adding more information about font metrics * Adding branded types to avoid future mistakes --- packages/excalidraw/CHANGELOG.md | 2 +- packages/excalidraw/element/textElement.ts | 54 +++++++++++++--------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index d88bae8ab..4984a9936 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -27,7 +27,7 @@ Please add the latest change on the top under the correct section. - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) -- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 630afd392..b54cc1c8e 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -31,7 +31,7 @@ import { getElementAbsoluteCoords } from "."; import { getSelectedElements } from "../scene"; import { isHittingElementNotConsideringBoundingBox } from "./collision"; -import { ExtractSetType } from "../utility-types"; +import { ExtractSetType, MakeBrand } from "../utility-types"; import { resetOriginalContainerCache, updateOriginalContainerCache, @@ -928,38 +928,50 @@ const DEFAULT_LINE_HEIGHT = { [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; -type FontMetrics = { - unitsPerEm: number; // head.unitsPerEm - ascender: number; // sTypoAscender - descender: number; // sTypoDescender -}; +/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */ +type sTypoAscender = number & MakeBrand<"sTypoAscender">; + +/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */ +type sTypoDescender = number & MakeBrand<"sTypoDescender">; + +/** head.unitsPerEm, usually either 1000 or 2048 */ +type unitsPerEm = number & MakeBrand<"unitsPerEm">; /** * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. - * For custom fonts, read these metrics on load and extend this object. + * For custom fonts, read these metrics from OS/2 table and extend this object. + * + * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first. */ -const FONT_METRICS = { +export const FONT_METRICS: Record< + number, + { + unitsPerEm: number; + ascender: sTypoAscender; + descender: sTypoDescender; + } +> = { [FONT_FAMILY.Virgil]: { - unitsPerEm: 1000, - ascender: 886, - descender: -374, + unitsPerEm: 1000 as unitsPerEm, + ascender: 886 as sTypoAscender, + descender: -374 as sTypoDescender, }, [FONT_FAMILY.Helvetica]: { - unitsPerEm: 2048, - ascender: 1577, - descender: -471, + unitsPerEm: 2048 as unitsPerEm, + ascender: 1577 as sTypoAscender, + descender: -471 as sTypoDescender, }, [FONT_FAMILY.Cascadia]: { - unitsPerEm: 2048, - ascender: 1977, - descender: -480, + unitsPerEm: 2048 as unitsPerEm, + ascender: 1977 as sTypoAscender, + descender: -480 as sTypoDescender, }, [FONT_FAMILY.Assistant]: { - unitsPerEm: 1000, - ascender: 1050, - descender: -500, + unitsPerEm: 1000 as unitsPerEm, + ascender: 1021 as sTypoAscender, + descender: -287 as sTypoDescender, }, -} as Record; +}; export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { if (fontFamily in DEFAULT_LINE_HEIGHT) { From 2382fad4f6a3a5b85b304b41ae88d0d235c55901 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 8 Mar 2024 22:29:19 +0100 Subject: [PATCH 087/112] feat: store library to IndexedDB & support storage adapters (#7655) --- excalidraw-app/App.tsx | 30 +- excalidraw-app/app_constants.ts | 6 +- excalidraw-app/data/LocalData.ts | 62 +++- excalidraw-app/data/localStorage.ts | 18 +- packages/excalidraw/CHANGELOG.md | 4 + packages/excalidraw/data/library.ts | 511 ++++++++++++++++++++++++--- packages/excalidraw/element/index.ts | 27 ++ packages/excalidraw/index.tsx | 4 +- packages/excalidraw/locales/en.json | 1 + packages/excalidraw/queue.test.ts | 62 ++++ packages/excalidraw/queue.ts | 45 +++ packages/excalidraw/types.ts | 24 +- packages/excalidraw/utility-types.ts | 3 + packages/excalidraw/utils.ts | 16 +- 14 files changed, 718 insertions(+), 95 deletions(-) create mode 100644 packages/excalidraw/queue.test.ts create mode 100644 packages/excalidraw/queue.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 7517bb379..1ab776aea 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -30,7 +30,6 @@ import { } from "../packages/excalidraw/index"; import { AppState, - LibraryItems, ExcalidrawImperativeAPI, BinaryFiles, ExcalidrawInitialDataState, @@ -64,7 +63,6 @@ import { loadScene, } from "./data"; import { - getLibraryItemsFromStorage, importFromLocalStorage, importUsernameFromLocalStorage, } from "./data/localStorage"; @@ -82,7 +80,11 @@ import { updateStaleImageStatuses } from "./data/FileManager"; import { newElementWith } from "../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks"; import { loadFilesFromFirebase } from "./data/firebase"; -import { LocalData } from "./data/LocalData"; +import { + LibraryIndexedDBAdapter, + LibraryLocalStorageMigrationAdapter, + LocalData, +} from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; import { reconcileElements } from "./collab/reconciliation"; @@ -315,7 +317,9 @@ const ExcalidrawWrapper = () => { useHandleLibrary({ excalidrawAPI, - getInitialLibraryItems: getLibraryItemsFromStorage, + adapter: LibraryIndexedDBAdapter, + // TODO maybe remove this in several months (shipped: 24-02-07) + migrationAdapter: LibraryLocalStorageMigrationAdapter, }); useEffect(() => { @@ -445,8 +449,12 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...localDataState, }); - excalidrawAPI.updateLibrary({ - libraryItems: getLibraryItemsFromStorage(), + LibraryIndexedDBAdapter.load().then((data) => { + if (data) { + excalidrawAPI.updateLibrary({ + libraryItems: data.libraryItems, + }); + } }); collabAPI?.setUsername(username || ""); } @@ -658,15 +666,6 @@ const ExcalidrawWrapper = () => { ); }; - const onLibraryChange = async (items: LibraryItems) => { - if (!items.length) { - localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); - return; - } - const serializedItems = JSON.stringify(items); - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); - }; - const isOffline = useAtomValue(isOfflineAtom); const onCollabDialogOpen = useCallback( @@ -742,7 +741,6 @@ const ExcalidrawWrapper = () => { renderCustomStats={renderCustomStats} detectScroll={false} handleKeyboardGlobally={true} - onLibraryChange={onLibraryChange} autoFocus={true} theme={theme} renderTopRightUI={(isMobile) => { diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 3402bf106..f4b56496d 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -39,10 +39,14 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", - LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_THEME: "excalidraw-theme", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", + + IDB_LIBRARY: "excalidraw-library", + + // do not use apart from migrations + __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", } as const; export const COOKIES = { diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a8a6c41b2..9d19e073b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -10,8 +10,18 @@ * (localStorage, indexedDB). */ -import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; +import { + createStore, + entries, + del, + getMany, + set, + setMany, + get, +} from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import { LibraryPersistedData } from "../../packages/excalidraw/data/library"; +import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { ExcalidrawElement, @@ -22,6 +32,7 @@ import { BinaryFileData, BinaryFiles, } from "../../packages/excalidraw/types"; +import { MaybePromise } from "../../packages/excalidraw/utility-types"; import { debounce } from "../../packages/excalidraw/utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { FileManager } from "./FileManager"; @@ -183,3 +194,52 @@ export class LocalData { }, }); } +export class LibraryIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = STORAGE_KEYS.IDB_LIBRARY; + /** library data store key */ + private static key = "libraryData"; + + private static store = createStore( + `${LibraryIndexedDBAdapter.idb_name}-db`, + `${LibraryIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise { + return set( + LibraryIndexedDBAdapter.key, + data, + LibraryIndexedDBAdapter.store, + ); + } +} + +/** LS Adapter used only for migrating LS library data + * to indexedDB */ +export class LibraryLocalStorageMigrationAdapter { + static load() { + const LSData = localStorage.getItem( + STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, + ); + if (LSData != null) { + const libraryItems: ImportedDataState["libraryItems"] = + JSON.parse(LSData); + if (libraryItems) { + return { libraryItems }; + } + } + return null; + } + static clear() { + localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); + } +} diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index ce4258f4e..0a6a16081 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -6,7 +6,6 @@ import { } from "../../packages/excalidraw/appState"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { STORAGE_KEYS } from "../app_constants"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; export const saveUsernameToLocalStorage = (username: string) => { try { @@ -88,28 +87,13 @@ export const getTotalStorageSize = () => { try { const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); - const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); const appStateSize = appState?.length || 0; const collabSize = collab?.length || 0; - const librarySize = library?.length || 0; - return appStateSize + collabSize + librarySize + getElementsStorageSize(); + return appStateSize + collabSize + getElementsStorageSize(); } catch (error: any) { console.error(error); return 0; } }; - -export const getLibraryItemsFromStorage = () => { - try { - const libraryItems: ImportedDataState["libraryItems"] = JSON.parse( - localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, - ); - - return libraryItems || []; - } catch (error) { - console.error(error); - return []; - } -}; diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 4984a9936..00d6ad527 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section. ### Features +- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 7b936efc1..525eecb57 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -4,6 +4,7 @@ import { LibraryItem, ExcalidrawImperativeAPI, LibraryItemsSource, + LibraryItems_anyVersion, } from "../types"; import { restoreLibraryItems } from "./restore"; import type App from "../components/App"; @@ -23,13 +24,72 @@ import { LIBRARY_SIDEBAR_TAB, } from "../constants"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; -import { cloneJSON } from "../utils"; +import { + arrayToMap, + cloneJSON, + preventUnload, + promiseTry, + resolvablePromise, +} from "../utils"; +import { MaybePromise } from "../utility-types"; +import { Emitter } from "../emitter"; +import { Queue } from "../queue"; +import { hashElementsVersion, hashString } from "../element"; + +type LibraryUpdate = { + /** deleted library items since last onLibraryChange event */ + deletedItems: Map; + /** newly added items in the library */ + addedItems: Map; +}; + +// an object so that we can later add more properties to it without breaking, +// such as schema version +export type LibraryPersistedData = { libraryItems: LibraryItems }; + +const onLibraryUpdateEmitter = new Emitter< + [update: LibraryUpdate, libraryItems: LibraryItems] +>(); + +export interface LibraryPersistenceAdapter { + /** + * Should load data that were previously saved into the database using the + * `save` method. Should throw if saving fails. + * + * Will be used internally in multiple places, such as during save to + * in order to reconcile changes with latest store data. + */ + load(metadata: { + /** + * Priority 1 indicates we're loading latest data with intent + * to reconcile with before save. + * Priority 2 indicates we're loading for read-only purposes, so + * host app can implement more aggressive caching strategy. + */ + priority: 1 | 2; + }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + /** Should persist to the database as is (do no change the data structure). */ + save(libraryData: LibraryPersistedData): MaybePromise; +} + +export interface LibraryMigrationAdapter { + /** + * loads data from legacy data source. Returns `null` if no data is + * to be migrated. + */ + load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + + /** clears entire storage afterwards */ + clear(): MaybePromise; +} export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; + /** indicates whether library is initialized with library items (has gone + * through at least one update). Used in UI. Specific to this atom only. */ isInitialized: boolean; libraryItems: LibraryItems; -}>({ status: "loaded", isInitialized: true, libraryItems: [] }); +}>({ status: "loaded", isInitialized: false, libraryItems: [] }); const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => cloneJSON(libraryItems); @@ -74,12 +134,45 @@ export const mergeLibraryItems = ( return [...newItems, ...localItems]; }; +/** + * Returns { deletedItems, addedItems } maps of all added and deleted items + * since last onLibraryChange event. + * + * Host apps are recommended to diff with the latest state they have. + */ +const createLibraryUpdate = ( + prevLibraryItems: LibraryItems, + nextLibraryItems: LibraryItems, +): LibraryUpdate => { + const nextItemsMap = arrayToMap(nextLibraryItems); + + const update: LibraryUpdate = { + deletedItems: new Map(), + addedItems: new Map(), + }; + + for (const item of prevLibraryItems) { + if (!nextItemsMap.has(item.id)) { + update.deletedItems.set(item.id, item); + } + } + + const prevItemsMap = arrayToMap(prevLibraryItems); + + for (const item of nextLibraryItems) { + if (!prevItemsMap.has(item.id)) { + update.addedItems.set(item.id, item); + } + } + + return update; +}; + class Library { /** latest libraryItems */ - private lastLibraryItems: LibraryItems = []; - /** indicates whether library is initialized with library items (has gone - * though at least one update) */ - private isInitialized = false; + private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ + private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); private app: App; @@ -95,21 +188,29 @@ class Library { private notifyListeners = () => { if (this.updateQueue.length > 0) { - jotaiStore.set(libraryItemsAtom, { + jotaiStore.set(libraryItemsAtom, (s) => ({ status: "loading", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, - }); + libraryItems: this.currLibraryItems, + isInitialized: s.isInitialized, + })); } else { - this.isInitialized = true; jotaiStore.set(libraryItemsAtom, { status: "loaded", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, + libraryItems: this.currLibraryItems, + isInitialized: true, }); try { - this.app.props.onLibraryChange?.( - cloneLibraryItems(this.lastLibraryItems), + const prevLibraryItems = this.prevLibraryItems; + this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + const nextLibraryItems = cloneLibraryItems(this.currLibraryItems); + + this.app.props.onLibraryChange?.(nextLibraryItems); + + // for internal use in `useHandleLibrary` hook + onLibraryUpdateEmitter.trigger( + createLibraryUpdate(prevLibraryItems, nextLibraryItems), + nextLibraryItems, ); } catch (error) { console.error(error); @@ -119,9 +220,8 @@ class Library { /** call on excalidraw instance unmount */ destroy = () => { - this.isInitialized = false; this.updateQueue = []; - this.lastLibraryItems = []; + this.currLibraryItems = []; jotaiStore.set(libraryItemSvgsCache, new Map()); // TODO uncomment after/if we make jotai store scoped to each excal instance // jotaiStore.set(libraryItemsAtom, { @@ -142,14 +242,14 @@ class Library { return new Promise(async (resolve) => { try { const libraryItems = await (this.getLastUpdateTask() || - this.lastLibraryItems); + this.currLibraryItems); if (this.updateQueue.length > 0) { resolve(this.getLatestLibrary()); } else { resolve(cloneLibraryItems(libraryItems)); } } catch (error) { - return resolve(this.lastLibraryItems); + return resolve(this.currLibraryItems); } }); }; @@ -181,7 +281,7 @@ class Library { try { const source = await (typeof libraryItems === "function" && !(libraryItems instanceof Blob) - ? libraryItems(this.lastLibraryItems) + ? libraryItems(this.currLibraryItems) : libraryItems); let nextItems; @@ -207,7 +307,7 @@ class Library { } if (merge) { - resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); + resolve(mergeLibraryItems(this.currLibraryItems, nextItems)); } else { resolve(nextItems); } @@ -244,12 +344,12 @@ class Library { await this.getLastUpdateTask(); if (typeof libraryItems === "function") { - libraryItems = libraryItems(this.lastLibraryItems); + libraryItems = libraryItems(this.currLibraryItems); } - this.lastLibraryItems = cloneLibraryItems(await libraryItems); + this.currLibraryItems = cloneLibraryItems(await libraryItems); - resolve(this.lastLibraryItems); + resolve(this.currLibraryItems); } catch (error: any) { reject(error); } @@ -257,7 +357,7 @@ class Library { .catch((error) => { if (error.name === "AbortError") { console.warn("Library update aborted by user"); - return this.lastLibraryItems; + return this.currLibraryItems; } throw error; }) @@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => { return libraryUrl ? { libraryUrl, idToken } : null; }; -export const useHandleLibrary = ({ - excalidrawAPI, - getInitialLibraryItems, -}: { - excalidrawAPI: ExcalidrawImperativeAPI | null; - getInitialLibraryItems?: () => LibraryItemsSource; -}) => { - const getInitialLibraryRef = useRef(getInitialLibraryItems); +class AdapterTransaction { + static queue = new Queue(); + + static async getLibraryItems( + adapter: LibraryPersistenceAdapter, + priority: 1 | 2, + _queue = true, + ): Promise { + const task = () => + new Promise(async (resolve, reject) => { + try { + const data = await adapter.load({ priority }); + resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + } catch (error: any) { + reject(error); + } + }); + + if (_queue) { + return AdapterTransaction.queue.push(task); + } + + return task(); + } + + static run = async ( + adapter: LibraryPersistenceAdapter, + fn: (transaction: AdapterTransaction) => Promise, + ) => { + const transaction = new AdapterTransaction(adapter); + return AdapterTransaction.queue.push(() => fn(transaction)); + }; + + // ------------------ + + private adapter: LibraryPersistenceAdapter; + + constructor(adapter: LibraryPersistenceAdapter) { + this.adapter = adapter; + } + + getLibraryItems(priority: 1 | 2) { + return AdapterTransaction.getLibraryItems(this.adapter, priority, false); + } +} + +let lastSavedLibraryItemsHash = 0; +let librarySaveCounter = 0; + +export const getLibraryItemsHash = (items: LibraryItems) => { + return hashString( + items + .map((item) => { + return `${item.id}:${hashElementsVersion(item.elements)}`; + }) + .sort() + .join(), + ); +}; + +const persistLibraryUpdate = async ( + adapter: LibraryPersistenceAdapter, + update: LibraryUpdate, +): Promise => { + try { + librarySaveCounter++; + + return await AdapterTransaction.run(adapter, async (transaction) => { + const nextLibraryItemsMap = arrayToMap( + await transaction.getLibraryItems(1), + ); + + for (const [id] of update.deletedItems) { + nextLibraryItemsMap.delete(id); + } + + const addedItems: LibraryItem[] = []; + + // we want to merge current library items with the ones stored in the + // DB so that we don't lose any elements that for some reason aren't + // in the current editor library, which could happen when: + // + // 1. we haven't received an update deleting some elements + // (in which case it's still better to keep them in the DB lest + // it was due to a different reason) + // 2. we keep a single DB for all active editors, but the editors' + // libraries aren't synced or there's a race conditions during + // syncing + // 3. some other race condition, e.g. during init where emit updates + // for partial updates (e.g. you install a 3rd party library and + // init from DB only after — we emit events for both updates) + for (const [id, item] of update.addedItems) { + if (nextLibraryItemsMap.has(id)) { + // replace item with latest version + // TODO we could prefer the newer item instead + nextLibraryItemsMap.set(id, item); + } else { + // we want to prepend the new items with the ones that are already + // in DB to preserve the ordering we do in editor (newly added + // items are added to the beginning) + addedItems.push(item); + } + } + + const nextLibraryItems = addedItems.concat( + Array.from(nextLibraryItemsMap.values()), + ); + + const version = getLibraryItemsHash(nextLibraryItems); + + if (version !== lastSavedLibraryItemsHash) { + await adapter.save({ libraryItems: nextLibraryItems }); + } + + lastSavedLibraryItemsHash = version; + + return nextLibraryItems; + }); + } finally { + librarySaveCounter--; + } +}; + +export const useHandleLibrary = ( + opts: { + excalidrawAPI: ExcalidrawImperativeAPI | null; + } & ( + | { + /** @deprecated we recommend using `opts.adapter` instead */ + getInitialLibraryItems?: () => MaybePromise; + } + | { + adapter: LibraryPersistenceAdapter; + /** + * Adapter that takes care of loading data from legacy data store. + * Supply this if you want to migrate data on initial load from legacy + * data store. + * + * Can be a different LibraryPersistenceAdapter. + */ + migrationAdapter?: LibraryMigrationAdapter; + } + ), +) => { + const { excalidrawAPI } = opts; + + const optsRef = useRef(opts); + optsRef.current = opts; + + const isLibraryLoadedRef = useRef(false); useEffect(() => { if (!excalidrawAPI) { return; } + // reset on editor remount (excalidrawAPI changed) + isLibraryLoadedRef.current = false; + const importLibraryFromURL = async ({ libraryUrl, idToken, @@ -463,23 +708,209 @@ export const useHandleLibrary = ({ }; // ------------------------------------------------------------------------- - // ------ init load -------------------------------------------------------- - if (getInitialLibraryRef.current) { - excalidrawAPI.updateLibrary({ - libraryItems: getInitialLibraryRef.current(), - }); - } + // ---------------------------------- init --------------------------------- + // ------------------------------------------------------------------------- const libraryUrlTokens = parseLibraryTokensFromUrl(); if (libraryUrlTokens) { importLibraryFromURL(libraryUrlTokens); } + + // ------ (A) init load (legacy) ------------------------------------------- + if ( + "getInitialLibraryItems" in optsRef.current && + optsRef.current.getInitialLibraryItems + ) { + console.warn( + "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.", + ); + + Promise.resolve(optsRef.current.getInitialLibraryItems()) + .then((libraryItems) => { + excalidrawAPI.updateLibrary({ + libraryItems, + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }); + }) + .catch((error: any) => { + console.error( + `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`, + ); + }); + } + + // ------------------------------------------------------------------------- // --------------------------------------------------------- init load ----- + // ------------------------------------------------------------------------- + + // ------ (B) data source adapter ------------------------------------------ + + if ("adapter" in optsRef.current && optsRef.current.adapter) { + const adapter = optsRef.current.adapter; + const migrationAdapter = optsRef.current.migrationAdapter; + + const initDataPromise = resolvablePromise(); + + // migrate from old data source if needed + // (note, if `migrate` function is defined, we always migrate even + // if the data has already been migrated. In that case it'll be a no-op, + // though with several unnecessary steps — we will still load latest + // DB data during the `persistLibraryChange()` step) + // ----------------------------------------------------------------------- + if (migrationAdapter) { + initDataPromise.resolve( + promiseTry(migrationAdapter.load) + .then(async (libraryData) => { + try { + // if no library data to migrate, assume no migration needed + // and skip persisting to new data store, as well as well + // clearing the old store via `migrationAdapter.clear()` + if (!libraryData) { + return AdapterTransaction.getLibraryItems(adapter, 2); + } + + // we don't queue this operation because it's running inside + // a promise that's running inside Library update queue itself + const nextItems = await persistLibraryUpdate( + adapter, + createLibraryUpdate( + [], + restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ), + ), + ); + try { + await migrationAdapter.clear(); + } catch (error: any) { + console.error( + `couldn't delete legacy library data: ${error.message}`, + ); + } + // migration suceeded, load migrated data + return nextItems; + } catch (error: any) { + console.error( + `couldn't migrate legacy library data: ${error.message}`, + ); + // migration failed, load empty library + return []; + } + }) + // errors caught during `migrationAdapter.load()` + .catch((error: any) => { + console.error(`error during library migration: ${error.message}`); + // as a default, load latest library from current data source + return AdapterTransaction.getLibraryItems(adapter, 2); + }), + ); + } else { + initDataPromise.resolve( + promiseTry(AdapterTransaction.getLibraryItems, adapter, 2), + ); + } + + // load initial (or migrated) library + excalidrawAPI + .updateLibrary({ + libraryItems: initDataPromise.then((libraryItems) => { + const _libraryItems = libraryItems || []; + lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems); + return _libraryItems; + }), + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }) + .finally(() => { + isLibraryLoadedRef.current = true; + }); + } + // ---------------------------------------------- data source datapter ----- window.addEventListener(EVENT.HASHCHANGE, onHashChange); return () => { window.removeEventListener(EVENT.HASHCHANGE, onHashChange); }; - }, [excalidrawAPI]); + }, [ + // important this useEffect only depends on excalidrawAPI so it only reruns + // on editor remounts (the excalidrawAPI changes) + excalidrawAPI, + ]); + + // This effect is run without excalidrawAPI dependency so that host apps + // can run this hook outside of an active editor instance and the library + // update queue/loop survives editor remounts + // + // This effect is still only meant to be run if host apps supply an persitence + // adapter. If we don't have access to it, it the update listener doesn't + // do anything. + useEffect( + () => { + // on update, merge with current library items and persist + // ----------------------------------------------------------------------- + const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on( + async (update, nextLibraryItems) => { + const isLoaded = isLibraryLoadedRef.current; + // we want to operate with the latest adapter, but we don't want this + // effect to rerun on every adapter change in case host apps' adapter + // isn't stable + const adapter = + ("adapter" in optsRef.current && optsRef.current.adapter) || null; + try { + if (adapter) { + if ( + // if nextLibraryItems hash identical to previously saved hash, + // exit early, even if actual upstream state ends up being + // different (e.g. has more data than we have locally), as it'd + // be low-impact scenario. + lastSavedLibraryItemsHash !== + getLibraryItemsHash(nextLibraryItems) + ) { + await persistLibraryUpdate(adapter, update); + } + } + } catch (error: any) { + console.error( + `couldn't persist library update: ${error.message}`, + update, + ); + + // currently we only show error if an editor is loaded + if (isLoaded && optsRef.current.excalidrawAPI) { + optsRef.current.excalidrawAPI.updateScene({ + appState: { + errorMessage: t("errors.saveLibraryError"), + }, + }); + } + } + }, + ); + + const onUnload = (event: Event) => { + if (librarySaveCounter) { + preventUnload(event); + } + }; + + window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload); + + return () => { + window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload); + unsubOnLibraryUpdate(); + lastSavedLibraryItemsHash = 0; + librarySaveCounter = 0; + }; + }, + [ + // this effect must not have any deps so it doesn't rerun + ], + ); }; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 093ef4829..7e9769d83 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -60,9 +60,36 @@ export { } from "./sizeHelpers"; export { showSelectedShapeActions } from "./showSelectedShapeActions"; +/** + * @deprecated unsafe, use hashElementsVersion instead + */ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) => elements.reduce((acc, el) => acc + el.version, 0); +/** + * Hashes elements' versionNonce (using djb2 algo). Order of elements matters. + */ +export const hashElementsVersion = ( + elements: readonly ExcalidrawElement[], +): number => { + let hash = 5381; + for (let i = 0; i < elements.length; i++) { + hash = (hash << 5) + hash + elements[i].versionNonce; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + +// string hash function (using djb2). Not cryptographically secure, use only +// for versioning and such. +export const hashString = (s: string): number => { + let hash: number = 5381; + for (let i = 0; i < s.length; i++) { + const char: number = s.charCodeAt(i); + hash = (hash << 5) + hash + char; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + export const getVisibleElements = (elements: readonly ExcalidrawElement[]) => elements.filter( (el) => !el.isDeleted && !isInvisiblySmallElement(el), diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 2dae37c6b..66eb91044 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -207,6 +207,8 @@ Excalidraw.displayName = "Excalidraw"; export { getSceneVersion, + hashElementsVersion, + hashString, isInvisiblySmallElement, getNonDeletedElements, } from "./element"; @@ -232,7 +234,7 @@ export { loadLibraryFromBlob, } from "./data/blob"; export { getFreeDrawSvgPath } from "./renderer/renderElement"; -export { mergeLibraryItems } from "./data/library"; +export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 924d5c8ae..ac9108a32 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -216,6 +216,7 @@ "failedToFetchImage": "Failed to fetch image.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", + "saveLibraryError": "Couldn't save library to storage. Please save your library to a file locally to make sure you don't lose changes.", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", "imageToolNotSupported": "Images are disabled.", diff --git a/packages/excalidraw/queue.test.ts b/packages/excalidraw/queue.test.ts new file mode 100644 index 000000000..66a10583e --- /dev/null +++ b/packages/excalidraw/queue.test.ts @@ -0,0 +1,62 @@ +import { Queue } from "./queue"; + +describe("Queue", () => { + const calls: any[] = []; + + const createJobFactory = + ( + // for purpose of this test, Error object will become a rejection value + resolutionOrRejectionValue: T, + ms = 1, + ) => + () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (resolutionOrRejectionValue instanceof Error) { + reject(resolutionOrRejectionValue); + } else { + resolve(resolutionOrRejectionValue); + } + }, ms); + }).then((x) => { + calls.push(x); + return x; + }); + }; + + beforeEach(() => { + calls.length = 0; + }); + + it("should await and resolve values in order of enqueueing", async () => { + const queue = new Queue(); + + const p1 = queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory("B")); + const p3 = queue.push(createJobFactory("C")); + + expect(await p3).toBe("C"); + expect(await p2).toBe("B"); + expect(await p1).toBe("A"); + + expect(calls).toEqual(["A", "B", "C"]); + }); + + it("should reject a job if it throws, and not affect other jobs", async () => { + const queue = new Queue(); + + const err = new Error("B"); + + queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory(err)); + const p3 = queue.push(createJobFactory("C")); + + const p2err = p2.catch((err) => err); + + await p3; + + expect(await p2err).toBe(err); + + expect(calls).toEqual(["A", "C"]); + }); +}); diff --git a/packages/excalidraw/queue.ts b/packages/excalidraw/queue.ts new file mode 100644 index 000000000..408e945ba --- /dev/null +++ b/packages/excalidraw/queue.ts @@ -0,0 +1,45 @@ +import { MaybePromise } from "./utility-types"; +import { promiseTry, ResolvablePromise, resolvablePromise } from "./utils"; + +type Job = (...args: TArgs) => MaybePromise; + +type QueueJob = { + jobFactory: Job; + promise: ResolvablePromise; + args: TArgs; +}; + +export class Queue { + private jobs: QueueJob[] = []; + private running = false; + + private tick() { + if (this.running) { + return; + } + const job = this.jobs.shift(); + if (job) { + this.running = true; + job.promise.resolve( + promiseTry(job.jobFactory, ...job.args).finally(() => { + this.running = false; + this.tick(); + }), + ); + } else { + this.running = false; + } + } + + push( + jobFactory: Job, + ...args: TArgs + ): Promise { + const promise = resolvablePromise(); + this.jobs.push({ jobFactory, promise, args }); + + this.tick(); + + return promise; + } +} diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 1eaa04449..fefb82c2c 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -38,7 +38,7 @@ import type { FileSystemHandle } from "./data/filesystem"; import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import { ContextMenuItems } from "./components/ContextMenu"; import { SnapLine } from "./snapping"; -import { Merge, ValueOf } from "./utility-types"; +import { Merge, MaybePromise, ValueOf } from "./utility-types"; export type Point = Readonly; @@ -380,21 +380,14 @@ export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1; export type LibraryItemsSource = | (( currentLibraryItems: LibraryItems, - ) => - | Blob - | LibraryItems_anyVersion - | Promise) - | Blob - | LibraryItems_anyVersion - | Promise; + ) => MaybePromise) + | MaybePromise; // ----------------------------------------------------------------------------- export type ExcalidrawInitialDataState = Merge< ImportedDataState, { - libraryItems?: - | Required["libraryItems"] - | Promise["libraryItems"]>; + libraryItems?: MaybePromise["libraryItems"]>; } >; @@ -409,10 +402,7 @@ export interface ExcalidrawProps { appState: AppState, files: BinaryFiles, ) => void; - initialData?: - | ExcalidrawInitialDataState - | null - | Promise; + initialData?: MaybePromise; excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void; isCollaborating?: boolean; onPointerUpdate?: (payload: { @@ -643,7 +633,7 @@ export type PointerDownState = Readonly<{ export type UnsubscribeCallback = () => void; -export type ExcalidrawImperativeAPI = { +export interface ExcalidrawImperativeAPI { updateScene: InstanceType["updateScene"]; updateLibrary: InstanceType["updateLibrary"]; resetScene: InstanceType["resetScene"]; @@ -700,7 +690,7 @@ export type ExcalidrawImperativeAPI = { onUserFollow: ( callback: (payload: OnUserFollowedPayload) => void, ) => UnsubscribeCallback; -}; +} export type Device = Readonly<{ viewport: { diff --git a/packages/excalidraw/utility-types.ts b/packages/excalidraw/utility-types.ts index 576769634..f7872393e 100644 --- a/packages/excalidraw/utility-types.ts +++ b/packages/excalidraw/utility-types.ts @@ -62,3 +62,6 @@ export type MakeBrand = { /** @private using ~ to sort last in intellisense */ [K in `~brand~${T}`]: T; }; + +/** Maybe just promise or already fulfilled one! */ +export type MaybePromise = T | Promise; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 525652e6b..d27445dfa 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -14,7 +14,7 @@ import { UnsubscribeCallback, Zoom, } from "./types"; -import { ResolutionType } from "./utility-types"; +import { MaybePromise, ResolutionType } from "./utility-types"; let mockDateTime: string | null = null; @@ -538,7 +538,9 @@ export const isTransparent = (color: string) => { }; export type ResolvablePromise = Promise & { - resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; + resolve: [T] extends [undefined] + ? (value?: MaybePromise>) => void + : (value: MaybePromise>) => void; reject: (error: Error) => void; }; export const resolvablePromise = () => { @@ -1090,3 +1092,13 @@ export const toBrandedType = ( }; // ----------------------------------------------------------------------------- + +// Promise.try, adapted from https://github.com/sindresorhus/p-try +export const promiseTry = async ( + fn: (...args: TArgs) => PromiseLike | TValue, + ...args: TArgs +): Promise => { + return new Promise((resolve) => { + resolve(fn(...args)); + }); +}; From 6a385d6663cc8a47ee8e31cf593da8cf4a171953 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:40:51 +0100 Subject: [PATCH 088/112] feat: change LibraryPersistenceAdapter `load()` `source` -> `priority` to clarify the semantics --- excalidraw-app/App.tsx | 2 +- packages/excalidraw/data/library.ts | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 1ab776aea..703599634 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -318,7 +318,7 @@ const ExcalidrawWrapper = () => { useHandleLibrary({ excalidrawAPI, adapter: LibraryIndexedDBAdapter, - // TODO maybe remove this in several months (shipped: 24-02-07) + // TODO maybe remove this in several months (shipped: 24-03-11) migrationAdapter: LibraryLocalStorageMigrationAdapter, }); diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 525eecb57..b2170b29a 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -51,6 +51,8 @@ const onLibraryUpdateEmitter = new Emitter< [update: LibraryUpdate, libraryItems: LibraryItems] >(); +export type LibraryAdatapterSource = "load" | "save"; + export interface LibraryPersistenceAdapter { /** * Should load data that were previously saved into the database using the @@ -61,12 +63,10 @@ export interface LibraryPersistenceAdapter { */ load(metadata: { /** - * Priority 1 indicates we're loading latest data with intent - * to reconcile with before save. - * Priority 2 indicates we're loading for read-only purposes, so - * host app can implement more aggressive caching strategy. + * Indicates whether we're loading data for save purposes, or reading + * purposes, in which case host app can implement more aggressive caching. */ - priority: 1 | 2; + source: LibraryAdatapterSource; }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; /** Should persist to the database as is (do no change the data structure). */ save(libraryData: LibraryPersistedData): MaybePromise; @@ -487,13 +487,13 @@ class AdapterTransaction { static async getLibraryItems( adapter: LibraryPersistenceAdapter, - priority: 1 | 2, + source: LibraryAdatapterSource, _queue = true, ): Promise { const task = () => new Promise(async (resolve, reject) => { try { - const data = await adapter.load({ priority }); + const data = await adapter.load({ source }); resolve(restoreLibraryItems(data?.libraryItems || [], "published")); } catch (error: any) { reject(error); @@ -523,8 +523,8 @@ class AdapterTransaction { this.adapter = adapter; } - getLibraryItems(priority: 1 | 2) { - return AdapterTransaction.getLibraryItems(this.adapter, priority, false); + getLibraryItems(source: LibraryAdatapterSource) { + return AdapterTransaction.getLibraryItems(this.adapter, source, false); } } @@ -551,7 +551,7 @@ const persistLibraryUpdate = async ( return await AdapterTransaction.run(adapter, async (transaction) => { const nextLibraryItemsMap = arrayToMap( - await transaction.getLibraryItems(1), + await transaction.getLibraryItems("save"), ); for (const [id] of update.deletedItems) { @@ -770,7 +770,7 @@ export const useHandleLibrary = ( // and skip persisting to new data store, as well as well // clearing the old store via `migrationAdapter.clear()` if (!libraryData) { - return AdapterTransaction.getLibraryItems(adapter, 2); + return AdapterTransaction.getLibraryItems(adapter, "load"); } // we don't queue this operation because it's running inside @@ -806,12 +806,12 @@ export const useHandleLibrary = ( .catch((error: any) => { console.error(`error during library migration: ${error.message}`); // as a default, load latest library from current data source - return AdapterTransaction.getLibraryItems(adapter, 2); + return AdapterTransaction.getLibraryItems(adapter, "load"); }), ); } else { initDataPromise.resolve( - promiseTry(AdapterTransaction.getLibraryItems, adapter, 2), + promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), ); } From b7babe554b16201f55d2636f365e9161805c47ce Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:57:01 +0100 Subject: [PATCH 089/112] feat: load old library if migration fails --- packages/excalidraw/data/library.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index b2170b29a..5e1af6c22 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -765,6 +765,7 @@ export const useHandleLibrary = ( initDataPromise.resolve( promiseTry(migrationAdapter.load) .then(async (libraryData) => { + let restoredData: LibraryItems | null = null; try { // if no library data to migrate, assume no migration needed // and skip persisting to new data store, as well as well @@ -773,17 +774,16 @@ export const useHandleLibrary = ( return AdapterTransaction.getLibraryItems(adapter, "load"); } + restoredData = restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ); + // we don't queue this operation because it's running inside // a promise that's running inside Library update queue itself const nextItems = await persistLibraryUpdate( adapter, - createLibraryUpdate( - [], - restoreLibraryItems( - libraryData.libraryItems || [], - "published", - ), - ), + createLibraryUpdate([], restoredData), ); try { await migrationAdapter.clear(); @@ -798,8 +798,8 @@ export const useHandleLibrary = ( console.error( `couldn't migrate legacy library data: ${error.message}`, ); - // migration failed, load empty library - return []; + // migration failed, load data from previous store, if any + return restoredData; } }) // errors caught during `migrationAdapter.load()` From 068895db0eed082505788a2db0c6d63664e857df Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:20:07 +0100 Subject: [PATCH 090/112] feat: expose more collaborator status icons (#7777) --- excalidraw-app/index.scss | 2 +- .../excalidraw/actions/actionNavigate.tsx | 92 +++++-- packages/excalidraw/clients.ts | 229 +++++++++++++++++- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/components/Avatar.tsx | 15 +- packages/excalidraw/components/LayerUI.scss | 7 + packages/excalidraw/components/UserList.scss | 108 +++++++-- packages/excalidraw/components/UserList.tsx | 228 ++++++++++------- .../components/canvases/InteractiveCanvas.tsx | 52 ++-- packages/excalidraw/components/icons.tsx | 25 +- packages/excalidraw/constants.ts | 8 + packages/excalidraw/css/variables.module.scss | 14 +- packages/excalidraw/laser-trails.ts | 2 +- packages/excalidraw/locales/en.json | 5 +- .../excalidraw/renderer/interactiveScene.ts | 168 ++----------- packages/excalidraw/scene/types.ts | 13 +- packages/excalidraw/types.ts | 7 +- packages/excalidraw/utils.ts | 8 + 18 files changed, 652 insertions(+), 335 deletions(-) diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index 021442753..24741b062 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -8,7 +8,7 @@ .top-right-ui { display: flex; justify-content: center; - align-items: center; + align-items: flex-start; } .footer-center { diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index ea65584fe..5c60a029d 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,10 +1,15 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { GoToCollaboratorComponentProps } from "../components/UserList"; -import { eyeIcon } from "../components/icons"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; import { t } from "../i18n"; import { Collaborator } from "../types"; import { register } from "./register"; +import clsx from "clsx"; export const actionGoToCollaborator = register({ name: "goToCollaborator", @@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ updateData, data, appState }) => { - const { clientId, collaborator, withName, isBeingFollowed } = + const { socketId, collaborator, withName, isBeingFollowed } = data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId, collaborator); + + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( +
    +
    +
    +
    +
    + ) : collaborator.isMuted ? ( +
    + {microphoneMutedIcon} +
    + ) : ( +
    {microphoneIcon}
    + ) + ) : null; return withName ? (
    updateData(collaborator)} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} + className={statusClassNames} />
    {collaborator.username}
    -
    - {eyeIcon} +
    + {isBeingFollowed && ( +
    + {eyeIcon} +
    + )} + {statusIconJSX}
    ) : ( - { - updateData(collaborator); - }} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} - /> +
    + { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
    + {statusIconJSX} +
    + )} +
    ); }, }); diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..439080bd5 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -1,3 +1,18 @@ +import { + COLOR_CHARCOAL_BLACK, + COLOR_VOICE_CALL, + COLOR_WHITE, + THEME, +} from "./constants"; +import { roundRect } from "./renderer/roundRect"; +import { InteractiveCanvasRenderConfig } from "./scene/types"; +import { + Collaborator, + InteractiveCanvasAppState, + SocketId, + UserIdleState, +} from "./types"; + function hashToInteger(id: string) { let hash = 0; if (id.length === 0) { @@ -11,14 +26,12 @@ function hashToInteger(id: string) { } export const getClientColor = ( - /** - * any uniquely identifying key, such as user id or socket id - */ - id: string, + socketId: SocketId, + collaborator: Collaborator | undefined, ) => { // to get more even distribution in case `id` is not uniformly distributed to // begin with, we hash it - const hash = Math.abs(hashToInteger(id)); + const hash = Math.abs(hashToInteger(collaborator?.id || socketId)); // we want to get a multiple of 10 number in the range of 0-360 (in other // words a hue value of step size 10). There are 37 such values including 0. const hue = (hash % 37) * 10; @@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => { firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" ).toUpperCase(); }; + +export const renderRemoteCursors = ({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, +}: { + context: CanvasRenderingContext2D; + renderConfig: InteractiveCanvasRenderConfig; + appState: InteractiveCanvasAppState; + normalizedWidth: number; + normalizedHeight: number; +}) => { + // Paint remote pointers + for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) { + let { x, y } = pointer; + + const collaborator = appState.collaborators.get(socketId); + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 11; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedWidth - width || + y < 0 || + y > normalizedHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedHeight - height); + + const background = getClientColor(socketId, collaborator); + + context.save(); + context.strokeStyle = background; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates.get(socketId); + const isInactive = + isOutOfBounds || + userState === UserIdleState.IDLE || + userState === UserIdleState.AWAY; + + if (isInactive) { + context.globalAlpha = 0.3; + } + + if (renderConfig.remotePointerButton.get(socketId) === "down") { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = background; + context.stroke(); + context.closePath(); + } + + // TODO remove the dark theme color after we stop inverting canvas colors + const IS_SPEAKING_COLOR = + appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL; + + const isSpeaking = collaborator?.isSpeaking; + + if (isSpeaking) { + // cursor outline for currently speaking user + context.fillStyle = IS_SPEAKING_COLOR; + context.strokeStyle = IS_SPEAKING_COLOR; + context.lineWidth = 10; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + } + + // Background (white outline) for arrow + context.fillStyle = COLOR_WHITE; + context.strokeStyle = COLOR_WHITE; + context.lineWidth = 6; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + + // Arrow + context.fillStyle = background; + context.strokeStyle = background; + context.lineWidth = 2; + context.lineJoin = "round"; + context.beginPath(); + if (isInactive) { + context.moveTo(x - 1, y - 1); + context.lineTo(x - 1, y + 15); + context.lineTo(x + 5, y + 10); + context.lineTo(x + 12, y + 9); + context.closePath(); + context.fill(); + } else { + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.fill(); + context.stroke(); + } + + const username = renderConfig.remotePointerUsernames.get(socketId) || ""; + + if (!isOutOfBounds && username) { + context.font = "600 12px sans-serif"; // font has to be set before context.measureText() + + const offsetX = (isSpeaking ? x + 0 : x) + width / 2; + const offsetY = (isSpeaking ? y + 0 : y) + height + 2; + const paddingHorizontal = 5; + const paddingVertical = 3; + const measure = context.measureText(username); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + const finalHeight = Math.max(measureHeight, 12); + + const boxX = offsetX - 1; + const boxY = offsetY - 1; + const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; + const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; + if (context.roundRect) { + context.beginPath(); + context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); + context.fillStyle = background; + context.fill(); + context.strokeStyle = COLOR_WHITE; + context.stroke(); + + if (isSpeaking) { + context.beginPath(); + context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8); + context.strokeStyle = IS_SPEAKING_COLOR; + context.stroke(); + } + } else { + roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE); + } + context.fillStyle = COLOR_CHARCOAL_BLACK; + + context.fillText( + username, + offsetX + paddingHorizontal + 1, + offsetY + + paddingVertical + + measure.actualBoundingBoxAscent + + Math.floor((finalHeight - measureHeight) / 2) + + 2, + ); + + // draw three vertical bars signalling someone is speaking + if (isSpeaking) { + context.fillStyle = IS_SPEAKING_COLOR; + const barheight = 8; + const margin = 8; + const gap = 5; + context.fillRect( + boxX + boxWidth + margin, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + context.fillRect( + boxX + boxWidth + margin + gap, + boxY + (boxHeight / 2 - (barheight * 2) / 2), + 2, + barheight * 2, + ); + context.fillRect( + boxX + boxWidth + margin + gap * 2, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + } + } + + context.restore(); + context.closePath(); + } +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97ce14662..b02d919d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -89,6 +89,7 @@ import { TOOL_TYPE, EDITOR_LS_KEYS, isIOS, + supportsResizeObserver, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () => export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); -const supportsResizeObserver = - typeof window !== "undefined" && "ResizeObserver" in window; - let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let isHoldingSpace: boolean = false; diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index b7b1bf962..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -9,8 +9,7 @@ type AvatarProps = { color: string; name: string; src?: string; - isBeingFollowed?: boolean; - isCurrentUser: boolean; + className?: string; }; export const Avatar = ({ @@ -18,22 +17,14 @@ export const Avatar = ({ onClick, name, src, - isBeingFollowed, - isCurrentUser, + className, }: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
    +
    {loadImg ? ( * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/UserList.scss b/packages/excalidraw/components/UserList.scss index fceb1e7c4..86c3179ad 100644 --- a/packages/excalidraw/components/UserList.scss +++ b/packages/excalidraw/components/UserList.scss @@ -1,16 +1,25 @@ @import "../css/variables.module"; .excalidraw { + --avatar-size: 1.75rem; + --avatarList-gap: 0.625rem; + --userList-padding: var(--space-factor); + + .UserList-wrapper { + display: flex; + width: 100%; + justify-content: flex-end; + pointer-events: none !important; + } + .UserList { pointer-events: none; - /*github corner*/ - padding: var(--space-factor) var(--space-factor) var(--space-factor) - var(--space-factor); + padding: var(--userList-padding); display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; - gap: 0.625rem; + gap: var(--avatarList-gap); &:empty { display: none; @@ -18,15 +27,16 @@ box-sizing: border-box; - // can fit max 4 avatars (3 avatars + show more) in a column - max-height: 120px; + --max-size: calc( + var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) * + (var(--max-avatars, 2) - 1) + var(--userList-padding) * 2 + ); - // can fit max 4 avatars (3 avatars + show more) when there's enough space - max-width: 120px; + // max width & height set to fix the max-avatars + max-height: var(--max-size); + max-width: var(--max-size); // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^ - - overflow: hidden; } .UserList > * { @@ -45,10 +55,11 @@ @include avatarStyles; background-color: var(--color-gray-20); border: 0 !important; - font-size: 0.5rem; + font-size: 0.625rem; font-weight: 400; flex-shrink: 0; color: var(--color-gray-100); + font-weight: bold; } .UserList__collaborator-name { @@ -57,13 +68,82 @@ white-space: nowrap; } - .UserList__collaborator-follow-status-icon { + .UserList__collaborator--avatar-only { + position: relative; + display: flex; + flex: 0 0 auto; + .UserList__collaborator-status-icon { + --size: 14px; + position: absolute; + display: flex; + flex: 0 0 auto; + bottom: -0.25rem; + right: -0.25rem; + width: var(--size); + height: var(--size); + svg { + flex: 0 0 auto; + width: var(--size); + height: var(--size); + } + } + } + + .UserList__collaborator-status-icons { margin-left: auto; flex: 0 0 auto; - width: 1rem; + min-width: 2.25rem; + gap: 0.25rem; + justify-content: flex-end; display: flex; } + .UserList__collaborator.is-muted + .UserList__collaborator-status-icon-microphone-muted { + color: var(--color-danger); + filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5)); + } + + .UserList__collaborator-status-icon-speaking-indicator { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + width: 1rem; + padding: 0 3px; + box-sizing: border-box; + + div { + width: 0.125rem; + height: 0.4rem; + // keep this in sync with constants.ts + background-color: #a2f1a6; + } + + div:nth-of-type(1) { + animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite; + } + + div:nth-of-type(2) { + animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite; + } + + div:nth-of-type(3) { + animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite; + } + } + + @keyframes speaking-indicator-anim { + 0%, + 100% { + transform: scaleY(1); + } + + 50% { + transform: scaleY(2); + } + } + --userlist-hint-bg-color: var(--color-gray-10); --userlist-hint-heading-color: var(--color-gray-80); --userlist-hint-text-color: var(--color-gray-60); @@ -80,7 +160,7 @@ position: static; top: auto; margin-top: 0; - max-height: 12rem; + max-height: 50vh; overflow-y: auto; padding: 0.25rem 0.5rem; border-top: 1px solid var(--userlist-collaborators-border-color); diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index ba01b52dc..ced759333 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -1,6 +1,6 @@ import "./UserList.scss"; -import React from "react"; +import React, { useLayoutEffect } from "react"; import clsx from "clsx"; import { Collaborator, SocketId } from "../types"; import { Tooltip } from "./Tooltip"; @@ -12,9 +12,11 @@ import { Island } from "./Island"; import { searchIcon } from "./icons"; import { t } from "../i18n"; import { isShallowEqual } from "../utils"; +import { supportsResizeObserver } from "../constants"; +import { MarkRequired } from "../utility-types"; export type GoToCollaboratorComponentProps = { - clientId: ClientId; + socketId: SocketId; collaborator: Collaborator; withName: boolean; isBeingFollowed: boolean; @@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = { /** collaborator user id or socket id (fallback) */ type ClientId = string & { _brand: "UserId" }; -const FIRST_N_AVATARS = 3; +const DEFAULT_MAX_AVATARS = 4; const SHOW_COLLABORATORS_FILTER_AT = 8; const ConditionalTooltipWrapper = ({ shouldWrap, children, - clientId, username, }: { shouldWrap: boolean; children: React.ReactNode; username?: string | null; - clientId: ClientId; }) => shouldWrap ? ( - - {children} - + {children} ) : ( - {children} + {children} ); const renderCollaborator = ({ actionManager, collaborator, - clientId, + socketId, withName = false, shouldWrapWithTooltip = false, isBeingFollowed, }: { actionManager: ActionManager; collaborator: Collaborator; - clientId: ClientId; + socketId: SocketId; withName?: boolean; shouldWrapWithTooltip?: boolean; isBeingFollowed: boolean; }) => { const data: GoToCollaboratorComponentProps = { - clientId, + socketId, collaborator, withName, isBeingFollowed, @@ -70,8 +68,7 @@ const renderCollaborator = ({ return ( @@ -82,7 +79,13 @@ const renderCollaborator = ({ type UserListUserObject = Pick< Collaborator, - "avatarUrl" | "id" | "socketId" | "username" + | "avatarUrl" + | "id" + | "socketId" + | "username" + | "isInCall" + | "isSpeaking" + | "isMuted" >; type UserListProps = { @@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [ "id", "socketId", "username", + "isInCall", + "isSpeaking", + "isMuted", ] as const; export const UserList = React.memo( ({ className, mobile, collaborators, userToFollow }: UserListProps) => { const actionManager = useExcalidrawActionManager(); - const uniqueCollaboratorsMap = new Map(); + const uniqueCollaboratorsMap = new Map< + ClientId, + MarkRequired + >(); collaborators.forEach((collaborator, socketId) => { const userId = (collaborator.id || socketId) as ClientId; @@ -114,115 +123,147 @@ export const UserList = React.memo( ); }); - const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter( - ([_, collaborator]) => collaborator.username?.trim(), - ); + const uniqueCollaboratorsArray = Array.from( + uniqueCollaboratorsMap.values(), + ).filter((collaborator) => collaborator.username?.trim()); const [searchTerm, setSearchTerm] = React.useState(""); - if (uniqueCollaboratorsArray.length === 0) { - return null; - } + const userListWrapper = React.useRef(null); + + useLayoutEffect(() => { + if (userListWrapper.current) { + const updateMaxAvatars = (width: number) => { + const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38))); + setMaxAvatars(maxAvatars); + }; + + updateMaxAvatars(userListWrapper.current.clientWidth); + + if (!supportsResizeObserver) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + updateMaxAvatars(width); + } + }); + + resizeObserver.observe(userListWrapper.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS); const searchTermNormalized = searchTerm.trim().toLowerCase(); const filteredCollaborators = searchTermNormalized - ? uniqueCollaboratorsArray.filter(([, collaborator]) => + ? uniqueCollaboratorsArray.filter((collaborator) => collaborator.username?.toLowerCase().includes(searchTerm), ) : uniqueCollaboratorsArray; const firstNCollaborators = uniqueCollaboratorsArray.slice( 0, - FIRST_N_AVATARS, + maxAvatars - 1, ); - const firstNAvatarsJSX = firstNCollaborators.map( - ([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - shouldWrapWithTooltip: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), + const firstNAvatarsJSX = firstNCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + shouldWrapWithTooltip: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), ); return mobile ? (
    - {uniqueCollaboratorsArray.map(([clientId, collaborator]) => + {uniqueCollaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId: collaborator.socketId, shouldWrapWithTooltip: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )}
    ) : ( -
    - {firstNAvatarsJSX} +
    +
    + {firstNAvatarsJSX} - {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && ( - { - if (!isOpen) { - setSearchTerm(""); - } - }} - > - - +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS} - - maxAvatars - 1 && ( + { + if (!isOpen) { + setSearchTerm(""); + } }} - align="end" - sideOffset={10} > - - {uniqueCollaboratorsArray.length >= - SHOW_COLLABORATORS_FILTER_AT && ( -
    - {searchIcon} - { - setSearchTerm(e.target.value); - }} - /> -
    - )} -
    - {filteredCollaborators.length === 0 && ( -
    - {t("userList.search.empty")} + + +{uniqueCollaboratorsArray.length - maxAvatars + 1} + + + + {uniqueCollaboratorsArray.length >= + SHOW_COLLABORATORS_FILTER_AT && ( +
    + {searchIcon} + { + setSearchTerm(e.target.value); + }} + />
    )} -
    - {t("userList.hint.text")} +
    + {filteredCollaborators.length === 0 && ( +
    + {t("userList.search.empty")} +
    + )} +
    + {t("userList.hint.text")} +
    + {filteredCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + withName: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), + )}
    - {filteredCollaborators.map(([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - withName: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), - )} -
    -
    -
    - - )} + + + + )} +
    ); }, @@ -236,10 +277,15 @@ export const UserList = React.memo( return false; } + const nextCollaboratorSocketIds = next.collaborators.keys(); + for (const [socketId, collaborator] of prev.collaborators) { const nextCollaborator = next.collaborators.get(socketId); if ( !nextCollaborator || + // this checks order of collaborators in the map is the same + // as previous render + socketId !== nextCollaboratorSocketIds.next().value || !isShallowEqual( collaborator, nextCollaborator, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index e76d8ae68..163756d57 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { return; } - const cursorButton: { - [id: string]: string | undefined; - } = {}; - const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = - {}; + const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] = + new Map(); + const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = + new Map(); const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] = - {}; - const pointerUsernames: { [id: string]: string } = {}; - const pointerUserStates: { [id: string]: string } = {}; + new Map(); + const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] = + new Map(); + const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] = + new Map(); props.appState.collaborators.forEach((user, socketId) => { if (user.selectedElementIds) { for (const id of Object.keys(user.selectedElementIds)) { - if (!(id in remoteSelectedElementIds)) { - remoteSelectedElementIds[id] = []; + if (!remoteSelectedElementIds.has(id)) { + remoteSelectedElementIds.set(id, []); } - remoteSelectedElementIds[id].push(socketId); + remoteSelectedElementIds.get(id)!.push(socketId); } } if (!user.pointer) { return; } if (user.username) { - pointerUsernames[socketId] = user.username; + remotePointerUsernames.set(socketId, user.username); } if (user.userState) { - pointerUserStates[socketId] = user.userState; + remotePointerUserStates.set(socketId, user.userState); } - pointerViewportCoords[socketId] = sceneCoordsToViewportCoords( - { - sceneX: user.pointer.x, - sceneY: user.pointer.y, - }, - props.appState, + remotePointerViewportCoords.set( + socketId, + sceneCoordsToViewportCoords( + { + sceneX: user.pointer.x, + sceneY: user.pointer.y, + }, + props.appState, + ), ); - cursorButton[socketId] = user.button; + remotePointerButton.set(socketId, user.button); }); const selectionColor = @@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { scale: window.devicePixelRatio, appState: props.appState, renderConfig: { - remotePointerViewportCoords: pointerViewportCoords, - remotePointerButton: cursorButton, + remotePointerViewportCoords, + remotePointerButton, remoteSelectedElementIds, - remotePointerUsernames: pointerUsernames, - remotePointerUserStates: pointerUserStates, + remotePointerUsernames, + remotePointerUserStates, selectionColor, renderScrollbars: false, }, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 967ae1976..063253f69 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon( ); export const eyeIcon = createIcon( - + @@ -1837,3 +1837,26 @@ export const searchIcon = createIcon( , tablerIconProps, ); + +export const microphoneIcon = createIcon( + + + + + + + , + tablerIconProps, +); + +export const microphoneMutedIcon = createIcon( + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 09e497564..ad87cb9e1 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -20,6 +20,9 @@ export const isIOS = export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; +export const supportsResizeObserver = + typeof window !== "undefined" && "ResizeObserver" in window; + export const APP_NAME = "Excalidraw"; export const DRAGGING_THRESHOLD = 10; // px @@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; +export const COLOR_WHITE = "#ffffff"; +export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; +// keep this in sync with CSS +export const COLOR_VOICE_CALL = "#a2f1a6"; + export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const GRID_SIZE = 20; // TODO make it configurable? diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 247e3f840..71097ba3e 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -116,8 +116,8 @@ } @mixin avatarStyles { - width: 1.25rem; - height: 1.25rem; + width: var(--avatar-size, 1.5rem); + height: var(--avatar-size, 1.5rem); position: relative; border-radius: 100%; outline-offset: 2px; @@ -131,6 +131,10 @@ color: var(--color-gray-90); flex: 0 0 auto; + &:active { + transform: scale(0.94); + } + &-img { width: 100%; height: 100%; @@ -144,14 +148,14 @@ right: -3px; bottom: -3px; left: -3px; - border: 1px solid var(--avatar-border-color); border-radius: 100%; } - &--is-followed::before { + &.is-followed::before { border-color: var(--color-primary-hover); + box-shadow: 0 0 0 1px var(--color-primary-hover); } - &--is-current-user { + &.is-current-user { cursor: auto; } } diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index 49a0de5be..a58efddef 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -84,7 +84,7 @@ export class LaserTrails implements Trail { if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key), + fill: () => getClientColor(key, collabolator), }); trail.start(this.container); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ac9108a32..1213bc318 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -534,7 +534,10 @@ }, "hint": { "text": "Click on user to follow", - "followStatus": "You're currently following this user" + "followStatus": "You're currently following this user", + "inCall": "User is in a voice call", + "micMuted": "User's microphone is muted", + "isSpeaking": "User is speaking" } } } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index a6d997770..0fd814e89 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -15,7 +15,7 @@ import { } from "../scene/scrollbars"; import { renderSelectionElement } from "../renderer/renderElement"; -import { getClientColor } from "../clients"; +import { getClientColor, renderRemoteCursors } from "../clients"; import { isSelectedViaGroup, getSelectedGroupIds, @@ -29,7 +29,7 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point } from "../types"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -726,14 +726,18 @@ const _renderInteractiveScene = ({ selectionColors.push(selectionColor); } // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { + const remoteClients = renderConfig.remoteSelectedElementIds.get( + element.id, + ); + if (remoteClients) { selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), ); } @@ -747,7 +751,7 @@ const _renderInteractiveScene = ({ elementX2, elementY2, selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], + dashed: !!remoteClients, cx, cy, activeEmbeddable: @@ -858,143 +862,13 @@ const _renderInteractiveScene = ({ // Reset zoom context.restore(); - // Paint remote pointers - for (const clientId in renderConfig.remotePointerViewportCoords) { - let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; - - x -= appState.offsetLeft; - y -= appState.offsetTop; - - const width = 11; - const height = 14; - - const isOutOfBounds = - x < 0 || - x > normalizedWidth - width || - y < 0 || - y > normalizedHeight - height; - - x = Math.max(x, 0); - x = Math.min(x, normalizedWidth - width); - y = Math.max(y, 0); - y = Math.min(y, normalizedHeight - height); - - const background = getClientColor(clientId); - - context.save(); - context.strokeStyle = background; - context.fillStyle = background; - - const userState = renderConfig.remotePointerUserStates[clientId]; - const isInactive = - isOutOfBounds || - userState === UserIdleState.IDLE || - userState === UserIdleState.AWAY; - - if (isInactive) { - context.globalAlpha = 0.3; - } - - if ( - renderConfig.remotePointerButton && - renderConfig.remotePointerButton[clientId] === "down" - ) { - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 3; - context.strokeStyle = "#ffffff88"; - context.stroke(); - context.closePath(); - - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 1; - context.strokeStyle = background; - context.stroke(); - context.closePath(); - } - - // Background (white outline) for arrow - context.fillStyle = oc.white; - context.strokeStyle = oc.white; - context.lineWidth = 6; - context.lineJoin = "round"; - context.beginPath(); - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.stroke(); - context.fill(); - - // Arrow - context.fillStyle = background; - context.strokeStyle = background; - context.lineWidth = 2; - context.lineJoin = "round"; - context.beginPath(); - if (isInactive) { - context.moveTo(x - 1, y - 1); - context.lineTo(x - 1, y + 15); - context.lineTo(x + 5, y + 10); - context.lineTo(x + 12, y + 9); - context.closePath(); - context.fill(); - } else { - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.fill(); - context.stroke(); - } - - const username = renderConfig.remotePointerUsernames[clientId] || ""; - - if (!isOutOfBounds && username) { - context.font = "600 12px sans-serif"; // font has to be set before context.measureText() - - const offsetX = x + width / 2; - const offsetY = y + height + 2; - const paddingHorizontal = 5; - const paddingVertical = 3; - const measure = context.measureText(username); - const measureHeight = - measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; - const finalHeight = Math.max(measureHeight, 12); - - const boxX = offsetX - 1; - const boxY = offsetY - 1; - const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; - const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; - if (context.roundRect) { - context.beginPath(); - context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); - context.fillStyle = background; - context.fill(); - context.strokeStyle = oc.white; - context.stroke(); - } else { - roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white); - } - context.fillStyle = oc.black; - - context.fillText( - username, - offsetX + paddingHorizontal + 1, - offsetY + - paddingVertical + - measure.actualBoundingBoxAscent + - Math.floor((finalHeight - measureHeight) / 2) + - 2, - ); - } - - context.restore(); - context.closePath(); - } + renderRemoteCursors({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, + }); // Paint scrollbars let scrollBars; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 02aa3b7bf..63a49fec5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,6 +1,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { + ExcalidrawElement, ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, @@ -13,6 +14,8 @@ import { ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, + SocketId, + UserIdleState, } from "../types"; import { MakeBrand } from "../utility-types"; @@ -46,11 +49,11 @@ export type SVGRenderConfig = { export type InteractiveCanvasRenderConfig = { // collab-related state // --------------------------------------------------------------------------- - remoteSelectedElementIds: { [elementId: string]: string[] }; - remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; - remotePointerUserStates: { [id: string]: string }; - remotePointerUsernames: { [id: string]: string }; - remotePointerButton?: { [id: string]: string | undefined }; + remoteSelectedElementIds: Map; + remotePointerViewportCoords: Map; + remotePointerUserStates: Map; + remotePointerUsernames: Map; + remotePointerButton: Map; selectionColor?: string; // extra options passed to the renderer // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index fefb82c2c..2729bc037 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -61,6 +61,9 @@ export type Collaborator = Readonly<{ id?: string; socketId?: SocketId; isCurrentUser?: boolean; + isInCall?: boolean; + isSpeaking?: boolean; + isMuted?: boolean; }>; export type CollaboratorPointer = { @@ -319,9 +322,9 @@ export interface AppState { y: number; } | null; objectsSnapModeEnabled: boolean; - /** the user's clientId & username who is being followed on the canvas */ + /** the user's socket id & username who is being followed on the canvas */ userToFollow: UserToFollow | null; - /** the clientIds of the users following the current user */ + /** the socket ids of the users following the current user */ followedBy: Set; } diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index d27445dfa..493dce340 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -791,6 +791,14 @@ export const isShallowEqual = < const aKeys = Object.keys(objA); const bKeys = Object.keys(objB); if (aKeys.length !== bKeys.length) { + if (debug) { + console.warn( + `%cisShallowEqual: objects don't have same properties ->`, + "color: #8B4000", + objA, + objB, + ); + } return false; } From 15bfa626b41c3981aed557666f67be3453243b29 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:41:06 +0100 Subject: [PATCH 091/112] feat: support to not render remote cursor & username (#7130) --- .../components/canvases/InteractiveCanvas.tsx | 2 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/index.tsx | 8 +++++- packages/excalidraw/laser-trails.ts | 25 +++++++++++-------- packages/excalidraw/types.ts | 13 ++++++++++ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 163756d57..e5cd60f62 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -86,7 +86,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { remoteSelectedElementIds.get(id)!.push(socketId); } } - if (!user.pointer) { + if (!user.pointer || user.pointer.renderCursor === false) { return; } if (user.username) { diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index ad87cb9e1..29659f86a 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -31,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; export const SHIFT_LOCKING_ANGLE = Math.PI / 12; +export const DEFAULT_LASER_COLOR = "red"; export const CURSOR_TYPE = { TEXT: "text", CROSSHAIR: "crosshair", diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 66eb91044..e1dc29e66 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -237,7 +237,13 @@ export { getFreeDrawSvgPath } from "./renderer/renderElement"; export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; -export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; +export { + FONT_FAMILY, + THEME, + MIME_TYPES, + ROUNDNESS, + DEFAULT_LASER_COLOR, +} from "./constants"; export { mutateElement, diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index a58efddef..e2ef258b0 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -5,6 +5,7 @@ import type App from "./components/App"; import { SocketId } from "./types"; import { easeOut } from "./utils"; import { getClientColor } from "./clients"; +import { DEFAULT_LASER_COLOR } from "./constants"; export class LaserTrails implements Trail { public localTrail: AnimatedTrail; @@ -20,7 +21,7 @@ export class LaserTrails implements Trail { this.localTrail = new AnimatedTrail(animationFrameHandler, app, { ...this.getTrailOptions(), - fill: () => "red", + fill: () => DEFAULT_LASER_COLOR, }); } @@ -78,13 +79,15 @@ export class LaserTrails implements Trail { return; } - for (const [key, collabolator] of this.app.state.collaborators.entries()) { + for (const [key, collaborator] of this.app.state.collaborators.entries()) { let trail!: AnimatedTrail; if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key, collabolator), + fill: () => + collaborator.pointer?.laserColor || + getClientColor(key, collaborator), }); trail.start(this.container); @@ -93,21 +96,21 @@ export class LaserTrails implements Trail { trail = this.collabTrails.get(key)!; } - if (collabolator.pointer && collabolator.pointer.tool === "laser") { - if (collabolator.button === "down" && !trail.hasCurrentTrail) { - trail.startPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.pointer && collaborator.pointer.tool === "laser") { + if (collaborator.button === "down" && !trail.hasCurrentTrail) { + trail.startPath(collaborator.pointer.x, collaborator.pointer.y); } if ( - collabolator.button === "down" && + collaborator.button === "down" && trail.hasCurrentTrail && - !trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y) + !trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) ) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); } - if (collabolator.button === "up" && trail.hasCurrentTrail) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.button === "up" && trail.hasCurrentTrail) { + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.endPath(); } } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2729bc037..d7f701ff8 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -70,6 +70,19 @@ export type CollaboratorPointer = { x: number; y: number; tool: "pointer" | "laser"; + /** + * Whether to render cursor + username. Useful when you only want to render + * laser trail. + * + * @default true + */ + renderCursor?: boolean; + /** + * Explicit laser color. + * + * @default string collaborator's cursor color + */ + laserColor?: string; }; export type DataURL = string & { _brand: "DataURL" }; From 7949aa1f1c0010866206ac4e7109139e2589aeb3 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 28 Mar 2024 16:44:29 +0530 Subject: [PATCH 092/112] feat: upgrade mermaid-to-excalidraw to 0.3.0 (#7819) --- packages/excalidraw/package.json | 2 +- yarn.lock | 132 ++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 5e5c52b21..0b12d46fa 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "0.2.0", + "@excalidraw/mermaid-to-excalidraw": "0.3.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index 61def89e7..e9dc642c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1980,7 +1980,7 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg== -"@braintree/sanitize-url@^6.0.2": +"@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== @@ -2147,13 +2147,13 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.2.0.tgz#1e0395cd2b1ce9f6898109f5dbf545f558f159cc" - integrity sha512-FR+Lw9dt+mQxsrmRL7YNU2wrlNXD16ZLyuNoKrPzPy+Ds3utzY1+/2UNeNu7FMSUO4hKdkrmyO+PDp9OvOhuKw== +"@excalidraw/mermaid-to-excalidraw@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.3.0.tgz#94c438133fc66db6b920e237abda5152b62e6cb0" + integrity sha512-eyFN8y2ES3HFtETZWZZBakkSB5ROfnHJeCLeBlMgrIk1fxbXpPtxlu2VwGNpqPjDiCfV5FYnx7FaZ4CRiVRVMg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.2.3" + mermaid "10.9.0" nanoid "4.0.2" "@excalidraw/prettier-config@1.0.2": @@ -3295,6 +3295,23 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== +"@types/d3-scale-chromatic@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz#fc0db9c10e789c351f4c42d96f31f2e4df8f5644" + integrity sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw== + +"@types/d3-scale@^4.0.3": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-time@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" + integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4986,13 +5003,6 @@ cose-base@^1.0.0: dependencies: layout-base "^1.0.0" -cose-base@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01" - integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g== - dependencies: - layout-base "^2.0.0" - cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -5094,21 +5104,21 @@ cytoscape-cose-bilkent@^4.1.0: dependencies: cose-base "^1.0.0" -cytoscape-fcose@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471" - integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ== - dependencies: - cose-base "^2.2.0" - -cytoscape@^3.23.0: - version "3.27.0" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.27.0.tgz#5141cd694570807c91075b609181bce102e0bb88" - integrity sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg== +cytoscape@^3.28.1: + version "3.28.1" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.28.1.tgz#f32c3e009bdf32d47845a16a4cd2be2bbc01baf7" + integrity sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg== dependencies: heap "^0.2.6" lodash "^4.17.21" +"d3-array@1 - 2": + version "2.12.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" + integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== + dependencies: + internmap "^1.0.0" + "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" @@ -5225,6 +5235,11 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + "d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" @@ -5245,6 +5260,14 @@ d3-random@3: resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== +d3-sankey@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.12.3.tgz#b3c268627bd72e5d80336e8de6acbfec9d15d01d" + integrity sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ== + dependencies: + d3-array "1 - 2" + d3-shape "^1.2.0" + d3-scale-chromatic@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" @@ -5276,6 +5299,13 @@ d3-shape@3: dependencies: d3-path "^3.1.0" +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + "d3-time-format@2 - 4", d3-time-format@4: version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" @@ -5565,10 +5595,10 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" -dompurify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.3.tgz#4b115d15a091ddc96f232bcef668550a2f6f1430" - integrity sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ== +dompurify@^3.0.5: + version "3.0.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.11.tgz#c163f5816eaac6aeef35dae2b77fca0504564efe" + integrity sha512-Fan4uMuyB26gFV3ovPoEoQbxRRPfTu3CvImyZnhGq5fsIEO+gEFLp45ISFt+kQBWsK5ulDdT0oV28jS1UrwQLg== dotenv@16.0.1: version "16.0.1" @@ -5602,10 +5632,10 @@ electron-to-chromium@^1.4.601: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.608.tgz#ff567c51dde4892ae330860c7d9f19571e9e1d69" integrity sha512-J2f/3iIIm3Mo0npneITZ2UPe4B1bg8fTNrFjD8715F/k1BvbviRuqYGkET1PgprrczXYTHFvotbBOmUp6KE0uA== -elkjs@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" - integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ== +elkjs@^0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.2.tgz#3d4ef6f17fde06a5d7eaa3063bb875e25e59e972" + integrity sha512-2Y/RaA1pdgSHpY0YG4TYuYCD2wh97CRvu22eLG3Kz0pgQ/6KbIFTxsTnDc4MH/6hFlg2L/9qXrDMG0nMjP63iw== emoji-regex@^8.0.0: version "8.0.0" @@ -6871,6 +6901,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +internmap@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" + integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== + interpret@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" @@ -7381,6 +7416,13 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +katex@^0.16.9: + version "0.16.10" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" + integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== + dependencies: + commander "^8.3.0" + khroma@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" @@ -7418,11 +7460,6 @@ layout-base@^1.0.0: resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2" integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg== -layout-base@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285" - integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== - leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7710,20 +7747,23 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.2.3: - version "10.2.3" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.2.3.tgz#789d3b582c5da8c69aa4a7c0e2b826562c8c8b12" - integrity sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw== +mermaid@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.0.tgz#4d1272fbe434bd8f3c2c150554dc8a23a9bf9361" + integrity sha512-swZju0hFox/B/qoLKK0rOxxgh8Cf7rJSfAUc1u8fezVihYMvrJAS45GzAxTVf4Q+xn9uMgitBcmWk7nWGXOs/g== dependencies: - "@braintree/sanitize-url" "^6.0.2" - cytoscape "^3.23.0" + "@braintree/sanitize-url" "^6.0.1" + "@types/d3-scale" "^4.0.3" + "@types/d3-scale-chromatic" "^3.0.0" + cytoscape "^3.28.1" cytoscape-cose-bilkent "^4.1.0" - cytoscape-fcose "^2.1.0" d3 "^7.4.0" + d3-sankey "^0.12.3" dagre-d3-es "7.0.10" dayjs "^1.11.7" - dompurify "3.0.3" - elkjs "^0.8.2" + dompurify "^3.0.5" + elkjs "^0.9.0" + katex "^0.16.9" khroma "^2.0.0" lodash-es "^4.17.21" mdast-util-from-markdown "^1.3.0" From 65bc500598b70be00c8d770f49928ff66f77470b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:52:23 +0100 Subject: [PATCH 093/112] fix: `excalidrawAPI.toggleSidebar` not switching between tabs correctly (#7821) --- packages/excalidraw/components/App.tsx | 18 +++- .../components/Sidebar/Sidebar.test.tsx | 82 ++++++++++++++++++- .../components/Sidebar/SidebarTab.tsx | 2 +- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b02d919d4..b920a1037 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3684,17 +3684,29 @@ class App extends React.Component { tab, force, }: { - name: SidebarName; + name: SidebarName | null; tab?: SidebarTabName; force?: boolean; }): boolean => { let nextName; if (force === undefined) { - nextName = this.state.openSidebar?.name === name ? null : name; + nextName = + this.state.openSidebar?.name === name && + this.state.openSidebar?.tab === tab + ? null + : name; } else { nextName = force ? name : null; } - this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); + + const nextState: AppState["openSidebar"] = nextName + ? { name: nextName } + : null; + if (nextState && tab) { + nextState.tab = tab; + } + + this.setState({ openSidebar: nextState }); return !!nextName; }; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx index 9787f9a73..6b60418b5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx @@ -85,7 +85,7 @@ describe("Sidebar", () => { }); }); - it("should toggle sidebar using props.toggleMenu()", async () => { + it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => { const { container } = await render( @@ -158,6 +158,20 @@ describe("Sidebar", () => { const sidebars = container.querySelectorAll(".sidebar"); expect(sidebars.length).toBe(1); }); + + // closing sidebar using `{ name: null }` + // ------------------------------------------------------------------------- + expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + expect(window.h.app.toggleSidebar({ name: null })).toBe(false); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); }); }); @@ -329,4 +343,70 @@ describe("Sidebar", () => { ); }); }); + + describe("Sidebar.tab", () => { + it("should toggle sidebars tabs correctly", async () => { + const { container } = await render( + + + + Library + Comments + + + , + ); + + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).toBeNull(); + + // open library sidebar + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "library" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).not.toBeNull(); + + // switch to comments tab + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + + // toggle sidebar closed + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(false); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).toBeNull(); + + // toggle sidebar open + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + }, + ); + }); + }); }); diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 741a69fd1..f7eacc1b1 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -10,7 +10,7 @@ export const SidebarTab = ({ children: React.ReactNode; } & React.HTMLAttributes) => { return ( - + {children} ); From 6b523563d804d48be260399fe45e30c394e07065 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:58:47 +0100 Subject: [PATCH 094/112] fix: ejs support in html files (#7822) --- excalidraw-app/index.html | 8 +- excalidraw-app/package.json | 4 +- excalidraw-app/vite.config.mts | 4 + yarn.lock | 243 +++++++++++++++++++++++++++++++-- 4 files changed, 240 insertions(+), 19 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 66f3afdab..2e1fa1adb 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -78,7 +78,7 @@ } - <% if ("%PROD%" === "true") { %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> + <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && + VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && - VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% } %> From cd50aa719fa5dcb77beb9f736725fa744e5ba6ba Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Mon, 8 Apr 2024 16:46:24 +0200 Subject: [PATCH 101/112] feat: add system mode to the theme selector (#7853) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 37 ++++------ excalidraw-app/components/AppMainMenu.tsx | 9 ++- excalidraw-app/index.html | 28 ++++++-- excalidraw-app/useHandleAppTheme.ts | 70 +++++++++++++++++++ packages/excalidraw/CHANGELOG.md | 1 + packages/excalidraw/actions/actionCanvas.tsx | 4 +- packages/excalidraw/components/App.tsx | 6 +- .../excalidraw/components/DarkModeToggle.tsx | 4 +- packages/excalidraw/components/RadioGroup.tsx | 7 +- .../components/dropdownMenu/DropdownMenu.scss | 22 ++++++ .../DropdownMenuItemContentRadio.tsx | 51 ++++++++++++++ packages/excalidraw/components/icons.tsx | 17 +++-- .../components/main-menu/DefaultItems.tsx | 64 +++++++++++++++-- packages/excalidraw/data/magic.ts | 3 +- .../hooks/useCreatePortalContainer.ts | 3 +- packages/excalidraw/locales/en.json | 2 + packages/excalidraw/renderer/helpers.ts | 4 +- packages/excalidraw/renderer/renderElement.ts | 5 +- packages/excalidraw/renderer/renderSnaps.ts | 3 +- packages/excalidraw/scene/export.ts | 3 +- setupTests.ts | 14 ++++ 21 files changed, 301 insertions(+), 56 deletions(-) create mode 100644 excalidraw-app/useHandleAppTheme.ts create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 1bf126924..56033ec15 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -17,7 +17,6 @@ import { FileId, NonDeletedExcalidrawElement, OrderedExcalidrawElement, - Theme, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -124,6 +123,7 @@ import { exportToPlus, share, } from "../packages/excalidraw/components/icons"; +import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; polyfill(); @@ -303,6 +303,9 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const { editorTheme } = useHandleAppTheme(); + // initial state // --------------------------------------------------------------------------- @@ -566,23 +569,6 @@ const ExcalidrawWrapper = () => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); - const [theme, setTheme] = useState( - () => - (localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_THEME, - ) as Theme | null) || - // FIXME migration from old LS scheme. Can be removed later. #5660 - importFromLocalStorage().appState?.theme || - THEME.LIGHT, - ); - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme); - // currently only used for body styling during init (see public/index.html), - // but may change in the future - document.documentElement.classList.toggle("dark", theme === THEME.DARK); - }, [theme]); - const onChange = ( elements: readonly OrderedExcalidrawElement[], appState: AppState, @@ -592,8 +578,6 @@ const ExcalidrawWrapper = () => { collabAPI.syncElements(elements); } - setTheme(appState.theme); - // this check is redundant, but since this is a hot path, it's best // not to evaludate the nested expression every time if (!LocalData.isSavePaused()) { @@ -798,7 +782,7 @@ const ExcalidrawWrapper = () => { detectScroll={false} handleKeyboardGlobally={true} autoFocus={true} - theme={theme} + theme={editorTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; @@ -820,6 +804,8 @@ const ExcalidrawWrapper = () => { onCollabDialogOpen={onCollabDialogOpen} isCollaborating={isCollaborating} isCollabEnabled={!isCollabDisabled} + theme={appTheme} + setTheme={(theme) => setAppTheme(theme)} /> { } }, }, - CommandPalette.defaultItems.toggleTheme, + { + ...CommandPalette.defaultItems.toggleTheme, + perform: () => { + setAppTheme( + editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + }, + }, ]} /> diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 813d620c8..fe3f36c9e 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { LanguageList } from "./LanguageList"; @@ -7,6 +8,8 @@ export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; + theme: Theme | "system"; + setTheme: (theme: Theme | "system") => void; }> = React.memo((props) => { return ( @@ -35,7 +38,11 @@ export const AppMainMenu: React.FC<{ - + diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 4d0e2eaa5..db5bd6457 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -64,12 +64,30 @@ `), - intrinsicSize: { w: 550, h: 720 }, - }; - } + intrinsicSize: { w: 550, h: 720 }, + }; embeddedLinkCache.set(link, ret); return ret; } @@ -313,8 +290,8 @@ export const maybeParseEmbedSrc = (str: string): string => { } const gistMatch = str.match(RE_GH_GIST_EMBED); - if (gistMatch && gistMatch.length === 2) { - return gistMatch[1]; + if (gistMatch && gistMatch.length === 3) { + return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`; } if (RE_GIPHY.test(str)) { @@ -325,6 +302,7 @@ export const maybeParseEmbedSrc = (str: string): string => { if (match && match.length === 2) { return match[1]; } + return str; }; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 8d3a0d4ef..2ee9a12b0 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -111,6 +111,7 @@ export type IframeData = | { intrinsicSize: { w: number; h: number }; error?: Error; + sandbox?: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } From 4689a6b300d8756b54fdbaa7a3f1f8db60b9cc78 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 12 Apr 2024 18:58:51 +0800 Subject: [PATCH 109/112] fix: hit test for closed sharp curves (#7881) --- packages/excalidraw/components/App.tsx | 1 + packages/utils/geometry/shape.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 322096d63..6a0fd1031 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4383,6 +4383,7 @@ class App extends React.Component { return shouldTestInside(element) ? getClosedCurveShape( + element, roughShape, [element.x, element.y], element.angle, diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 1fbcd7935..87c0fe099 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -20,6 +20,7 @@ import { ExcalidrawFreeDrawElement, ExcalidrawIframeElement, ExcalidrawImageElement, + ExcalidrawLinearElement, ExcalidrawRectangleElement, ExcalidrawSelectionElement, ExcalidrawTextElement, @@ -233,12 +234,12 @@ export const getFreedrawShape = ( }; export const getClosedCurveShape = ( + element: ExcalidrawLinearElement, roughShape: Drawable, startingPoint: Point = [0, 0], angleInRadian: number, center: Point, ): GeometricShape => { - const ops = getCurvePathOps(roughShape); const transform = (p: Point) => pointRotate( [p[0] + startingPoint[0], p[1] + startingPoint[1]], @@ -246,6 +247,15 @@ export const getClosedCurveShape = ( center, ); + if (element.roundness === null) { + return { + type: "polygon", + data: close(element.points.map((p) => transform(p as Point))), + }; + } + + const ops = getCurvePathOps(roughShape); + const points: Point[] = []; let odd = false; for (const operation of ops) { From afcde542f9e991b4b671df191aa155e1ebee6006 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:51:17 +0200 Subject: [PATCH 110/112] fix: parse embeddable srcdoc urls strictly (#7884) --- packages/excalidraw/data/url.ts | 6 ++++- packages/excalidraw/element/embeddable.ts | 30 ++++++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts index 2655c141d..dae576068 100644 --- a/packages/excalidraw/data/url.ts +++ b/packages/excalidraw/data/url.ts @@ -1,11 +1,15 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; +export const sanitizeHTMLAttribute = (html: string) => { + return html.replace(/"/g, """); +}; + export const normalizeLink = (link: string) => { link = link.trim(); if (!link) { return link; } - return sanitizeUrl(link); + return sanitizeUrl(sanitizeHTMLAttribute(link)); }; export const isLocalLink = (link: string | null) => { diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index 40213aff8..8b55a5441 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -11,6 +11,7 @@ import { ExcalidrawIframeLikeElement, IframeData, } from "./types"; +import { sanitizeHTMLAttribute } from "../data/url"; const embeddedLinkCache = new Map(); @@ -21,12 +22,13 @@ const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/; -const RE_GH_GIST = /^https:\/\/gist\.github\.com/; +const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/; const RE_GH_GIST_EMBED = - /https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i; + /^ twitter embeds -const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/; +const RE_TWITTER = + /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; const RE_TWITTER_EMBED = /^ createSrcDoc( - ` `, + `
    `, ), intrinsicSize: { w: 480, h: 480 }, sandbox: { allowSameOrigin: true }, @@ -167,11 +175,15 @@ export const getEmbedLink = ( } if (RE_GH_GIST.test(link)) { + const [, user, gistId] = link.match(RE_GH_GIST)!; + const safeURL = sanitizeHTMLAttribute( + `https://gist.github.com/${user}/${gistId}`, + ); const ret: IframeData = { type: "document", srcdoc: () => createSrcDoc(` - + `), intrinsicSize: { w: 550, h: 720 }, + sandbox: { allowSameOrigin }, }; embeddedLinkCache.set(link, ret); return ret; } - embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type }); - return { link, intrinsicSize: aspectRatio, type }; + embeddedLinkCache.set(link, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; }; export const createPlaceholderEmbeddableLabel = ( @@ -265,34 +319,39 @@ export const actionSetEmbeddableAsActiveTool = register({ }, }); -const validateHostname = ( +const matchHostname = ( url: string, /** using a Set assumes it already contains normalized bare domains */ allowedHostnames: Set | string, -): boolean => { +): string | null => { try { const { hostname } = new URL(url); const bareDomain = hostname.replace(/^www\./, ""); - const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( - /^([^.]+)/, - "*", - ); if (allowedHostnames instanceof Set) { - return ( - ALLOWED_DOMAINS.has(bareDomain) || - ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded) + if (ALLOWED_DOMAINS.has(bareDomain)) { + return bareDomain; + } + + const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( + /^([^.]+)/, + "*", ); + if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) { + return bareDomainWithFirstSubdomainWildcarded; + } + return null; } - if (bareDomain === allowedHostnames.replace(/^www\./, "")) { - return true; + const bareAllowedHostname = allowedHostnames.replace(/^www\./, ""); + if (bareDomain === bareAllowedHostname) { + return bareAllowedHostname; } } catch (error) { // ignore } - return false; + return null; }; export const maybeParseEmbedSrc = (str: string): string => { @@ -342,7 +401,7 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } @@ -350,5 +409,5 @@ export const embeddableURLValidator = ( } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); };

    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�?qd(5Hh*uAIxK&qTYu3V< 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 PfG>3wZRJu$%i#Y78=43z literal 0 HcmV?d00001 diff --git a/examples/excalidraw/with-script-in-browser/public/images/excalibot.png b/examples/excalidraw/with-script-in-browser/public/images/excalibot.png new file mode 100644 index 0000000000000000000000000000000000000000..7928ec325b9ab27d04a434ff69a3a60e068a727d GIT binary patch literal 30330 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 literal 0 HcmV?d00001 diff --git a/examples/excalidraw/with-script-in-browser/public/images/pika.jpeg b/examples/excalidraw/with-script-in-browser/public/images/pika.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..455ed52a638a0b060fba54892835e8923d671bfd GIT binary patch literal 6250 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 Vod0WA{QK4aPuTdMX5h>5{{x3Dqs0IK literal 0 HcmV?d00001 diff --git a/examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg b/examples/excalidraw/with-script-in-browser/public/images/rocket.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f17a74bd65c0705097ac832a6f63c6f3f6ca4e82 GIT binary patch literal 40368 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