From b33fa6d6f64d27adc3a47b25c0aa55711740d0af Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 29 Jun 2023 03:14:42 -0700 Subject: [PATCH 01/55] fix: stronger enforcement of normalizeLink (#6728) Co-authored-by: dwelle --- package.json | 1 + src/components/App.tsx | 16 ++++++++++----- src/data/restore.ts | 3 ++- src/data/url.test.tsx | 30 ++++++++++++++++++++++++++++ src/data/url.ts | 9 +++++++++ src/element/Hyperlink.tsx | 26 +++++++++--------------- src/packages/excalidraw/CHANGELOG.md | 1 + src/packages/excalidraw/index.tsx | 2 ++ src/renderer/renderElement.ts | 3 ++- yarn.lock | 5 +++++ 10 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 src/data/url.test.tsx create mode 100644 src/data/url.ts diff --git a/package.json b/package.json index 584bdcbc4..d1198fa23 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ] }, "dependencies": { + "@braintree/sanitize-url": "6.0.2", "@excalidraw/random-username": "1.0.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/src/components/App.tsx b/src/components/App.tsx index 99be4b0db..2479731e8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -291,13 +291,12 @@ import { } from "../element/textElement"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { - normalizeLink, showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, isPointHittingLinkIcon, - isLocalLink, } from "../element/Hyperlink"; +import { isLocalLink, normalizeLink } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; import { Fonts } from "../scene/Fonts"; @@ -3352,12 +3351,19 @@ class App extends React.Component { this.device.isMobile, ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { - const url = this.hitLinkElement.link; + let url = this.hitLinkElement.link; if (url) { + url = normalizeLink(url); let customEvent; if (this.props.onLinkOpen) { customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent); - this.props.onLinkOpen(this.hitLinkElement, customEvent); + this.props.onLinkOpen( + { + ...this.hitLinkElement, + link: url, + }, + customEvent, + ); } if (!customEvent?.defaultPrevented) { const target = isLocalLink(url) ? "_self" : "_blank"; @@ -3365,7 +3371,7 @@ class App extends React.Component { // https://mathiasbynens.github.io/rel-noopener/ if (newWindow) { newWindow.opener = null; - newWindow.location = normalizeLink(url); + newWindow.location = url; } } } diff --git a/src/data/restore.ts b/src/data/restore.ts index 57a0265f6..5f2adc004 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -41,6 +41,7 @@ import { measureBaseline, } from "../element/textElement"; import { COLOR_PALETTE } from "../colors"; +import { normalizeLink } from "./url"; type RestoredAppState = Omit< AppState, @@ -142,7 +143,7 @@ const restoreElementWithProperties = < ? element.boundElementIds.map((id) => ({ type: "arrow", id })) : element.boundElements ?? [], updated: element.updated ?? getUpdatedTimestamp(), - link: element.link ?? null, + link: element.link ? normalizeLink(element.link) : null, locked: element.locked ?? false, }; diff --git a/src/data/url.test.tsx b/src/data/url.test.tsx new file mode 100644 index 000000000..36bed0a38 --- /dev/null +++ b/src/data/url.test.tsx @@ -0,0 +1,30 @@ +import { normalizeLink } from "./url"; + +describe("normalizeLink", () => { + // NOTE not an extensive XSS test suite, just to check if we're not + // regressing in sanitization + it("should sanitize links", () => { + expect( + // eslint-disable-next-line no-script-url + normalizeLink(`javascript://%0aalert(document.domain)`).startsWith( + // eslint-disable-next-line no-script-url + `javascript:`, + ), + ).toBe(false); + expect(normalizeLink("ola")).toBe("ola"); + expect(normalizeLink(" ola")).toBe("ola"); + + expect(normalizeLink("https://www.excalidraw.com")).toBe( + "https://www.excalidraw.com", + ); + expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com"); + expect(normalizeLink("/ola")).toBe("/ola"); + expect(normalizeLink("http://test")).toBe("http://test"); + expect(normalizeLink("ftp://test")).toBe("ftp://test"); + expect(normalizeLink("file://")).toBe("file://"); + expect(normalizeLink("file://")).toBe("file://"); + expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); + expect(normalizeLink("[[test]]")).toBe("[[test]]"); + expect(normalizeLink("")).toBe(""); + }); +}); diff --git a/src/data/url.ts b/src/data/url.ts new file mode 100644 index 000000000..d34320a5d --- /dev/null +++ b/src/data/url.ts @@ -0,0 +1,9 @@ +import { sanitizeUrl } from "@braintree/sanitize-url"; + +export const normalizeLink = (link: string) => { + return sanitizeUrl(link); +}; + +export const isLocalLink = (link: string | null) => { + return !!(link?.includes(location.origin) || link?.startsWith("/")); +}; diff --git a/src/element/Hyperlink.tsx b/src/element/Hyperlink.tsx index 6bf3b17ea..cf741ce08 100644 --- a/src/element/Hyperlink.tsx +++ b/src/element/Hyperlink.tsx @@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip"; import { getSelectedElements } from "../scene"; import { isPointHittingElementBoundingBox } from "./collision"; import { getElementAbsoluteCoords } from "./"; +import { isLocalLink, normalizeLink } from "../data/url"; import "./Hyperlink.scss"; import { trackEvent } from "../analytics"; @@ -166,7 +167,7 @@ export const Hyperlink = ({ /> ) : ( { - link = link.trim(); - if (link) { - // prefix with protocol if not fully-qualified - if (!link.includes("://") && !/^[[\\/]/.test(link)) { - link = `https://${link}`; - } - } - return link; -}; - -export const isLocalLink = (link: string | null) => { - return !!(link?.includes(location.origin) || link?.startsWith("/")); -}; - export const actionLink = register({ name: "hyperlink", perform: (elements, appState) => { diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 30292159b..1cd27d95c 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section. ### Features +- 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) - Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 22f79dd33..8ee2956bb 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -247,3 +247,5 @@ export { WelcomeScreen }; export { LiveCollaborationTrigger }; export { DefaultSidebar } from "../../components/DefaultSidebar"; + +export { normalizeLink } from "../../data/url"; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index ed6ccb69f..0efe5df96 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -50,6 +50,7 @@ import { } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; import { getContainingFrame } from "../frame"; +import { normalizeLink } from "../data/url"; // 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 @@ -1203,7 +1204,7 @@ export const renderElementToSvg = ( // 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", element.link); + anchorTag.setAttribute("href", normalizeLink(element.link)); root.appendChild(anchorTag); root = anchorTag; } diff --git a/yarn.lock b/yarn.lock index 2e3c1ea8e..2e8bdda5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1086,6 +1086,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@braintree/sanitize-url@6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" + integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg== + "@csstools/normalize.css@*": version "12.0.0" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4" From 29a5e982c3ca4ba52b8d78362ffe2129c1c41e2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Moln=C3=A1r?= <38168628+barnabasmolnar@users.noreply.github.com> Date: Thu, 29 Jun 2023 12:36:38 +0200 Subject: [PATCH 02/55] feat: support scrollToContent opts.fitToViewport (#6581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dwelle Co-authored-by: Arnošt Pleskot --- .../@excalidraw/excalidraw/api/props/ref.mdx | 40 +++--- src/actions/actionCanvas.tsx | 136 +++++++++++++----- src/actions/types.ts | 3 +- src/components/App.tsx | 89 +++++++++--- src/keys.ts | 1 + src/packages/excalidraw/CHANGELOG.md | 3 +- src/packages/excalidraw/example/App.tsx | 73 +++++++++- src/tests/fitToContent.test.tsx | 13 -- src/utils.ts | 130 ++++++++++++----- 9 files changed, 364 insertions(+), 124 deletions(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx index 08e807907..eaf58f758 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx @@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history. ## scrollToContent -
-  (
- {" "} - target?:{" "} -
- ExcalidrawElement - {" "} - |{" "} - - ExcalidrawElement - - [], -
- {" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number - } -
) => void -
+```tsx +( + target?: ExcalidrawElement | ExcalidrawElement[], + opts?: + | { + fitToContent?: boolean; + animate?: boolean; + duration?: number; + } + | { + fitToViewport?: boolean; + viewportZoomFactor?: number; + animate?: boolean; + duration?: number; + } +) => void +``` Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene. | Attribute | type | default | Description | | --- | --- | --- | --- | -| target | ExcalidrawElement | ExcalidrawElement[] | All scene elements | The element(s) to scroll to. | -| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. | +| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | All scene elements | The element(s) to scroll to. | +| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. Note that the zoom range is between 10%-100%. | +| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. | +| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) | | opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. | | opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. | diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index a94a3672a..ae4a08a96 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -20,7 +20,6 @@ import { isHandToolActive, } from "../appState"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; -import { excludeElementsInFramesFromSelection } from "../scene/selection"; import { Bounds } from "../element/bounds"; export const actionChangeViewBackgroundColor = register({ @@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = ( return clampedZoomValueToFitElements as NormalizedZoomValue; }; -export const zoomToFitElements = ( - elements: readonly ExcalidrawElement[], - appState: Readonly, - zoomToSelection: boolean, -) => { - const nonDeletedElements = getNonDeletedElements(elements); - const selectedElements = getSelectedElements(nonDeletedElements, appState); - - const commonBounds = - zoomToSelection && selectedElements.length > 0 - ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements)) - : getCommonBounds( - excludeElementsInFramesFromSelection(nonDeletedElements), - ); - - const newZoom = { - value: zoomValueToFitBoundsOnViewport(commonBounds, { - width: appState.width, - height: appState.height, - }), - }; +export const zoomToFit = ({ + targetElements, + appState, + fitToViewport = false, + viewportZoomFactor = 0.7, +}: { + 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)); const [x1, y1, x2, y2] = commonBounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; + + let newZoomValue; + let scrollX; + let scrollY; + + if (fitToViewport) { + const commonBoundsWidth = x2 - x1; + const commonBoundsHeight = y2 - y1; + + newZoomValue = + Math.min( + appState.width / commonBoundsWidth, + appState.height / commonBoundsHeight, + ) * Math.min(1, Math.max(viewportZoomFactor, 0.1)); + + // Apply clamping to newZoomValue to be between 10% and 3000% + newZoomValue = Math.min( + Math.max(newZoomValue, 0.1), + 30.0, + ) as NormalizedZoomValue; + + scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX; + scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; + } else { + newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, { + width: appState.width, + height: appState.height, + }); + + const centerScroll = centerScrollOn({ + scenePoint: { x: centerX, y: centerY }, + viewportDimensions: { + width: appState.width, + height: appState.height, + }, + zoom: { value: newZoomValue }, + }); + + scrollX = centerScroll.scrollX; + scrollY = centerScroll.scrollY; + } + return { appState: { ...appState, - ...centerScrollOn({ - scenePoint: { x: centerX, y: centerY }, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: newZoom, - }), - zoom: newZoom, + scrollX, + scrollY, + zoom: { value: newZoomValue }, }, commitToHistory: false, }; }; -export const actionZoomToSelected = register({ - name: "zoomToSelection", +// 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. +export const actionZoomToFitSelectionInViewport = register({ + name: "zoomToFitSelectionInViewport", trackEvent: { category: "canvas" }, - perform: (elements, appState) => zoomToFitElements(elements, appState, true), + perform: (elements, appState) => { + const selectedElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + return zoomToFit({ + targetElements: selectedElements.length ? selectedElements : elements, + appState, + fitToViewport: false, + }); + }, + // NOTE shift-2 should have been assigned actionZoomToFitSelection. + // TBD on how proceed keyTest: (event) => event.code === CODES.TWO && event.shiftKey && @@ -279,11 +322,34 @@ export const actionZoomToSelected = register({ !event[KEYS.CTRL_OR_CMD], }); +export const actionZoomToFitSelection = register({ + name: "zoomToFitSelection", + trackEvent: { category: "canvas" }, + perform: (elements, appState) => { + const selectedElements = getSelectedElements( + getNonDeletedElements(elements), + appState, + ); + return zoomToFit({ + targetElements: selectedElements.length ? selectedElements : elements, + appState, + fitToViewport: true, + }); + }, + // NOTE this action should use shift-2 per figma, alas + keyTest: (event) => + event.code === CODES.THREE && + event.shiftKey && + !event.altKey && + !event[KEYS.CTRL_OR_CMD], +}); + export const actionZoomToFit = register({ name: "zoomToFit", viewMode: true, trackEvent: { category: "canvas" }, - perform: (elements, appState) => zoomToFitElements(elements, appState, false), + perform: (elements, appState) => + zoomToFit({ targetElements: elements, appState, fitToViewport: false }), keyTest: (event) => event.code === CODES.ONE && event.shiftKey && diff --git a/src/actions/types.ts b/src/actions/types.ts index 7ba40afab..bd81fa559 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -82,7 +82,8 @@ export type ActionName = | "zoomOut" | "resetZoom" | "zoomToFit" - | "zoomToSelection" + | "zoomToFitSelection" + | "zoomToFitSelectionInViewport" | "changeFontFamily" | "changeTextAlign" | "changeVerticalAlign" diff --git a/src/components/App.tsx b/src/components/App.tsx index 2479731e8..3ccfa1246 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -245,6 +245,7 @@ import { isTransparent, easeToValuesRAF, muteFSAbortError, + easeOut, } from "../utils"; import { ContextMenu, @@ -320,10 +321,7 @@ import { actionRemoveAllElementsFromFrame, actionSelectAllElementsInFrame, } from "../actions/actionFrame"; -import { - actionToggleHandTool, - zoomToFitElements, -} from "../actions/actionCanvas"; +import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; @@ -2239,27 +2237,51 @@ class App extends React.Component { target: | ExcalidrawElement | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), - opts?: { fitToContent?: boolean; animate?: boolean; duration?: number }, + opts?: + | { + fitToContent?: boolean; + fitToViewport?: never; + viewportZoomFactor?: never; + animate?: boolean; + duration?: number; + } + | { + fitToContent?: never; + fitToViewport?: boolean; + /** when fitToViewport=true, how much screen should the content cover, + * between 0.1 (10%) and 1 (100%) + */ + viewportZoomFactor?: number; + animate?: boolean; + duration?: number; + }, ) => { this.cancelInProgresAnimation?.(); // convert provided target into ExcalidrawElement[] if necessary - const targets = Array.isArray(target) ? target : [target]; + const targetElements = Array.isArray(target) ? target : [target]; let zoom = this.state.zoom; let scrollX = this.state.scrollX; let scrollY = this.state.scrollY; - if (opts?.fitToContent) { - // compute an appropriate viewport location (scroll X, Y) and zoom level - // that fit the target elements on the scene - const { appState } = zoomToFitElements(targets, this.state, false); + if (opts?.fitToContent || opts?.fitToViewport) { + const { appState } = zoomToFit({ + targetElements, + appState: this.state, + fitToViewport: !!opts?.fitToViewport, + viewportZoomFactor: opts?.viewportZoomFactor, + }); zoom = appState.zoom; scrollX = appState.scrollX; scrollY = appState.scrollY; } else { // compute only the viewport location, without any zoom adjustment - const scroll = calculateScrollCenter(targets, this.state, this.canvas); + const scroll = calculateScrollCenter( + targetElements, + this.state, + this.canvas, + ); scrollX = scroll.scrollX; scrollY = scroll.scrollY; } @@ -2269,19 +2291,42 @@ class App extends React.Component { if (opts?.animate) { const origScrollX = this.state.scrollX; const origScrollY = this.state.scrollY; + const origZoom = this.state.zoom.value; - // zoom animation could become problematic on scenes with large number - // of elements, setting it to its final value to improve user experience. - // - // using zoomCanvas() to zoom on current viewport center - this.zoomCanvas(zoom.value); + const cancel = easeToValuesRAF({ + fromValues: { + scrollX: origScrollX, + scrollY: origScrollY, + zoom: origZoom, + }, + toValues: { scrollX, scrollY, zoom: zoom.value }, + interpolateValue: (from, to, progress, key) => { + // for zoom, use different easing + if (key === "zoom") { + return from * Math.pow(to / from, easeOut(progress)); + } + // handle using default + return undefined; + }, + onStep: ({ scrollX, scrollY, zoom }) => { + this.setState({ + scrollX, + scrollY, + zoom: { value: zoom }, + }); + }, + onStart: () => { + this.setState({ shouldCacheIgnoreZoom: true }); + }, + onEnd: () => { + this.setState({ shouldCacheIgnoreZoom: false }); + }, + onCancel: () => { + this.setState({ shouldCacheIgnoreZoom: false }); + }, + duration: opts?.duration ?? 500, + }); - const cancel = easeToValuesRAF( - [origScrollX, origScrollY], - [scrollX, scrollY], - (scrollX, scrollY) => this.setState({ scrollX, scrollY }), - { duration: opts?.duration ?? 500 }, - ); this.cancelInProgresAnimation = () => { cancel(); this.cancelInProgresAnimation = null; diff --git a/src/keys.ts b/src/keys.ts index 14b7d288e..33f1188dc 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -10,6 +10,7 @@ export const CODES = { BRACKET_LEFT: "BracketLeft", ONE: "Digit1", TWO: "Digit2", + THREE: "Digit3", NINE: "Digit9", QUOTE: "Quote", ZERO: "Digit0", diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 1cd27d95c..5933f5649 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section. ### Features +- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#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) - Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213) @@ -64,7 +65,7 @@ Please add the latest change on the top under the correct section. ### Features -- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319) +- [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319) - Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224) diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 03646541e..7f17b292f 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
- + + + +
diff --git a/src/tests/fitToContent.test.tsx b/src/tests/fitToContent.test.tsx index 6fce7cdcd..fd7a1170b 100644 --- a/src/tests/fitToContent.test.tsx +++ b/src/tests/fitToContent.test.tsx @@ -160,19 +160,6 @@ describe("fitToContent animated", () => { expect(window.requestAnimationFrame).toHaveBeenCalled(); - // Since this is an animation, we expect values to change through time. - // We'll verify that the zoom/scroll values change in each animation frame - - // zoom is not animated, it should be set to its final value, which in our - // case zooms out to 50% so that th element is fully visible (it's 2x large - // as the canvas) - expect(h.state.zoom.value).toBeLessThanOrEqual(0.5); - - // FIXME I think this should be [-100, -100] so we may have a bug in our zoom - // hadnling, alas - expect(h.state.scrollX).toBe(25); - expect(h.state.scrollY).toBe(25); - await waitForNextAnimationFrame(); const prevScrollX = h.state.scrollX; diff --git a/src/utils.ts b/src/utils.ts index 40bff9591..c644efd81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -197,68 +197,134 @@ export const throttleRAF = ( * @param {number} k - The value to be tweened. * @returns {number} The tweened value. */ -function easeOut(k: number): number { +export const easeOut = (k: number) => { return 1 - Math.pow(1 - k, 4); -} +}; + +const easeOutInterpolate = (from: number, to: number, progress: number) => { + return (to - from) * easeOut(progress) + from; +}; /** - * Compute new values based on the same ease function and trigger the - * callback through a requestAnimationFrame call + * Animates values from `fromValues` to `toValues` using the requestAnimationFrame API. + * Executes the `onStep` callback on each step with the interpolated values. + * Returns a function that can be called to cancel the animation. * - * use `opts` to define a duration and/or an easeFn + * @example + * // Example usage: + * const fromValues = { x: 0, y: 0 }; + * const toValues = { x: 100, y: 200 }; + * const onStep = ({x, y}) => { + * setState(x, y) + * }; + * const onCancel = () => { + * console.log("Animation canceled"); + * }; * - * for example: - * ```ts - * easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c)) - * ``` + * const cancelAnimation = easeToValuesRAF({ + * fromValues, + * toValues, + * onStep, + * onCancel, + * }); * - * @param fromValues The initial values, must be numeric - * @param toValues The destination values, must also be numeric - * @param callback The callback receiving the values - * @param opts default to 250ms duration and the easeOut function + * // To cancel the animation: + * cancelAnimation(); */ -export const easeToValuesRAF = ( - fromValues: number[], - toValues: number[], - callback: (...values: number[]) => void, - opts?: { duration?: number; easeFn?: (value: number) => number }, -) => { +export const easeToValuesRAF = < + T extends Record, + K extends keyof T, +>({ + fromValues, + toValues, + onStep, + duration = 250, + interpolateValue, + onStart, + onEnd, + onCancel, +}: { + fromValues: T; + toValues: T; + /** + * Interpolate a single value. + * Return undefined to be handled by the default interpolator. + */ + interpolateValue?: ( + fromValue: number, + toValue: number, + /** no easing applied */ + progress: number, + key: K, + ) => number | undefined; + onStep: (values: T) => void; + duration?: number; + onStart?: () => void; + onEnd?: () => void; + onCancel?: () => void; +}) => { let canceled = false; let frameId = 0; let startTime: number; - const duration = opts?.duration || 250; // default animation to 0.25 seconds - const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut - function step(timestamp: number) { if (canceled) { return; } if (startTime === undefined) { startTime = timestamp; + onStart?.(); } - const elapsed = timestamp - startTime; + const elapsed = Math.min(timestamp - startTime, duration); + const factor = easeOut(elapsed / duration); + + const newValues = {} as T; + + Object.keys(fromValues).forEach((key) => { + const _key = key as keyof T; + const result = ((toValues[_key] - fromValues[_key]) * factor + + fromValues[_key]) as T[keyof T]; + newValues[_key] = result; + }); + + onStep(newValues); if (elapsed < duration) { - // console.log(elapsed, duration, elapsed / duration); - const factor = easeFn(elapsed / duration); - const newValues = fromValues.map( - (fromValue, index) => - (toValues[index] - fromValue) * factor + fromValue, - ); + const progress = elapsed / duration; + + const newValues = {} as T; + + Object.keys(fromValues).forEach((key) => { + const _key = key as K; + const startValue = fromValues[_key]; + const endValue = toValues[_key]; + + let result; + + result = interpolateValue + ? interpolateValue(startValue, endValue, progress, _key) + : easeOutInterpolate(startValue, endValue, progress); + + if (result == null) { + result = easeOutInterpolate(startValue, endValue, progress); + } + + newValues[_key] = result as T[K]; + }); + onStep(newValues); - callback(...newValues); frameId = window.requestAnimationFrame(step); } else { - // ensure final values are reached at the end of the transition - callback(...toValues); + onStep(toValues); + onEnd?.(); } } frameId = window.requestAnimationFrame(step); return () => { + onCancel?.(); canceled = true; window.cancelAnimationFrame(frameId); }; From 3ddcc48e4c192f525930e503b3e9214627f0043d Mon Sep 17 00:00:00 2001 From: zsviczian Date: Thu, 29 Jun 2023 12:39:44 +0200 Subject: [PATCH 03/55] fix: UI disappears when pressing the eyedropper shortcut on mobile (#6725) --- src/components/LayerUI.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 04cba8625..007073760 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -399,7 +399,7 @@ const LayerUI = ({ } /> )} - {device.isMobile && !eyeDropperState && ( + {device.isMobile && ( Date: Sat, 8 Jul 2023 23:33:34 +0200 Subject: [PATCH 04/55] feat: make `appState.selectedElementIds` more stable (#6745) --- src/actions/actionBoundText.tsx | 3 +- src/actions/actionDuplicateSelection.tsx | 1 + src/actions/actionFinalize.tsx | 7 - src/actions/actionGroup.tsx | 14 +- src/actions/actionSelectAll.ts | 1 + src/components/App.tsx | 335 +++++++++++------- src/excalidraw-app/debug.ts | 135 +++++++ src/groups.ts | 16 +- src/scene/selection.test.ts | 35 ++ src/scene/selection.ts | 56 ++- .../__snapshots__/contextmenu.test.tsx.snap | 13 - .../regressionTests.test.tsx.snap | 137 +------ src/tests/flip.test.tsx | 40 ++- src/tests/zindex.test.tsx | 1 + src/types.ts | 4 +- 15 files changed, 503 insertions(+), 295 deletions(-) create mode 100644 src/excalidraw-app/debug.ts create mode 100644 src/scene/selection.test.ts diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index c8a1faac3..06f2acda1 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -31,6 +31,7 @@ import { } from "../element/types"; import { getSelectedElements } from "../scene"; import { AppState } from "../types"; +import { Mutable } from "../utility-types"; import { getFontString } from "../utils"; import { register } from "./register"; @@ -211,7 +212,7 @@ export const actionWrapTextInContainer = register({ appState, ); let updatedElements: readonly ExcalidrawElement[] = elements.slice(); - const containerIds: AppState["selectedElementIds"] = {}; + const containerIds: Mutable = {}; for (const textElement of selectedElements) { if (isTextElement(textElement)) { diff --git a/src/actions/actionDuplicateSelection.tsx b/src/actions/actionDuplicateSelection.tsx index ae2d6f7bc..181e70e7d 100644 --- a/src/actions/actionDuplicateSelection.tsx +++ b/src/actions/actionDuplicateSelection.tsx @@ -274,6 +274,7 @@ const duplicateElements = ( ), }, getNonDeletedElements(finalElements), + appState, ), }; }; diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx index 3508de0ad..99c8d8cf5 100644 --- a/src/actions/actionFinalize.tsx +++ b/src/actions/actionFinalize.tsx @@ -125,13 +125,6 @@ export const actionFinalize = register({ { x, y }, ); } - - if ( - !appState.activeTool.locked && - appState.activeTool.type !== "freedraw" - ) { - appState.selectedElementIds[multiPointElement.id] = true; - } } if ( diff --git a/src/actions/actionGroup.tsx b/src/actions/actionGroup.tsx index 5f22e3aed..ddd1a6559 100644 --- a/src/actions/actionGroup.tsx +++ b/src/actions/actionGroup.tsx @@ -218,6 +218,7 @@ export const actionUngroup = register({ const updateAppState = selectGroupsForSelectedElements( { ...appState, selectedGroupIds: {} }, getNonDeletedElements(nextElements), + appState, ); frames.forEach((frame) => { @@ -232,9 +233,18 @@ export const actionUngroup = register({ }); // remove binded text elements from selection - boundTextElementIds.forEach( - (id) => (updateAppState.selectedElementIds[id] = false), + updateAppState.selectedElementIds = Object.entries( + updateAppState.selectedElementIds, + ).reduce( + (acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => { + if (selected && !boundTextElementIds.includes(id)) { + acc[id] = true; + } + return acc; + }, + {}, ); + return { appState: updateAppState, elements: nextElements, diff --git a/src/actions/actionSelectAll.ts b/src/actions/actionSelectAll.ts index 40e7f0415..6ba78b939 100644 --- a/src/actions/actionSelectAll.ts +++ b/src/actions/actionSelectAll.ts @@ -41,6 +41,7 @@ export const actionSelectAll = register({ selectedElementIds, }, getNonDeletedElements(elements), + appState, ), commitToHistory: true, }; diff --git a/src/components/App.tsx b/src/components/App.tsx index 3ccfa1246..887392e20 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -315,7 +315,10 @@ import { updateFrameMembershipOfSelectedElements, isElementInFrame, } from "../frame"; -import { excludeElementsInFramesFromSelection } from "../scene/selection"; +import { + excludeElementsInFramesFromSelection, + makeNextSelectedElementIds, +} from "../scene/selection"; import { actionPaste } from "../actions/actionClipboard"; import { actionRemoveAllElementsFromFrame, @@ -1353,6 +1356,7 @@ class App extends React.Component { this.scene.destroy(); this.library.destroy(); clearTimeout(touchTimeout); + isSomeElementSelected.clearCache(); touchTimeout = 0; } @@ -1825,7 +1829,7 @@ class App extends React.Component { if (event.touches.length === 2) { this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), }); } }; @@ -1835,7 +1839,10 @@ class App extends React.Component { if (event.touches.length > 0) { this.setState({ previousSelectedElementIds: {}, - selectedElementIds: this.state.previousSelectedElementIds, + selectedElementIds: makeNextSelectedElementIds( + this.state.previousSelectedElementIds, + this.state, + ), }); } else { gesture.pointers.clear(); @@ -1895,7 +1902,14 @@ class App extends React.Component { const imageElement = this.createImageElement({ sceneX, sceneY }); this.insertImageElement(imageElement, file); this.initializeImageDimensions(imageElement); - this.setState({ selectedElementIds: { [imageElement.id]: true } }); + this.setState({ + selectedElementIds: makeNextSelectedElementIds( + { + [imageElement.id]: true, + }, + this.state, + ), + }); return; } @@ -2032,6 +2046,7 @@ class App extends React.Component { selectedGroupIds: {}, }, this.scene.getNonDeletedElements(), + this.state, ), () => { if (opts.files) { @@ -2130,8 +2145,9 @@ class App extends React.Component { } this.setState({ - selectedElementIds: Object.fromEntries( - textElements.map((el) => [el.id, true]), + selectedElementIds: makeNextSelectedElementIds( + Object.fromEntries(textElements.map((el) => [el.id, true])), + this.state, ), }); @@ -2749,7 +2765,7 @@ class App extends React.Component { } else { setCursorForShape(this.canvas, this.state); this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -2794,7 +2810,7 @@ class App extends React.Component { if (nextActiveTool.type !== "selection") { this.setState({ activeTool: nextActiveTool, - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -2831,7 +2847,7 @@ class App extends React.Component { // elements by mistake while zooming if (this.isTouchScreenMultiTouchGesture()) { this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), }); } gesture.initialScale = this.state.zoom.value; @@ -2876,7 +2892,10 @@ class App extends React.Component { if (this.isTouchScreenMultiTouchGesture()) { this.setState({ previousSelectedElementIds: {}, - selectedElementIds: this.state.previousSelectedElementIds, + selectedElementIds: makeNextSelectedElementIds( + this.state.previousSelectedElementIds, + this.state, + ), }); } gesture.initialScale = null; @@ -2941,10 +2960,13 @@ class App extends React.Component { ? element.containerId : element.id; this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [elementIdToSelect]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [elementIdToSelect]: true, + }, + prevState, + ), })); } if (isDeleted) { @@ -2980,7 +3002,7 @@ class App extends React.Component { private deselectElements() { this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -3291,6 +3313,7 @@ class App extends React.Component { selectedGroupIds: {}, }, this.scene.getNonDeletedElements(), + prevState, ), ); return; @@ -3998,12 +4021,15 @@ class App extends React.Component { editingElement: null, startBoundElement: null, suggestedBindings: [], - selectedElementIds: Object.keys(this.state.selectedElementIds) - .filter((key) => key !== element.id) - .reduce((obj: { [id: string]: boolean }, key) => { - obj[key] = this.state.selectedElementIds[key]; - return obj; - }, {}), + selectedElementIds: makeNextSelectedElementIds( + Object.keys(this.state.selectedElementIds) + .filter((key) => key !== element.id) + .reduce((obj: { [id: string]: true }, key) => { + obj[key] = this.state.selectedElementIds[key]; + return obj; + }, {}), + this.state, + ), }, }); return; @@ -4472,7 +4498,7 @@ class App extends React.Component { private clearSelectionIfNotUsingSelection = (): void => { if (this.state.activeTool.type !== "selection") { this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -4604,9 +4630,12 @@ class App extends React.Component { if (this.state.editingLinearElement) { this.setState({ - selectedElementIds: { - [this.state.editingLinearElement.elementId]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + [this.state.editingLinearElement.elementId]: true, + }, + this.state, + ), }); // If we click on something } else if (hitElement != null) { @@ -4634,7 +4663,7 @@ class App extends React.Component { !isElementInGroup(hitElement, this.state.editingGroupId) ) { this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -4650,7 +4679,7 @@ class App extends React.Component { !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements ) { this.setState((prevState) => { - const nextSelectedElementIds = { + const nextSelectedElementIds: { [id: string]: true } = { ...prevState.selectedElementIds, [hitElement.id]: true, }; @@ -4668,13 +4697,13 @@ class App extends React.Component { previouslySelectedElements, hitElement.id, ).forEach((element) => { - nextSelectedElementIds[element.id] = false; + delete nextSelectedElementIds[element.id]; }); } else if (hitElement.frameId) { // if hitElement is in a frame and its frame has been selected // disable selection for the given element if (nextSelectedElementIds[hitElement.frameId]) { - nextSelectedElementIds[hitElement.id] = false; + delete nextSelectedElementIds[hitElement.id]; } } else { // hitElement is neither a frame nor an element in a frame @@ -4704,7 +4733,7 @@ class App extends React.Component { framesInGroups.has(element.frameId) ) { // deselect element and groups containing the element - nextSelectedElementIds[element.id] = false; + delete nextSelectedElementIds[element.id]; element.groupIds .flatMap((gid) => getElementsInGroup( @@ -4712,10 +4741,9 @@ class App extends React.Component { gid, ), ) - .forEach( - (element) => - (nextSelectedElementIds[element.id] = false), - ); + .forEach((element) => { + delete nextSelectedElementIds[element.id]; + }); } }); } @@ -4728,6 +4756,7 @@ class App extends React.Component { showHyperlinkPopup: hitElement.link ? "info" : false, }, this.scene.getNonDeletedElements(), + prevState, ); }); pointerDownState.hit.wasAddedToSelection = true; @@ -4844,12 +4873,18 @@ class App extends React.Component { frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => ({ - selectedElementIds: { + this.setState((prevState) => { + const nextSelectedElementIds = { ...prevState.selectedElementIds, - [element.id]: false, - }, - })); + }; + delete nextSelectedElementIds[element.id]; + return { + selectedElementIds: makeNextSelectedElementIds( + nextSelectedElementIds, + prevState, + ), + }; + }); const pressures = element.simulatePressure ? element.pressures @@ -4945,10 +4980,13 @@ class App extends React.Component { } this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [multiElement.id]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [multiElement.id]: true, + }, + prevState, + ), })); // clicking outside commit zone → update reference for last committed // point @@ -4999,12 +5037,18 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => ({ - selectedElementIds: { + this.setState((prevState) => { + const nextSelectedElementIds = { ...prevState.selectedElementIds, - [element.id]: false, - }, - })); + }; + delete nextSelectedElementIds[element.id]; + return { + selectedElementIds: makeNextSelectedElementIds( + nextSelectedElementIds, + prevState, + ), + }; + }); mutateElement(element, { points: [...element.points, [0, 0]], }); @@ -5378,15 +5422,16 @@ class App extends React.Component { const oldIdToDuplicatedId = new Map(); const hitElement = pointerDownState.hit.element; const elements = this.scene.getElementsIncludingDeleted(); - const selectedElementIds: Array = + const selectedElementIds = new Set( getSelectedElements(elements, this.state, { includeBoundTextElement: true, includeElementsInFrames: true, - }).map((element) => element.id); + }).map((element) => element.id), + ); for (const element of elements) { if ( - selectedElementIds.includes(element.id) || + selectedElementIds.has(element.id) || // case: the state.selectedElementIds might not have been // updated yet by the time this mousemove event is fired (element.id === hitElement?.id && @@ -5524,14 +5569,9 @@ class App extends React.Component { }, }, this.scene.getNonDeletedElements(), + prevState, ), ); - } else { - this.setState({ - selectedElementIds: {}, - selectedGroupIds: {}, - editingGroupId: null, - }); } } // box-select line editor points @@ -5547,28 +5587,29 @@ class App extends React.Component { elements, draggingElement, ); - this.setState((prevState) => - selectGroupsForSelectedElements( + this.setState((prevState) => { + const nextSelectedElementIds = elementsWithinSelection.reduce( + (acc: Record, element) => { + acc[element.id] = true; + return acc; + }, + {}, + ); + + if (pointerDownState.hit.element) { + // if using ctrl/cmd, select the hitElement only if we + // haven't box-selected anything else + if (!elementsWithinSelection.length) { + nextSelectedElementIds[pointerDownState.hit.element.id] = true; + } else { + delete nextSelectedElementIds[pointerDownState.hit.element.id]; + } + } + + return selectGroupsForSelectedElements( { ...prevState, - selectedElementIds: { - ...prevState.selectedElementIds, - ...elementsWithinSelection.reduce( - (acc: Record, element) => { - acc[element.id] = true; - return acc; - }, - {}, - ), - ...(pointerDownState.hit.element - ? { - // if using ctrl/cmd, select the hitElement only if we - // haven't box-selected anything else - [pointerDownState.hit.element.id]: - !elementsWithinSelection.length, - } - : null), - }, + selectedElementIds: nextSelectedElementIds, showHyperlinkPopup: elementsWithinSelection.length === 1 && elementsWithinSelection[0].link @@ -5585,8 +5626,9 @@ class App extends React.Component { : null, }, this.scene.getNonDeletedElements(), - ), - ); + prevState, + ); + }); } } }); @@ -5780,7 +5822,12 @@ class App extends React.Component { try { this.initializeImageDimensions(imageElement); this.setState( - { selectedElementIds: { [imageElement.id]: true } }, + { + selectedElementIds: makeNextSelectedElementIds( + { [imageElement.id]: true }, + this.state, + ), + }, () => { this.actionManager.executeAction(actionFinalize); }, @@ -5844,10 +5891,13 @@ class App extends React.Component { activeTool: updateActiveTool(this.state, { type: "selection", }), - selectedElementIds: { - ...prevState.selectedElementIds, - [draggingElement.id]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [draggingElement.id]: true, + }, + prevState, + ), selectedLinearElement: new LinearElementEditor( draggingElement, this.scene, @@ -6141,31 +6191,37 @@ class App extends React.Component { if (childEvent.shiftKey && !this.state.editingLinearElement) { if (this.state.selectedElementIds[hitElement.id]) { if (isSelectedViaGroup(this.state, hitElement)) { - // We want to unselect all groups hitElement is part of - // as well as all elements that are part of the groups - // hitElement is part of - const idsOfSelectedElementsThatAreInGroups = hitElement.groupIds - .flatMap((groupId) => - getElementsInGroup( - this.scene.getNonDeletedElements(), - groupId, - ), - ) - .map((element) => ({ [element.id]: false })) - .reduce((prevId, acc) => ({ ...prevId, ...acc }), {}); + this.setState((_prevState) => { + const nextSelectedElementIds = { + ..._prevState.selectedElementIds, + }; - this.setState((_prevState) => ({ - selectedGroupIds: { - ..._prevState.selectedElementIds, - ...hitElement.groupIds - .map((gId) => ({ [gId]: false })) - .reduce((prev, acc) => ({ ...prev, ...acc }), {}), - }, - selectedElementIds: { - ..._prevState.selectedElementIds, - ...idsOfSelectedElementsThatAreInGroups, - }, - })); + // We want to unselect all groups hitElement is part of + // as well as all elements that are part of the groups + // hitElement is part of + for (const groupedElement of hitElement.groupIds.flatMap( + (groupId) => + getElementsInGroup( + this.scene.getNonDeletedElements(), + groupId, + ), + )) { + delete nextSelectedElementIds[groupedElement.id]; + } + + return { + selectedGroupIds: { + ..._prevState.selectedElementIds, + ...hitElement.groupIds + .map((gId) => ({ [gId]: false })) + .reduce((prev, acc) => ({ ...prev, ...acc }), {}), + }, + selectedElementIds: makeNextSelectedElementIds( + nextSelectedElementIds, + _prevState, + ), + }; + }); // if not gragging a linear element point (outside editor) } else if (!this.state.selectedLinearElement?.isDragging) { // remove element from selection while @@ -6174,8 +6230,8 @@ class App extends React.Component { this.setState((prevState) => { const newSelectedElementIds = { ...prevState.selectedElementIds, - [hitElement!.id]: false, }; + delete newSelectedElementIds[hitElement!.id]; const newSelectedElements = getSelectedElements( this.scene.getNonDeletedElements(), { ...prevState, selectedElementIds: newSelectedElementIds }, @@ -6196,6 +6252,7 @@ class App extends React.Component { : prevState.selectedLinearElement, }, this.scene.getNonDeletedElements(), + prevState, ); }); } @@ -6206,21 +6263,23 @@ class App extends React.Component { // when hitElement is part of a selected frame, deselect the frame // to avoid frame and containing elements selected simultaneously this.setState((prevState) => { - const nextSelectedElementIds = { + const nextSelectedElementIds: { + [id: string]: true; + } = { ...prevState.selectedElementIds, [hitElement.id]: true, - // deselect the frame - [hitElement.frameId!]: false, }; + // deselect the frame + delete nextSelectedElementIds[hitElement.frameId!]; // deselect groups containing the frame (this.scene.getElement(hitElement.frameId!)?.groupIds ?? []) .flatMap((gid) => getElementsInGroup(this.scene.getNonDeletedElements(), gid), ) - .forEach( - (element) => (nextSelectedElementIds[element.id] = false), - ); + .forEach((element) => { + delete nextSelectedElementIds[element.id]; + }); return selectGroupsForSelectedElements( { @@ -6229,15 +6288,19 @@ class App extends React.Component { showHyperlinkPopup: hitElement.link ? "info" : false, }, this.scene.getNonDeletedElements(), + prevState, ); }); } else { // add element to selection while keeping prev elements selected this.setState((_prevState) => ({ - selectedElementIds: { - ..._prevState.selectedElementIds, - [hitElement!.id]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + ..._prevState.selectedElementIds, + [hitElement!.id]: true, + }, + _prevState, + ), })); } } else { @@ -6255,6 +6318,7 @@ class App extends React.Component { : prevState.selectedLinearElement, }, this.scene.getNonDeletedElements(), + prevState, ), })); } @@ -6279,7 +6343,7 @@ class App extends React.Component { } else { // Deselect selected elements this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, editingGroupId: null, }); @@ -6290,13 +6354,17 @@ class App extends React.Component { if ( !activeTool.locked && activeTool.type !== "freedraw" && - draggingElement + draggingElement && + draggingElement.type !== "selection" ) { this.setState((prevState) => ({ - selectedElementIds: { - ...prevState.selectedElementIds, - [draggingElement.id]: true, - }, + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [draggingElement.id]: true, + }, + prevState, + ), })); } @@ -6610,7 +6678,10 @@ class App extends React.Component { this.initializeImageDimensions(imageElement); this.setState( { - selectedElementIds: { [imageElement.id]: true }, + selectedElementIds: makeNextSelectedElementIds( + { [imageElement.id]: true }, + this.state, + ), }, () => { this.actionManager.executeAction(actionFinalize); @@ -6837,7 +6908,7 @@ class App extends React.Component { private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedGroupIds: {}, // Continue editing the same group if the user selected a different // element from it @@ -6849,7 +6920,7 @@ class App extends React.Component { : null, })); this.setState({ - selectedElementIds: {}, + selectedElementIds: makeNextSelectedElementIds({}, this.state), previousSelectedElementIds: this.state.selectedElementIds, }); } @@ -6918,7 +6989,12 @@ class App extends React.Component { const imageElement = this.createImageElement({ sceneX, sceneY }); this.insertImageElement(imageElement, file); this.initializeImageDimensions(imageElement); - this.setState({ selectedElementIds: { [imageElement.id]: true } }); + this.setState({ + selectedElementIds: makeNextSelectedElementIds( + { [imageElement.id]: true }, + this.state, + ), + }); return; } @@ -7043,6 +7119,7 @@ class App extends React.Component { : null, }, this.scene.getNonDeletedElements(), + this.state, ) : this.state), showHyperlinkPopup: false, diff --git a/src/excalidraw-app/debug.ts b/src/excalidraw-app/debug.ts new file mode 100644 index 000000000..6e439f1c8 --- /dev/null +++ b/src/excalidraw-app/debug.ts @@ -0,0 +1,135 @@ +declare global { + interface Window { + debug: typeof Debug; + } +} + +const lessPrecise = (num: number, precision = 5) => + parseFloat(num.toPrecision(precision)); + +const getAvgFrameTime = (times: number[]) => + lessPrecise(times.reduce((a, b) => a + b) / times.length); + +const getFps = (frametime: number) => lessPrecise(1000 / frametime); + +export class Debug { + public static DEBUG_LOG_TIMES = true; + + private static TIMES_AGGR: Record = + {}; + private static TIMES_AVG: Record< + string, + { t: number; times: number[]; avg: number | null } + > = {}; + private static LAST_DEBUG_LOG_CALL = 0; + private static DEBUG_LOG_INTERVAL_ID: null | number = null; + + private static setupInterval = () => { + if (Debug.DEBUG_LOG_INTERVAL_ID === null) { + console.info("%c(starting perf recording)", "color: lime"); + Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000); + } + Debug.LAST_DEBUG_LOG_CALL = Date.now(); + }; + + private static debugLogger = () => { + if ( + Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 && + Debug.DEBUG_LOG_INTERVAL_ID !== null + ) { + window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID); + Debug.DEBUG_LOG_INTERVAL_ID = null; + for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) { + if (avg != null) { + console.info( + `%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`, + "color: blue", + ); + } + } + console.info("%c(stopping perf recording)", "color: red"); + Debug.TIMES_AGGR = {}; + Debug.TIMES_AVG = {}; + return; + } + if (Debug.DEBUG_LOG_TIMES) { + for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) { + if (times.length) { + console.info( + name, + lessPrecise(times.reduce((a, b) => a + b)), + times.sort((a, b) => a - b).map((x) => lessPrecise(x)), + ); + Debug.TIMES_AGGR[name] = { t, times: [] }; + } + } + for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) { + if (times.length) { + const avgFrameTime = getAvgFrameTime(times); + console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`); + Debug.TIMES_AVG[name] = { + t, + times: [], + avg: + avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime, + }; + } + } + } + }; + + public static logTime = (time?: number, name = "default") => { + Debug.setupInterval(); + const now = performance.now(); + const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || { + t: 0, + times: [], + }); + if (t) { + times.push(time != null ? time : now - t); + } + Debug.TIMES_AGGR[name].t = now; + }; + public static logTimeAverage = (time?: number, name = "default") => { + Debug.setupInterval(); + const now = performance.now(); + const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || { + t: 0, + times: [], + }); + if (t) { + times.push(time != null ? time : now - t); + } + Debug.TIMES_AVG[name].t = now; + }; + + private static logWrapper = + (type: "logTime" | "logTimeAverage") => + (fn: (...args: T) => R, name = "default") => { + return (...args: T) => { + const t0 = performance.now(); + const ret = fn(...args); + Debug.logTime(performance.now() - t0, name); + return ret; + }; + }; + + public static logTimeWrap = Debug.logWrapper("logTime"); + public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage"); + + public static perfWrap = ( + fn: (...args: T) => R, + name = "default", + ) => { + return (...args: T) => { + // eslint-disable-next-line no-console + console.time(name); + const ret = fn(...args); + // eslint-disable-next-line no-console + console.timeEnd(name); + return ret; + }; + }; +} + +window.debug = Debug; diff --git a/src/groups.ts b/src/groups.ts index eda013737..a3bf134ec 100644 --- a/src/groups.ts +++ b/src/groups.ts @@ -2,6 +2,7 @@ import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types"; import { AppState } from "./types"; import { getSelectedElements } from "./scene"; import { getBoundTextElement } from "./element/textElement"; +import { makeNextSelectedElementIds } from "./scene/selection"; export const selectGroup = ( groupId: GroupId, @@ -67,13 +68,21 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] => export const selectGroupsForSelectedElements = ( appState: AppState, elements: readonly NonDeleted[], + prevAppState: AppState, ): AppState => { let nextAppState: AppState = { ...appState, selectedGroupIds: {} }; const selectedElements = getSelectedElements(elements, appState); if (!selectedElements.length) { - return { ...nextAppState, editingGroupId: null }; + return { + ...nextAppState, + editingGroupId: null, + selectedElementIds: makeNextSelectedElementIds( + nextAppState.selectedElementIds, + prevAppState, + ), + }; } for (const selectedElement of selectedElements) { @@ -91,6 +100,11 @@ export const selectGroupsForSelectedElements = ( } } + nextAppState.selectedElementIds = makeNextSelectedElementIds( + nextAppState.selectedElementIds, + prevAppState, + ); + return nextAppState; }; diff --git a/src/scene/selection.test.ts b/src/scene/selection.test.ts new file mode 100644 index 000000000..644d2129f --- /dev/null +++ b/src/scene/selection.test.ts @@ -0,0 +1,35 @@ +import { makeNextSelectedElementIds } from "./selection"; + +describe("makeNextSelectedElementIds", () => { + const _makeNextSelectedElementIds = ( + selectedElementIds: { [id: string]: true }, + prevSelectedElementIds: { [id: string]: true }, + expectUpdated: boolean, + ) => { + const ret = makeNextSelectedElementIds(selectedElementIds, { + selectedElementIds: prevSelectedElementIds, + }); + expect(ret === selectedElementIds).toBe(expectUpdated); + }; + it("should return prevState selectedElementIds if no change", () => { + _makeNextSelectedElementIds({}, {}, false); + _makeNextSelectedElementIds({ 1: true }, { 1: true }, false); + _makeNextSelectedElementIds( + { 1: true, 2: true }, + { 1: true, 2: true }, + false, + ); + }); + it("should return new selectedElementIds if changed", () => { + // _makeNextSelectedElementIds({ 1: true }, { 1: false }, true); + _makeNextSelectedElementIds({ 1: true }, {}, true); + _makeNextSelectedElementIds({}, { 1: true }, true); + _makeNextSelectedElementIds({ 1: true }, { 2: true }, true); + _makeNextSelectedElementIds({ 1: true }, { 1: true, 2: true }, true); + _makeNextSelectedElementIds( + { 1: true, 2: true }, + { 1: true, 3: true }, + true, + ); + }); +}); diff --git a/src/scene/selection.ts b/src/scene/selection.ts index 5b8cb35b4..bbb629d3c 100644 --- a/src/scene/selection.ts +++ b/src/scene/selection.ts @@ -10,6 +10,7 @@ import { getContainingFrame, getFrameElements, } from "../frame"; +import { isShallowEqual } from "../utils"; /** * Frames and their containing elements are not to be selected at the same time. @@ -88,11 +89,41 @@ export const getElementsWithinSelection = ( return elementsInSelection; }; -export const isSomeElementSelected = ( - elements: readonly NonDeletedExcalidrawElement[], - appState: Pick, -): boolean => - elements.some((element) => appState.selectedElementIds[element.id]); +// FIXME move this into the editor instance to keep utility methods stateless +export const isSomeElementSelected = (function () { + let lastElements: readonly NonDeletedExcalidrawElement[] | null = null; + let lastSelectedElementIds: AppState["selectedElementIds"] | null = null; + let isSelected: boolean | null = null; + + const ret = ( + elements: readonly NonDeletedExcalidrawElement[], + appState: Pick, + ): boolean => { + if ( + isSelected != null && + elements === lastElements && + appState.selectedElementIds === lastSelectedElementIds + ) { + return isSelected; + } + + isSelected = elements.some( + (element) => appState.selectedElementIds[element.id], + ); + lastElements = elements; + lastSelectedElementIds = appState.selectedElementIds; + + return isSelected; + }; + + ret.clearCache = () => { + lastElements = null; + lastSelectedElementIds = null; + isSelected = null; + }; + + return ret; +})(); /** * Returns common attribute (picked by `getAttribute` callback) of selected @@ -161,3 +192,18 @@ export const getTargetElements = ( : getSelectedElements(elements, appState, { includeBoundTextElement: true, }); + +/** + * returns prevState's selectedElementids if no change from previous, so as to + * retain reference identity for memoization + */ +export const makeNextSelectedElementIds = ( + nextSelectedElementIds: AppState["selectedElementIds"], + prevState: Pick, +) => { + if (isShallowEqual(prevState.selectedElementIds, nextSelectedElementIds)) { + return prevState.selectedElementIds; + } + + return nextSelectedElementIds; +}; diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index e2ed78b37..4c67a4c79 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -2179,7 +2179,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { @@ -2413,7 +2412,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "selectedGroupIds": Object { "id3": true, @@ -4171,7 +4169,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -4399,7 +4396,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "selectedGroupIds": Object { "id3": true, @@ -4479,7 +4475,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -4892,7 +4887,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -4901,8 +4895,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -5469,7 +5461,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -5478,8 +5469,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { @@ -5713,8 +5702,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 588dff76f..2da3ccc24 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -65,9 +65,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, - "id4": true, - "id6": true, }, "resizingElement": null, "scrollX": 0, @@ -76,7 +73,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id7": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { @@ -443,8 +439,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id3": true, - "id4": true, }, "selectedGroupIds": Object { "id5": true, @@ -618,29 +612,20 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, - "id5": true, }, "resizingElement": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, "selectedElementIds": Object { - "id0": false, "id1": true, - "id2": false, - "id3": true, - "id5": true, - "id6": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { "id0": true, "id1": true, "id2": true, - "id3": true, "id4": false, - "id5": true, }, "selectedLinearElement": null, "selectionElement": null, @@ -1003,7 +988,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, @@ -1179,7 +1163,6 @@ Object { "scrollY": 0, "scrolledOutside": false, "selectedElementIds": Object { - "id12": true, "id7": true, }, "selectedElementsAreBeingDragged": false, @@ -1448,8 +1431,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, @@ -1528,7 +1509,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id5": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -1712,8 +1692,6 @@ Object { "id0": true, "id1": true, "id7": true, - "id8": true, - "id9": true, }, "selectedGroupIds": Object { "id10": true, @@ -1825,7 +1803,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id11": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -1934,7 +1911,6 @@ Object { "editingLinearElement": null, "name": "Untitled-201933152653", "selectedElementIds": Object { - "id12": true, "id7": true, }, "selectedGroupIds": Object {}, @@ -2116,7 +2092,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -2239,7 +2214,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -2347,7 +2321,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -2356,8 +2329,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id3": true, - "id4": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { @@ -2724,8 +2695,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id3": true, - "id4": true, }, "selectedGroupIds": Object { "id5": true, @@ -2904,7 +2873,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -3059,7 +3027,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -3394,7 +3361,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id1": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -3754,7 +3720,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id1": true, - "id3": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -4247,7 +4212,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -4370,7 +4334,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -4478,7 +4441,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id1": true, }, "resizingElement": null, "scrollX": 0, @@ -4486,8 +4448,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id1": true, - "id2": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -4610,7 +4570,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -4654,8 +4613,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id1": true, - "id2": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -4770,7 +4727,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id2": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -5069,7 +5025,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -5522,7 +5477,6 @@ Object { "previousSelectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -5875,7 +5829,6 @@ Object { "previousSelectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -6423,9 +6376,7 @@ Object { "scrollX": 0, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id1": true, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -7157,7 +7108,6 @@ Object { "previousSelectedElementIds": Object { "id0": true, "id1": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -7166,8 +7116,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -7395,8 +7343,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -7536,9 +7482,7 @@ Object { "scrollX": 0, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id7": false, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -9539,9 +9483,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "name": "Untitled-201933152653", - "selectedElementIds": Object { - "id7": false, - }, + "selectedElementIds": Object {}, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", }, @@ -9948,7 +9890,6 @@ Object { "previousSelectedElementIds": Object { "id0": true, "id2": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -9956,7 +9897,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id1": true, - "id4": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -10380,7 +10320,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -10389,8 +10328,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -10681,7 +10618,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -10689,7 +10625,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id1": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -10801,7 +10736,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id2": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -10938,7 +10872,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id2": true, }, "resizingElement": null, "scrollX": 0, @@ -10946,8 +10879,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id2": true, - "id3": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -11059,7 +10990,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id2": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -11132,8 +11062,6 @@ Object { "name": "Untitled-201933152653", "selectedElementIds": Object { "id0": true, - "id2": true, - "id3": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -12334,9 +12262,7 @@ Object { "scrollX": 0, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id0": false, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -12435,9 +12361,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "name": "Untitled-201933152653", - "selectedElementIds": Object { - "id0": false, - }, + "selectedElementIds": Object {}, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", }, @@ -13439,9 +13363,7 @@ Object { "scrollX": 0, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id0": false, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -13540,9 +13462,7 @@ Object { "editingGroupId": null, "editingLinearElement": null, "name": "Untitled-201933152653", - "selectedElementIds": Object { - "id0": false, - }, + "selectedElementIds": Object {}, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", }, @@ -13864,7 +13784,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -13874,8 +13793,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, - "id5": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object { @@ -14347,7 +14264,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, @@ -14459,8 +14375,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, - "id5": true, }, "selectedGroupIds": Object { "id4": true, @@ -14727,7 +14641,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object { "id0": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -14735,7 +14648,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id1": true, - "id4": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -15029,9 +14941,7 @@ Object { "scrollX": -2.916666666666668, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id0": true, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -15261,10 +15171,7 @@ Object { "scrollX": 0, "scrollY": 0, "scrolledOutside": false, - "selectedElementIds": Object { - "id0": false, - "id1": true, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, @@ -15451,8 +15358,6 @@ Object { "previousSelectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -15461,9 +15366,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, - "id4": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -15691,9 +15593,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, - "id4": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -15832,7 +15731,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -15842,7 +15740,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id5": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -16204,7 +16101,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, @@ -16316,7 +16212,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id5": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -16487,7 +16382,6 @@ Object { "scrolledOutside": false, "selectedElementIds": Object { "id0": true, - "id1": true, }, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, @@ -16728,7 +16622,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id11": true, "id5": true, "id6": true, }, @@ -17036,8 +16929,6 @@ Object { "selectedElementIds": Object { "id0": true, "id1": true, - "id2": true, - "id3": true, }, "selectedGroupIds": Object { "id4": true, @@ -17356,8 +17247,6 @@ Object { "selectedElementIds": Object { "id5": true, "id6": true, - "id7": true, - "id8": true, }, "selectedGroupIds": Object { "id9": true, @@ -18309,7 +18198,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id4": true, }, "selectedGroupIds": Object {}, "viewBackgroundColor": "#ffffff", @@ -18418,7 +18306,6 @@ Object { "selectedElementIds": Object { "id0": true, "id2": true, - "id4": true, }, "selectedGroupIds": Object { "id5": true, @@ -18532,7 +18419,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id7": true, }, "selectedGroupIds": Object { "id3": true, @@ -18737,7 +18623,6 @@ Object { "previousSelectedElementIds": Object { "id1": true, "id2": true, - "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -19552,9 +19437,7 @@ Object { "scrollX": 10, "scrollY": -10, "scrolledOutside": false, - "selectedElementIds": Object { - "id0": true, - }, + "selectedElementIds": Object {}, "selectedElementsAreBeingDragged": false, "selectedGroupIds": Object {}, "selectedLinearElement": null, diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index 091d1c73b..c90c07834 100644 --- a/src/tests/flip.test.tsx +++ b/src/tests/flip.test.tsx @@ -430,7 +430,10 @@ describe("arrow", () => { const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); h.app.scene.replaceAllElements([line]); - h.app.state.selectedElementIds[line.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [line.id]: true, + }; mutateElement(line, { angle: originalAngle, }); @@ -446,7 +449,10 @@ describe("arrow", () => { const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); h.app.scene.replaceAllElements([line]); - h.app.state.selectedElementIds[line.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [line.id]: true, + }; mutateElement(line, { angle: originalAngle, }); @@ -616,7 +622,10 @@ describe("line", () => { const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); h.app.scene.replaceAllElements([line]); - h.app.state.selectedElementIds[line.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [line.id]: true, + }; mutateElement(line, { angle: originalAngle, }); @@ -632,7 +641,10 @@ describe("line", () => { const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); h.app.scene.replaceAllElements([line]); - h.app.state.selectedElementIds[line.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [line.id]: true, + }; mutateElement(line, { angle: originalAngle, }); @@ -659,14 +671,20 @@ describe("freedraw", () => { it("flips an unrotated drawing horizontally correctly", async () => { const draw = createAndReturnOneDraw(); // select draw, since not done automatically - h.state.selectedElementIds[draw.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [draw.id]: true, + }; await checkHorizontalFlip(); }); it("flips an unrotated drawing vertically correctly", async () => { const draw = createAndReturnOneDraw(); // select draw, since not done automatically - h.state.selectedElementIds[draw.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [draw.id]: true, + }; await checkVerticalFlip(); }); @@ -676,7 +694,10 @@ describe("freedraw", () => { const draw = createAndReturnOneDraw(originalAngle); // select draw, since not done automatically - h.state.selectedElementIds[draw.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [draw.id]: true, + }; await checkRotatedHorizontalFlip(expectedAngle); }); @@ -687,7 +708,10 @@ describe("freedraw", () => { const draw = createAndReturnOneDraw(originalAngle); // select draw, since not done automatically - h.state.selectedElementIds[draw.id] = true; + h.state.selectedElementIds = { + ...h.state.selectedElementIds, + [draw.id]: true, + }; await checkRotatedVerticalFlip(expectedAngle); }); diff --git a/src/tests/zindex.test.tsx b/src/tests/zindex.test.tsx index a59af77cf..de3292af6 100644 --- a/src/tests/zindex.test.tsx +++ b/src/tests/zindex.test.tsx @@ -89,6 +89,7 @@ const populateElements = ( ...selectGroupsForSelectedElements( { ...h.state, ...appState, selectedElementIds }, h.elements, + h.state, ), ...appState, selectedElementIds, diff --git a/src/types.ts b/src/types.ts index fd00578b8..bb641088a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,8 +181,8 @@ export type AppState = { defaultSidebarDockedPreference: boolean; lastPointerDownWith: PointerType; - selectedElementIds: { [id: string]: boolean }; - previousSelectedElementIds: { [id: string]: boolean }; + selectedElementIds: Readonly<{ [id: string]: true }>; + previousSelectedElementIds: { [id: string]: true }; selectedElementsAreBeingDragged: boolean; shouldCacheIgnoreZoom: boolean; toast: { message: string; closable?: boolean; duration?: number } | null; From cf0413338e0e2bc22397a94138ece91a4e001a49 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Mon, 10 Jul 2023 17:13:44 +0200 Subject: [PATCH 05/55] feat: support customizing what parts of frames are rendered (#6752) --- src/actions/actionFrame.ts | 13 +- src/actions/types.ts | 2 +- src/appState.ts | 4 +- src/components/App.tsx | 25 +- src/renderer/renderElement.ts | 6 +- src/renderer/renderScene.ts | 4 +- .../__snapshots__/contextmenu.test.tsx.snap | 119 +++++- .../regressionTests.test.tsx.snap | 371 +++++++++++++++--- .../packages/__snapshots__/utils.test.ts.snap | 7 +- src/types.ts | 9 +- 10 files changed, 472 insertions(+), 88 deletions(-) diff --git a/src/actions/actionFrame.ts b/src/actions/actionFrame.ts index 980d0f7d0..054e51011 100644 --- a/src/actions/actionFrame.ts +++ b/src/actions/actionFrame.ts @@ -90,8 +90,8 @@ export const actionRemoveAllElementsFromFrame = register({ predicate: (elements, appState) => isSingleFrameSelected(elements, appState), }); -export const actionToggleFrameRendering = register({ - name: "toggleFrameRendering", +export const actionupdateFrameRendering = register({ + name: "updateFrameRendering", viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => { @@ -99,13 +99,16 @@ export const actionToggleFrameRendering = register({ elements, appState: { ...appState, - shouldRenderFrames: !appState.shouldRenderFrames, + frameRendering: { + ...appState.frameRendering, + enabled: !appState.frameRendering.enabled, + }, }, commitToHistory: false, }; }, - contextItemLabel: "labels.toggleFrameRendering", - checked: (appState: AppState) => appState.shouldRenderFrames, + contextItemLabel: "labels.updateFrameRendering", + checked: (appState: AppState) => appState.frameRendering.enabled, }); export const actionSetFrameAsActiveTool = register({ diff --git a/src/actions/types.ts b/src/actions/types.ts index bd81fa559..ab20a896b 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -119,7 +119,7 @@ export type ActionName = | "toggleHandTool" | "selectAllElementsInFrame" | "removeAllElementsFromFrame" - | "toggleFrameRendering" + | "updateFrameRendering" | "setFrameAsActiveTool" | "createContainerFromText" | "wrapTextInContainer"; diff --git a/src/appState.ts b/src/appState.ts index aaca09757..104fbcbf5 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -84,7 +84,7 @@ export const getDefaultAppState = (): Omit< showStats: false, startBoundElement: null, suggestedBindings: [], - shouldRenderFrames: true, + frameRendering: { enabled: true, clip: true, name: true, outline: true }, frameToHighlight: null, editingFrame: null, elementsToHighlight: null, @@ -191,7 +191,7 @@ const APP_STATE_STORAGE_CONF = (< showStats: { browser: true, export: false, server: false }, startBoundElement: { browser: false, export: false, server: false }, suggestedBindings: { browser: false, export: false, server: false }, - shouldRenderFrames: { browser: false, export: false, server: false }, + frameRendering: { browser: false, export: false, server: false }, frameToHighlight: { browser: false, export: false, server: false }, editingFrame: { browser: false, export: false, server: false }, elementsToHighlight: { browser: false, export: false, server: false }, diff --git a/src/components/App.tsx b/src/components/App.tsx index 887392e20..611ff47b4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -505,7 +505,7 @@ class App extends React.Component { setActiveTool: this.setActiveTool, setCursor: this.setCursor, resetCursor: this.resetCursor, - toggleFrameRendering: this.toggleFrameRendering, + updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, } as const; if (typeof excalidrawRef === "function") { @@ -651,7 +651,7 @@ class App extends React.Component { }; private renderFrameNames = () => { - if (!this.state.shouldRenderFrames) { + if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) { return null; } @@ -2208,10 +2208,23 @@ class App extends React.Component { }); }; - toggleFrameRendering = () => { + updateFrameRendering = ( + opts: + | Partial + | (( + prevState: AppState["frameRendering"], + ) => Partial), + ) => { this.setState((prevState) => { + const next = + typeof opts === "function" ? opts(prevState.frameRendering) : opts; return { - shouldRenderFrames: !prevState.shouldRenderFrames, + frameRendering: { + enabled: next?.enabled ?? prevState.frameRendering.enabled, + clip: next?.clip ?? prevState.frameRendering.clip, + name: next?.name ?? prevState.frameRendering.name, + outline: next?.outline ?? prevState.frameRendering.outline, + }, }; }); }; @@ -3089,7 +3102,9 @@ 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); - return containingFrame && this.state.shouldRenderFrames + return containingFrame && + this.state.frameRendering.enabled && + this.state.frameRendering.clip ? isCursorInFrame({ x, y }, containingFrame) : true; }); diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 0efe5df96..ff47d4359 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -931,7 +931,11 @@ export const renderElement = ( break; } case "frame": { - if (!renderConfig.isExporting && appState.shouldRenderFrames) { + if ( + !renderConfig.isExporting && + appState.frameRendering.enabled && + appState.frameRendering.outline + ) { context.save(); context.translate( element.x + renderConfig.scrollX, diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index eac3d3c57..1344329e5 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -470,7 +470,9 @@ export const _renderScene = ({ if ( frameId && ((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) || - (!renderConfig.isExporting && appState.shouldRenderFrames)) + (!renderConfig.isExporting && + appState.frameRendering.enabled && + appState.frameRendering.clip)) ) { context.save(); diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 4c67a4c79..583c809a2 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -314,6 +314,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -353,7 +359,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -501,6 +506,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -537,7 +548,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -694,6 +704,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -730,7 +746,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -1061,6 +1076,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -1097,7 +1118,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -1428,6 +1448,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -1464,7 +1490,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -1621,6 +1646,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -1655,7 +1686,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -1851,6 +1881,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -1887,7 +1923,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -2146,6 +2181,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -2187,7 +2228,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -2529,6 +2569,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -2565,7 +2611,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -3402,6 +3447,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -3438,7 +3489,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -3769,6 +3819,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -3805,7 +3861,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4136,6 +4191,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -4175,7 +4236,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4862,6 +4922,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -4901,7 +4967,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -5436,6 +5501,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -5477,7 +5548,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -5934,6 +6004,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -5968,7 +6044,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -6324,6 +6399,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -6360,7 +6441,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -6692,6 +6772,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 100, @@ -6728,7 +6814,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 2da3ccc24..fd2c2f5d7 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -38,6 +38,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -81,7 +87,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -585,6 +590,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -630,7 +641,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -1134,6 +1144,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -1170,7 +1186,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -2060,6 +2075,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -2098,7 +2119,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -2296,6 +2316,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -2337,7 +2363,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -2841,6 +2866,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -2879,7 +2910,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -3138,6 +3168,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -3174,7 +3210,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -3329,6 +3364,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -3367,7 +3408,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -3860,6 +3900,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -3896,7 +3942,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4180,6 +4225,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -4218,7 +4269,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4416,6 +4466,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -4454,7 +4510,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4695,6 +4750,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -4733,7 +4794,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -4992,6 +5052,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -5031,7 +5097,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -5451,6 +5516,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -5516,7 +5587,6 @@ Object { "y": 500, }, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -5803,6 +5873,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -5840,7 +5916,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -6127,6 +6202,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -6191,7 +6272,6 @@ Object { "y": 110, }, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -6346,6 +6426,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -6382,7 +6468,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -6537,6 +6622,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -6573,7 +6664,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -7082,6 +7172,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -7122,7 +7218,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -7454,6 +7549,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -7488,7 +7589,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -9864,6 +9964,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -9903,7 +10009,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -10295,6 +10400,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -10334,7 +10445,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -10593,6 +10703,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -10631,7 +10747,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -10847,6 +10962,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -10885,7 +11006,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -11173,6 +11293,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -11209,7 +11335,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -11364,6 +11489,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -11400,7 +11531,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -11555,6 +11685,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -11591,7 +11727,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -11746,6 +11881,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -11805,7 +11946,6 @@ Object { }, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -11990,6 +12130,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -12049,7 +12195,6 @@ Object { }, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -12234,6 +12379,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -12268,7 +12419,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -12465,6 +12615,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -12524,7 +12680,6 @@ Object { }, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -12709,6 +12864,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -12745,7 +12906,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -12900,6 +13060,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -12959,7 +13125,6 @@ Object { }, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -13144,6 +13309,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -13180,7 +13351,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -13335,6 +13505,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -13369,7 +13545,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -13566,6 +13741,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -13602,7 +13783,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -13757,6 +13937,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -13801,7 +13987,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -14616,6 +14801,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -14654,7 +14845,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -14913,6 +15103,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -14947,7 +15143,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": true, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -15027,6 +15222,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -15061,7 +15262,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -15141,6 +15341,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -15177,7 +15383,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -15332,6 +15537,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -15372,7 +15583,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -15704,6 +15914,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -15746,7 +15962,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -16352,6 +16567,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -16388,7 +16609,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -16586,6 +16806,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -16632,7 +16858,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -17573,6 +17798,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -17607,7 +17838,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -17687,6 +17917,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -17725,7 +17961,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -18597,6 +18832,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -18664,7 +18905,6 @@ Object { "y": 0, }, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -19084,6 +19324,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -19150,7 +19396,6 @@ Object { "y": 0, }, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -19409,6 +19654,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -19443,7 +19694,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": true, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -19523,6 +19773,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -19559,7 +19815,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -20111,6 +20366,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -20145,7 +20406,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, @@ -20225,6 +20485,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "height": 768, @@ -20259,7 +20525,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": true, diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index b0fd58fca..34085498f 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -39,6 +39,12 @@ Object { "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "frameRendering": Object { + "clip": true, + "enabled": true, + "name": true, + "outline": true, + }, "frameToHighlight": null, "gridSize": null, "isBindingEnabled": true, @@ -70,7 +76,6 @@ Object { "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, - "shouldRenderFrames": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": false, diff --git a/src/types.ts b/src/types.ts index bb641088a..40f54831d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,7 +115,12 @@ export type AppState = { startBoundElement: NonDeleted | null; suggestedBindings: SuggestedBinding[]; frameToHighlight: NonDeleted | null; - shouldRenderFrames: boolean; + frameRendering: { + enabled: boolean; + name: boolean; + outline: boolean; + clip: boolean; + }; editingFrame: string | null; elementsToHighlight: NonDeleted[] | null; // element being edited, but not necessarily added to elements array yet @@ -543,7 +548,7 @@ export type ExcalidrawImperativeAPI = { * the frames are still interactive in edit mode. As such, this API should be * used in conjunction with view mode (props.viewModeEnabled). */ - toggleFrameRendering: InstanceType["toggleFrameRendering"]; + updateFrameRendering: InstanceType["updateFrameRendering"]; }; export type Device = Readonly<{ From 2e46e27490b08a5da66e8e3b64e3c7251f2a5e09 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 14 Jul 2023 20:21:02 +0200 Subject: [PATCH 06/55] fix: use actual dock state to not close docked library on insert (#6766) --- src/components/App.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 611ff47b4..7f747dbd4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -330,6 +330,7 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { activeEyeDropperAtom } from "./EyeDropper"; +import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -473,8 +474,6 @@ class App extends React.Component { name, width: window.innerWidth, height: window.innerHeight, - showHyperlinkPopup: false, - defaultSidebarDockedPreference: false, }; this.id = nanoid(); @@ -2031,7 +2030,7 @@ class App extends React.Component { openSidebar: this.state.openSidebar && this.device.canDeviceFitSidebar && - this.state.defaultSidebarDockedPreference + jotaiStore.get(isSidebarDockedAtom) ? this.state.openSidebar : null, selectedElementIds: nextElementsToSelect.reduce( From 9f76f8677baa1e95fc1af011e35f31f87631167b Mon Sep 17 00:00:00 2001 From: David Luzar Date: Mon, 17 Jul 2023 01:09:44 +0200 Subject: [PATCH 07/55] feat: cache most of element selection (#6747) --- src/actions/actionAddToLibrary.ts | 15 +- src/actions/actionAlign.tsx | 66 +- src/actions/actionBoundText.tsx | 36 +- src/actions/actionCanvas.tsx | 16 +- src/actions/actionClipboard.tsx | 54 +- src/actions/actionDistribute.tsx | 37 +- src/actions/actionDuplicateSelection.tsx | 1 + src/actions/actionElementLock.ts | 20 +- src/actions/actionFlip.ts | 6 +- src/actions/actionFrame.ts | 33 +- src/actions/actionGroup.tsx | 37 +- src/actions/actionLinearEditor.ts | 30 +- src/actions/actionSelectAll.ts | 1 + src/actions/manager.tsx | 2 + src/actions/types.ts | 3 + src/components/App.tsx | 132 +- src/components/ContextMenu.tsx | 4 +- src/components/HintViewer.tsx | 19 +- src/components/LayerUI.tsx | 5 +- src/components/MobileMenu.tsx | 12 +- src/frame.ts | 9 +- src/groups.ts | 24 +- src/scene/Scene.ts | 93 + .../__snapshots__/contextmenu.test.tsx.snap | 72 +- .../regressionTests.test.tsx.snap | 3476 +---------------- src/tests/zindex.test.tsx | 1 + src/utility-types.ts | 3 + 27 files changed, 452 insertions(+), 3755 deletions(-) diff --git a/src/actions/actionAddToLibrary.ts b/src/actions/actionAddToLibrary.ts index ef69a60de..feb4725e8 100644 --- a/src/actions/actionAddToLibrary.ts +++ b/src/actions/actionAddToLibrary.ts @@ -1,6 +1,4 @@ import { register } from "./register"; -import { getSelectedElements } from "../scene"; -import { getNonDeletedElements } from "../element"; import { deepCopyElement } from "../element/newElement"; import { randomId } from "../random"; import { t } from "../i18n"; @@ -9,14 +7,11 @@ export const actionAddToLibrary = register({ name: "addToLibrary", trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { - const selectedElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - { - includeBoundTextElement: true, - includeElementsInFrames: true, - }, - ); + const selectedElements = app.scene.getSelectedElements({ + selectedElementIds: appState.selectedElementIds, + includeBoundTextElement: true, + includeElementsInFrames: true, + }); if (selectedElements.some((element) => element.type === "image")) { return { commitToHistory: false, diff --git a/src/actions/actionAlign.tsx b/src/actions/actionAlign.tsx index d917f8037..5697a707e 100644 --- a/src/actions/actionAlign.tsx +++ b/src/actions/actionAlign.tsx @@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; -import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { AppState } from "../types"; +import { isSomeElementSelected } from "../scene"; +import { AppClassProperties, AppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; const alignActionsPredicate = ( elements: readonly ExcalidrawElement[], appState: AppState, + _: unknown, + app: AppClassProperties, ) => { - const selectedElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); + const selectedElements = app.scene.getSelectedElements(appState); return ( selectedElements.length > 1 && // TODO enable aligning frames when implemented properly @@ -36,12 +35,10 @@ const alignActionsPredicate = ( const alignSelectedElements = ( elements: readonly ExcalidrawElement[], appState: Readonly, + app: AppClassProperties, alignment: Alignment, ) => { - const selectedElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); + const selectedElements = app.scene.getSelectedElements(appState); const updatedElements = alignElements(selectedElements, alignment); @@ -50,6 +47,7 @@ const alignSelectedElements = ( return updateFrameMembershipOfSelectedElements( elements.map((element) => updatedElementsMap.get(element.id) || element), appState, + app, ); }; @@ -57,10 +55,10 @@ export const actionAlignTop = register({ name: "alignTop", trackEvent: { category: "element" }, predicate: alignActionsPredicate, - perform: (elements, appState) => { + perform: (elements, appState, _, app) => { return { appState, - elements: alignSelectedElements(elements, appState, { + elements: alignSelectedElements(elements, appState, app, { position: "start", axis: "y", }), @@ -69,9 +67,9 @@ export const actionAlignTop = register({ }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP, - PanelComponent: ({ elements, appState, updateData }) => ( + PanelComponent: ({ elements, appState, updateData, app }) => (