diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 12d5e2e48..81476e241 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -1,9 +1,13 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { + ExcalidrawElement, + NonDeleted, + NonDeletedElementsMap, +} from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; -import { AppState, PointerDownState } from "../types"; +import { AppState } from "../types"; import { arrayToMap } from "../utils"; import { CODES, KEYS } from "../keys"; import { getCommonBoundingBox } from "../element/bounds"; @@ -20,7 +24,12 @@ export const actionFlipHorizontal = register({ perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "horizontal"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "horizontal", + ), appState, app, ), @@ -38,7 +47,12 @@ export const actionFlipVertical = register({ perform: (elements, appState, _, app) => { return { elements: updateFrameMembershipOfSelectedElements( - flipSelectedElements(elements, appState, "vertical"), + flipSelectedElements( + elements, + app.scene.getNonDeletedElementsMap(), + appState, + "vertical", + ), appState, app, ), @@ -53,6 +67,7 @@ export const actionFlipVertical = register({ const flipSelectedElements = ( elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", ) => { @@ -67,6 +82,7 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, + elementsMap, appState, flipDirection, ); @@ -79,15 +95,17 @@ const flipSelectedElements = ( }; const flipElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + elementsMap: NonDeletedElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { - const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); + const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements); resizeMultipleElements( - { originalElements: arrayToMap(elements) } as PointerDownState, - elements, + elementsMap, + selectedElements, + elementsMap, "nw", true, flipDirection === "horizontal" ? maxX : minX, @@ -96,7 +114,7 @@ const flipElements = ( (isBindingEnabled(appState) ? bindOrUnbindSelectedElements - : unbindLinearElements)(elements); + : unbindLinearElements)(selectedElements); - return elements; + return selectedElements; }; diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 4cddb2ac0..8232db3cd 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -63,11 +63,7 @@ export const actionRemoveAllElementsFromFrame = register({ if (isFrameLikeElement(selectedElement)) { return { - elements: removeAllElementsFromFrame( - elements, - selectedElement, - appState, - ), + elements: removeAllElementsFromFrame(elements, selectedElement), appState: { ...appState, selectedElementIds: { diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index e6cb05840..42bd26efe 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -105,11 +105,7 @@ export const actionGroup = register({ const frameElementsMap = groupByFrameLikes(selectedElements); frameElementsMap.forEach((elementsInFrame, frameId) => { - nextElements = removeElementsFromFrame( - nextElements, - elementsInFrame, - appState, - ); + removeElementsFromFrame(elementsInFrame); }); } @@ -229,7 +225,6 @@ export const actionUngroup = register({ nextElements, getElementsInResizingFrame(nextElements, frame, appState), frame, - appState, ); } }); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 9489970ae..c2a47802f 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1,4 +1,4 @@ -import { AppState, Primitive } from "../types"; +import { AppClassProperties, AppState, Primitive } from "../types"; import { DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS, @@ -66,7 +66,6 @@ import { import { mutateElement, newElementWith } from "../element/mutateElement"; import { getBoundTextElement, - getContainerElement, getDefaultLineHeight, } from "../element/textElement"; import { @@ -189,6 +188,7 @@ const offsetElementAfterFontResize = ( const changeFontSize = ( elements: readonly ExcalidrawElement[], appState: AppState, + app: AppClassProperties, getNewFontSize: (element: ExcalidrawTextElement) => number, fallbackValue?: ExcalidrawTextElement["fontSize"], ) => { @@ -206,7 +206,10 @@ const changeFontSize = ( let newElement: ExcalidrawTextElement = newElementWith(oldElement, { fontSize: newFontSize, }); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -600,8 +603,8 @@ export const actionChangeOpacity = register({ export const actionChangeFontSize = register({ name: "changeFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, () => value, value); + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, () => value, value); }, PanelComponent: ({ elements, appState, updateData }) => (
@@ -663,8 +666,8 @@ export const actionChangeFontSize = register({ export const actionDecreaseFontSize = register({ name: "decreaseFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round( // get previous value before relative increase (doesn't work fully // due to rounding and float precision issues) @@ -685,8 +688,8 @@ export const actionDecreaseFontSize = register({ export const actionIncreaseFontSize = register({ name: "increaseFontSize", trackEvent: false, - perform: (elements, appState, value) => { - return changeFontSize(elements, appState, (element) => + perform: (elements, appState, value, app) => { + return changeFontSize(elements, appState, app, (element) => Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), ); }, @@ -703,7 +706,7 @@ export const actionIncreaseFontSize = register({ export const actionChangeFontFamily = register({ name: "changeFontFamily", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -717,7 +720,10 @@ export const actionChangeFontFamily = register({ lineHeight: getDefaultLineHeight(value), }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } @@ -795,7 +801,7 @@ export const actionChangeFontFamily = register({ export const actionChangeTextAlign = register({ name: "changeTextAlign", trackEvent: false, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -806,7 +812,10 @@ export const actionChangeTextAlign = register({ oldElement, { textAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } @@ -875,7 +884,7 @@ export const actionChangeTextAlign = register({ export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", trackEvent: { category: "element" }, - perform: (elements, appState, value) => { + perform: (elements, appState, value, app) => { return { elements: changeProperty( elements, @@ -887,7 +896,10 @@ export const actionChangeVerticalAlign = register({ { verticalAlign: value }, ); - redrawTextBoundingBox(newElement, getContainerElement(oldElement)); + redrawTextBoundingBox( + newElement, + app.scene.getContainerElement(oldElement), + ); return newElement; } diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index f07664f1a..d67c8893d 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,7 +1,6 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { ActionManager } from "../actions/manager"; -import { getNonDeletedElements } from "../element"; -import { ExcalidrawElement, ExcalidrawElementType } from "../element/types"; +import { ExcalidrawElementType, NonDeletedElementsMap } from "../element/types"; import { t } from "../i18n"; import { useDevice } from "./App"; import { @@ -44,17 +43,14 @@ import { useTunnels } from "../context/tunnels"; export const SelectedShapeActions = ({ appState, - elements, + elementsMap, renderAction, }: { appState: UIAppState; - elements: readonly ExcalidrawElement[]; + elementsMap: NonDeletedElementsMap; renderAction: ActionManager["renderAction"]; }) => { - const targetElements = getTargetElements( - getNonDeletedElements(elements), - appState, - ); + const targetElements = getTargetElements(elementsMap, appState); let isSingleElementBoundContainer = false; if ( @@ -137,12 +133,12 @@ export const SelectedShapeActions = ({ {renderAction("changeFontFamily")} {(appState.activeTool.type === "text" || - suppportsHorizontalAlign(targetElements)) && + suppportsHorizontalAlign(targetElements, elementsMap)) && renderAction("changeTextAlign")} )} - {shouldAllowVerticalAlign(targetElements) && + {shouldAllowVerticalAlign(targetElements, elementsMap) && renderAction("changeVerticalAlign")} {(canHaveArrowheads(appState.activeTool.type) || targetElements.some((element) => canHaveArrowheads(element.type))) && ( diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 77c972882..9e3ff5dac 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1417,7 +1417,7 @@ class App extends React.Component { const { renderTopRightUI, renderCustomStats } = this.props; const versionNonce = this.scene.getVersionNonce(); - const { canvasElements, visibleElements } = + const { elementsMap, visibleElements } = this.renderer.getRenderableElements({ versionNonce, zoom: this.state.zoom, @@ -1627,7 +1627,7 @@ class App extends React.Component { { { private renderInteractiveSceneCallback = ({ atLeastOneVisibleElement, scrollBars, - elements, + elementsMap, }: RenderInteractiveSceneCallback) => { if (scrollBars) { currentScrollBars = scrollBars; @@ -2789,7 +2789,7 @@ class App extends React.Component { // hide when editing text isTextElement(this.state.editingElement) ? false - : !atLeastOneVisibleElement && elements.length > 0; + : !atLeastOneVisibleElement && elementsMap.size > 0; if (this.state.scrolledOutside !== scrolledOutside) { this.setState({ scrolledOutside }); } @@ -3119,7 +3119,10 @@ class App extends React.Component { newElements.forEach((newElement) => { if (isTextElement(newElement) && isBoundToContainer(newElement)) { - const container = getContainerElement(newElement); + const container = getContainerElement( + newElement, + this.scene.getElementsMapIncludingDeleted(), + ); redrawTextBoundingBox(newElement, container); } }); @@ -4183,11 +4186,18 @@ class App extends React.Component { this.scene.replaceAllElements([ ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { - return updateTextElement(_element, { - text, - isDeleted, - originalText, - }); + return updateTextElement( + _element, + getContainerElement( + _element, + this.scene.getElementsMapIncludingDeleted(), + ), + { + text, + isDeleted, + originalText, + }, + ); } return _element; }), @@ -7700,13 +7710,9 @@ class App extends React.Component { groupIds: [], }); - this.scene.replaceAllElements( - removeElementsFromFrame( - this.scene.getElementsIncludingDeleted(), - [linearElement], - this.state, - ), - ); + removeElementsFromFrame([linearElement]); + + this.scene.informMutation(); } } } @@ -7716,7 +7722,7 @@ class App extends React.Component { this.getTopLayerFrameAtSceneCoords(sceneCoords); const selectedElements = this.scene.getSelectedElements(this.state); - let nextElements = this.scene.getElementsIncludingDeleted(); + let nextElements = this.scene.getElementsMapIncludingDeleted(); const updateGroupIdsAfterEditingGroup = ( elements: ExcalidrawElement[], @@ -7809,7 +7815,7 @@ class App extends React.Component { this.scene.replaceAllElements( addElementsToFrame( - this.scene.getElementsIncludingDeleted(), + this.scene.getElementsMapIncludingDeleted(), elementsInsideFrame, draggingElement, ), @@ -7857,7 +7863,6 @@ class App extends React.Component { this.state, ), frame, - this.state, ); } @@ -9137,10 +9142,10 @@ class App extends React.Component { if ( transformElements( - pointerDownState, + pointerDownState.originalElements, transformHandleType, selectedElements, - pointerDownState.resize.arrowDirection, + this.scene.getElementsMapIncludingDeleted(), shouldRotateWithDiscreteAngle(event), shouldResizeFromCenter(event), selectedElements.length === 1 && isImageElement(selectedElements[0]) @@ -9150,7 +9155,6 @@ class App extends React.Component { resizeY, pointerDownState.resize.center.x, pointerDownState.resize.center.y, - this.state, ) ) { this.maybeSuggestBindingForAll(selectedElements); diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 8cedf689d..7247e8bf1 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -226,7 +226,7 @@ const LayerUI = ({ > diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 91d0c518c..98f85a9ac 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -183,7 +183,7 @@ export const MobileMenu = ({
diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 0aaa52c7c..0782b92b9 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -7,6 +7,7 @@ import type { DOMAttributes } from "react"; import type { AppState, InteractiveCanvasAppState } from "../../types"; import type { InteractiveCanvasRenderConfig, + RenderableElementsMap, RenderInteractiveSceneCallback, } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; @@ -15,7 +16,7 @@ import { isRenderThrottlingEnabled } from "../../reactUtils"; type InteractiveCanvasProps = { containerRef: React.RefObject; canvas: HTMLCanvasElement | null; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; versionNonce: number | undefined; @@ -113,7 +114,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { renderInteractiveScene( { canvas: props.canvas, - elements: props.elements, + elementsMap: props.elementsMap, visibleElements: props.visibleElements, selectedElements: props.selectedElements, scale: window.devicePixelRatio, @@ -201,10 +202,10 @@ const areEqual = ( prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.versionNonce !== nextProps.versionNonce || prevProps.scale !== nextProps.scale || - // we need to memoize on element arrays because they may have renewed + // we need to memoize on elementsMap because they may have renewed // even if versionNonce didn't change (e.g. we filter elements out based // on appState) - prevProps.elements !== nextProps.elements || + prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements || prevProps.selectedElements !== nextProps.selectedElements ) { diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index c8174566b..3dc5b9175 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -3,14 +3,17 @@ import { RoughCanvas } from "roughjs/bin/canvas"; import { renderStaticScene } from "../../renderer/renderScene"; import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; -import type { StaticCanvasRenderConfig } from "../../scene/types"; +import type { + RenderableElementsMap, + StaticCanvasRenderConfig, +} from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; type StaticCanvasProps = { canvas: HTMLCanvasElement; rc: RoughCanvas; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; versionNonce: number | undefined; selectionNonce: number | undefined; @@ -63,7 +66,7 @@ const StaticCanvas = (props: StaticCanvasProps) => { canvas, rc: props.rc, scale: props.scale, - elements: props.elements, + elementsMap: props.elementsMap, visibleElements: props.visibleElements, appState: props.appState, renderConfig: props.renderConfig, @@ -106,10 +109,10 @@ const areEqual = ( if ( prevProps.versionNonce !== nextProps.versionNonce || prevProps.scale !== nextProps.scale || - // we need to memoize on element arrays because they may have renewed + // we need to memoize on elementsMap because they may have renewed // even if versionNonce didn't change (e.g. we filter elements out based // on appState) - prevProps.elements !== nextProps.elements || + prevProps.elementsMap !== nextProps.elementsMap || prevProps.visibleElements !== nextProps.visibleElements ) { return false; diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index ff4063593..12e7f1af1 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -40,6 +40,7 @@ import { arrayToMap } from "../utils"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, + getContainerElement, getDefaultLineHeight, measureBaseline, } from "../element/textElement"; @@ -179,7 +180,6 @@ const restoreElementWithProperties = < const restoreElement = ( element: Exclude, - refreshDimensions = false, ): typeof element | null => { switch (element.type) { case "text": @@ -232,10 +232,6 @@ const restoreElement = ( element = bumpVersion(element); } - if (refreshDimensions) { - element = { ...element, ...refreshTextDimensions(element) }; - } - return element; case "freedraw": { return restoreElementWithProperties(element, { @@ -426,10 +422,7 @@ export const restoreElements = ( // filtering out selection, which is legacy, no longer kept in elements, // and causing issues if retained if (element.type !== "selection" && !isInvisiblySmallElement(element)) { - let migratedElement: ExcalidrawElement | null = restoreElement( - element, - opts?.refreshDimensions, - ); + let migratedElement: ExcalidrawElement | null = restoreElement(element); if (migratedElement) { const localElement = localElementsMap?.get(element.id); if (localElement && localElement.version > migratedElement.version) { @@ -462,6 +455,16 @@ export const restoreElements = ( } else if (element.boundElements) { repairContainerElement(element, restoredElementsMap); } + + if (opts.refreshDimensions && isTextElement(element)) { + Object.assign( + element, + refreshTextDimensions( + element, + getContainerElement(element, restoredElementsMap), + ), + ); + } } return restoredElements; diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 292fc995d..673649e5f 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -5,6 +5,7 @@ import { ExcalidrawFreeDrawElement, NonDeleted, ExcalidrawTextElementWithContainer, + ElementsMapOrArray, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; import rough from "roughjs/bin/rough"; @@ -161,7 +162,11 @@ export const getElementAbsoluteCoords = ( includeBoundText, ); } else if (isTextElement(element)) { - const container = getContainerElement(element); + const elementsMap = + Scene.getScene(element)?.getElementsMapIncludingDeleted(); + const container = elementsMap + ? getContainerElement(element, elementsMap) + : null; if (isArrowElement(container)) { const coords = LinearElementEditor.getBoundTextElementPosition( container, @@ -729,10 +734,8 @@ const getLinearElementRotatedBounds = ( export const getElementBounds = (element: ExcalidrawElement): Bounds => { return ElementBounds.getBounds(element); }; -export const getCommonBounds = ( - elements: readonly ExcalidrawElement[], -): Bounds => { - if (!elements.length) { +export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { + if ("size" in elements ? !elements.size : !elements.length) { return [0, 0, 0, 0]; } diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index 025ed4901..f62b0f95f 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -5,17 +5,12 @@ import { ExcalidrawProps } from "../types"; import { getFontString, updateActiveTool } from "../utils"; import { setCursorForShape } from "../cursor"; import { newTextElement } from "./newElement"; -import { getContainerElement, wrapText } from "./textElement"; -import { - isFrameLikeElement, - isIframeElement, - isIframeLikeElement, -} from "./typeChecks"; +import { wrapText } from "./textElement"; +import { isIframeElement } from "./typeChecks"; import { ExcalidrawElement, ExcalidrawIframeLikeElement, IframeData, - NonDeletedExcalidrawElement, } from "./types"; const embeddedLinkCache = new Map(); @@ -217,21 +212,6 @@ export const getEmbedLink = ( return { link, intrinsicSize: aspectRatio, type }; }; -export const isIframeLikeOrItsLabel = ( - element: NonDeletedExcalidrawElement, -): Boolean => { - if (isIframeLikeElement(element)) { - return true; - } - if (element.type === "text") { - const container = getContainerElement(element); - if (container && isFrameLikeElement(container)) { - return true; - } - } - return false; -}; - export const createPlaceholderEmbeddableLabel = ( element: ExcalidrawIframeLikeElement, ): ExcalidrawElement => { diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 00cae296c..3158c064c 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -31,7 +31,6 @@ import { getElementAbsoluteCoords } from "."; import { adjustXYWithRotation } from "../math"; import { getResizedElementAbsoluteCoords } from "./bounds"; import { - getContainerElement, measureText, normalizeText, wrapText, @@ -333,12 +332,12 @@ const getAdjustedDimensions = ( export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, text = textElement.text, ) => { if (textElement.isDeleted) { return; } - const container = getContainerElement(textElement); if (container) { text = wrapText( text, @@ -352,6 +351,7 @@ export const refreshTextDimensions = ( export const updateTextElement = ( textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, { text, isDeleted, @@ -365,7 +365,7 @@ export const updateTextElement = ( return newElementWith(textElement, { originalText, isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, originalText), + ...refreshTextDimensions(textElement, container, originalText), }); }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 883382933..46b891aca 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -15,6 +15,7 @@ import { ExcalidrawElement, ExcalidrawTextElementWithContainer, ExcalidrawImageElement, + ElementsMap, } from "./types"; import type { Mutable } from "../utility-types"; import { @@ -41,7 +42,7 @@ import { MaybeTransformHandleType, TransformHandleDirection, } from "./transformHandles"; -import { AppState, Point, PointerDownState } from "../types"; +import { Point, PointerDownState } from "../types"; import Scene from "../scene/Scene"; import { getApproxMinLineWidth, @@ -68,10 +69,10 @@ export const normalizeAngle = (angle: number): number => { // Returns true when transform (resizing/rotation) happened export const transformElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], transformHandleType: MaybeTransformHandleType, selectedElements: readonly NonDeletedExcalidrawElement[], - resizeArrowDirection: "origin" | "end", + elementsMap: ElementsMap, shouldRotateWithDiscreteAngle: boolean, shouldResizeFromCenter: boolean, shouldMaintainAspectRatio: boolean, @@ -79,7 +80,6 @@ export const transformElements = ( pointerY: number, centerX: number, centerY: number, - appState: AppState, ) => { if (selectedElements.length === 1) { const [element] = selectedElements; @@ -89,7 +89,6 @@ export const transformElements = ( pointerX, pointerY, shouldRotateWithDiscreteAngle, - pointerDownState.originalElements, ); updateBoundElements(element); } else if ( @@ -101,6 +100,7 @@ export const transformElements = ( ) { resizeSingleTextElement( element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -109,9 +109,10 @@ export const transformElements = ( updateBoundElements(element); } else if (transformHandleType) { resizeSingleElement( - pointerDownState.originalElements, + originalElements, shouldMaintainAspectRatio, element, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -123,7 +124,7 @@ export const transformElements = ( } else if (selectedElements.length > 1) { if (transformHandleType === "rotation") { rotateMultipleElements( - pointerDownState, + originalElements, selectedElements, pointerX, pointerY, @@ -139,8 +140,9 @@ export const transformElements = ( transformHandleType === "se" ) { resizeMultipleElements( - pointerDownState, + originalElements, selectedElements, + elementsMap, transformHandleType, shouldResizeFromCenter, pointerX, @@ -157,7 +159,6 @@ const rotateSingleElement = ( pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, - originalElements: Map>, ) => { const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const cx = (x1 + x2) / 2; @@ -207,6 +208,7 @@ const rescalePointsInElement = ( const measureFontSizeFromWidth = ( element: NonDeleted, + elementsMap: ElementsMap, nextWidth: number, nextHeight: number, ): { size: number; baseline: number } | null => { @@ -215,7 +217,7 @@ const measureFontSizeFromWidth = ( const hasContainer = isBoundToContainer(element); if (hasContainer) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (container) { width = getBoundTextMaxWidth(container); } @@ -257,6 +259,7 @@ const getSidesForTransformHandle = ( const resizeSingleTextElement = ( element: NonDeleted, + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, @@ -303,7 +306,12 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight); + const metrics = measureFontSizeFromWidth( + element, + elementsMap, + nextWidth, + nextHeight, + ); if (metrics === null) { return; } @@ -342,6 +350,7 @@ export const resizeSingleElement = ( originalElements: PointerDownState["originalElements"], shouldMaintainAspectRatio: boolean, element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, transformHandleDirection: TransformHandleDirection, shouldResizeFromCenter: boolean, pointerX: number, @@ -448,6 +457,7 @@ export const resizeSingleElement = ( const nextFont = measureFontSizeFromWidth( boundTextElement, + elementsMap, getBoundTextMaxWidth(updatedElement), getBoundTextMaxHeight(updatedElement, boundTextElement), ); @@ -637,8 +647,9 @@ export const resizeSingleElement = ( }; export const resizeMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], selectedElements: readonly NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, transformHandleType: "nw" | "ne" | "sw" | "se", shouldResizeFromCenter: boolean, pointerX: number, @@ -658,7 +669,7 @@ export const resizeMultipleElements = ( }[], element, ) => { - const origElement = pointerDownState.originalElements.get(element.id); + const origElement = originalElements.get(element.id); if (origElement) { acc.push({ orig: origElement, latest: element }); } @@ -679,7 +690,7 @@ export const resizeMultipleElements = ( if (!textId) { return acc; } - const text = pointerDownState.originalElements.get(textId) ?? null; + const text = originalElements.get(textId) ?? null; if (!isBoundToContainer(text)) { return acc; } @@ -825,7 +836,12 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth(orig, width, height); + const metrics = measureFontSizeFromWidth( + orig, + elementsMap, + width, + height, + ); if (!metrics) { return; } @@ -833,7 +849,7 @@ export const resizeMultipleElements = ( update.baseline = metrics.baseline; } - const boundTextElement = pointerDownState.originalElements.get( + const boundTextElement = originalElements.get( getBoundTextElementId(orig) ?? "", ) as ExcalidrawTextElementWithContainer | undefined; @@ -884,7 +900,7 @@ export const resizeMultipleElements = ( }; const rotateMultipleElements = ( - pointerDownState: PointerDownState, + originalElements: PointerDownState["originalElements"], elements: readonly NonDeletedExcalidrawElement[], pointerX: number, pointerY: number, @@ -906,8 +922,7 @@ const rotateMultipleElements = ( const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const origAngle = - pointerDownState.originalElements.get(element.id)?.angle ?? - element.angle; + originalElements.get(element.id)?.angle ?? element.angle; const [rotatedCX, rotatedCY] = rotate( cx, cy, diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index e084dfba3..da1348ec2 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -1,5 +1,6 @@ import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { + ElementsMap, ExcalidrawElement, ExcalidrawElementType, ExcalidrawTextContainer, @@ -682,17 +683,15 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => { }; export const getContainerElement = ( - element: - | (ExcalidrawElement & { - containerId: ExcalidrawElement["id"] | null; - }) - | null, -) => { + element: ExcalidrawTextElement | null, + elementsMap: ElementsMap, +): ExcalidrawTextContainer | null => { if (!element) { return null; } if (element.containerId) { - return Scene.getScene(element)?.getElement(element.containerId) || null; + return (elementsMap.get(element.containerId) || + null) as ExcalidrawTextContainer | null; } return null; }; @@ -752,28 +751,16 @@ export const getContainerCoords = (container: NonDeletedExcalidrawElement) => { }; }; -export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { - const container = getContainerElement(textElement); +export const getTextElementAngle = ( + textElement: ExcalidrawTextElement, + container: ExcalidrawTextContainer | null, +) => { if (!container || isArrowElement(container)) { return textElement.angle; } return container.angle; }; -export const getBoundTextElementOffset = ( - boundTextElement: ExcalidrawTextElement | null, -) => { - const container = getContainerElement(boundTextElement); - if (!container || !boundTextElement) { - return 0; - } - if (isArrowElement(container)) { - return BOUND_TEXT_PADDING * 8; - } - - return BOUND_TEXT_PADDING; -}; - export const getBoundTextElementPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, @@ -788,12 +775,12 @@ export const getBoundTextElementPosition = ( export const shouldAllowVerticalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; @@ -804,12 +791,12 @@ export const shouldAllowVerticalAlign = ( export const suppportsHorizontalAlign = ( selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, ) => { return selectedElements.some((element) => { - const hasBoundContainer = isBoundToContainer(element); - if (hasBoundContainer) { - const container = getContainerElement(element); - if (isTextElement(element) && isArrowElement(container)) { + if (isBoundToContainer(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { return false; } return true; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 52f89e0b9..801f0c440 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -153,7 +153,10 @@ export const textWysiwyg = ({ if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; - const container = getContainerElement(updatedTextElement); + const container = getContainerElement( + updatedTextElement, + app.scene.getElementsMapIncludingDeleted(), + ); let maxWidth = updatedTextElement.width; let maxHeight = updatedTextElement.height; @@ -277,7 +280,7 @@ export const textWysiwyg = ({ transform: getTransform( textElementWidth, textElementHeight, - getTextElementAngle(updatedTextElement), + getTextElementAngle(updatedTextElement, container), appState, maxWidth, editorMaxHeight, @@ -348,7 +351,10 @@ export const textWysiwyg = ({ if (!data) { return; } - const container = getContainerElement(element); + const container = getContainerElement( + element, + app.scene.getElementsMapIncludingDeleted(), + ); const font = getFontString({ fontSize: app.state.currentItemFontSize, @@ -528,7 +534,10 @@ export const textWysiwyg = ({ return; } let text = editable.value; - const container = getContainerElement(updateElement); + const container = getContainerElement( + updateElement, + app.scene.getElementsMapIncludingDeleted(), + ); if (container) { text = updateElement.text; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index c468eac82..7659ad1e9 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -6,7 +6,7 @@ import { THEME, VERTICAL_ALIGN, } from "../constants"; -import { MarkNonNullable, ValueOf } from "../utility-types"; +import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types"; import { MagicCacheData } from "../data/magic"; export type ChartType = "bar" | "line"; @@ -254,3 +254,31 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase & export type FileId = string & { _brand: "FileId" }; export type ExcalidrawElementType = ExcalidrawElement["type"]; + +/** + * Map of excalidraw elements. + * Unspecified whether deleted or non-deleted. + * Can be a subset of Scene elements. + */ +export type ElementsMap = Map; + +/** + * Map of non-deleted elements. + * Can be a subset of Scene elements. + */ +export type NonDeletedElementsMap = Map< + ExcalidrawElement["id"], + NonDeletedExcalidrawElement +> & + MakeBrand<"NonDeletedElementsMap">; + +/** + * Map of all excalidraw Scene elements, including deleted. + * Not a subset. Use this type when you need access to current Scene elements. + */ +export type SceneElementsMap = Map & + MakeBrand<"SceneElementsMap">; + +export type ElementsMapOrArray = + | readonly ExcalidrawElement[] + | Readonly; diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index a5e743711..ecb70ef1e 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -4,6 +4,8 @@ import { isTextElement, } from "./element"; import { + ElementsMap, + ElementsMapOrArray, ExcalidrawElement, ExcalidrawFrameLikeElement, NonDeleted, @@ -26,6 +28,7 @@ import { elementsOverlappingBBox, } from "../utils/export"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; +import { ReadonlySetLike } from "./utility-types"; // --------------------------- Frame State ------------------------------------ export const bindElementsToFramesAfterDuplication = ( @@ -211,9 +214,17 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => { }; export const getFrameChildren = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, frameId: string, -) => allElements.filter((element) => element.frameId === frameId); +) => { + const frameChildren: ExcalidrawElement[] = []; + for (const element of allElements.values()) { + if (element.frameId === frameId) { + frameChildren.push(element); + } + } + return frameChildren; +}; export const getFrameLikeElements = ( allElements: ExcalidrawElementsIncludingDeleted, @@ -425,23 +436,20 @@ export const filterElementsEligibleAsFrameChildren = ( * Retains (or repairs for target frame) the ordering invriant where children * elements come right before the parent frame: * [el, el, child, child, frame, el] + * + * @returns mutated allElements (same data structure) */ -export const addElementsToFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const addElementsToFrame = ( + allElements: T, elementsToAdd: NonDeletedExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => { - const { currTargetFrameChildrenMap } = allElements.reduce( - (acc, element, index) => { - if (element.frameId === frame.id) { - acc.currTargetFrameChildrenMap.set(element.id, true); - } - return acc; - }, - { - currTargetFrameChildrenMap: new Map(), - }, - ); +): T => { + const currTargetFrameChildrenMap = new Map(); + for (const element of allElements.values()) { + if (element.frameId === frame.id) { + currTargetFrameChildrenMap.set(element.id, true); + } + } const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); @@ -492,13 +500,12 @@ export const addElementsToFrame = ( false, ); } - return allElements.slice(); + + return allElements; }; export const removeElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, - elementsToRemove: NonDeletedExcalidrawElement[], - appState: AppState, + elementsToRemove: ReadonlySetLike, ) => { const _elementsToRemove = new Map< ExcalidrawElement["id"], @@ -536,35 +543,34 @@ export const removeElementsFromFrame = ( false, ); } - - return allElements.slice(); }; -export const removeAllElementsFromFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const removeAllElementsFromFrame = ( + allElements: readonly T[], frame: ExcalidrawFrameLikeElement, - appState: AppState, ) => { const elementsInFrame = getFrameChildren(allElements, frame.id); - return removeElementsFromFrame(allElements, elementsInFrame, appState); + removeElementsFromFrame(elementsInFrame); + return allElements; }; -export const replaceAllElementsInFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const replaceAllElementsInFrame = ( + allElements: readonly T[], nextElementsInFrame: ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, - appState: AppState, -) => { +): T[] => { return addElementsToFrame( - removeAllElementsFromFrame(allElements, frame, appState), + removeAllElementsFromFrame(allElements, frame), nextElementsInFrame, frame, - ); + ).slice(); }; /** does not mutate elements, but returns new ones */ -export const updateFrameMembershipOfSelectedElements = ( - allElements: ExcalidrawElementsIncludingDeleted, +export const updateFrameMembershipOfSelectedElements = < + T extends ElementsMapOrArray, +>( + allElements: T, appState: AppState, app: AppClassProperties, ) => { @@ -589,19 +595,22 @@ export const updateFrameMembershipOfSelectedElements = ( const elementsToRemove = new Set(); + const elementsMap = arrayToMap(allElements); + elementsToFilter.forEach((element) => { if ( element.frameId && !isFrameLikeElement(element) && - !isElementInFrame(element, allElements, appState) + !isElementInFrame(element, elementsMap, appState) ) { elementsToRemove.add(element); } }); - return elementsToRemove.size > 0 - ? removeElementsFromFrame(allElements, [...elementsToRemove], appState) - : allElements; + if (elementsToRemove.size > 0) { + removeElementsFromFrame(elementsToRemove); + } + return allElements; }; /** @@ -609,14 +618,16 @@ export const updateFrameMembershipOfSelectedElements = ( * anywhere in the group tree */ export const omitGroupsContainingFrameLikes = ( - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMapOrArray, /** subset of elements you want to filter. Optional perf optimization so we * don't have to filter all elements unnecessarily */ selectedElements?: readonly ExcalidrawElement[], ) => { const uniqueGroupIds = new Set(); - for (const el of selectedElements || allElements) { + const elements = selectedElements || allElements; + + for (const el of elements.values()) { const topMostGroupId = el.groupIds[el.groupIds.length - 1]; if (topMostGroupId) { uniqueGroupIds.add(topMostGroupId); @@ -634,9 +645,15 @@ export const omitGroupsContainingFrameLikes = ( } } - return (selectedElements || allElements).filter( - (el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]), - ); + const ret: ExcalidrawElement[] = []; + + for (const element of elements.values()) { + if (!rejectedGroupIds.has(element.groupIds[element.groupIds.length - 1])) { + ret.push(element); + } + } + + return ret; }; /** @@ -645,10 +662,11 @@ export const omitGroupsContainingFrameLikes = ( */ export const getTargetFrame = ( element: ExcalidrawElement, + elementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, elementsMap) || element : element; return appState.selectedElementIds[_element.id] && @@ -661,12 +679,12 @@ export const getTargetFrame = ( // given an element, return if the element is in some frame export const isElementInFrame = ( element: ExcalidrawElement, - allElements: ExcalidrawElementsIncludingDeleted, + allElements: ElementsMap, appState: StaticCanvasAppState, ) => { - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, allElements, appState); const _element = isTextElement(element) - ? getContainerElement(element) || element + ? getContainerElement(element, allElements) || element : element; if (frame) { diff --git a/packages/excalidraw/groups.ts b/packages/excalidraw/groups.ts index dd5512ba1..b0bedc4f9 100644 --- a/packages/excalidraw/groups.ts +++ b/packages/excalidraw/groups.ts @@ -3,6 +3,7 @@ import { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement, + ElementsMapOrArray, } from "./element/types"; import { AppClassProperties, @@ -270,9 +271,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) => element.groupIds.includes(groupId); export const getElementsInGroup = ( - elements: readonly ExcalidrawElement[], + elements: ElementsMapOrArray, groupId: string, -) => elements.filter((element) => isElementInGroup(element, groupId)); +) => { + const elementsInGroup: ExcalidrawElement[] = []; + for (const element of elements.values()) { + if (isElementInGroup(element, groupId)) { + elementsInGroup.push(element); + } + } + return elementsInGroup; +}; export const getSelectedGroupIdForElement = ( element: ExcalidrawElement, diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 94eda49f9..39e6c4974 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -21,7 +21,11 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Drawable } from "roughjs/bin/core"; import type { RoughSVG } from "roughjs/bin/svg"; -import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types"; +import { + SVGRenderConfig, + StaticCanvasRenderConfig, + RenderableElementsMap, +} from "../scene/types"; import { distance, getFontString, @@ -611,6 +615,7 @@ export const renderSelectionElement = ( export const renderElement = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, rc: RoughCanvas, context: CanvasRenderingContext2D, renderConfig: StaticCanvasRenderConfig, @@ -715,7 +720,7 @@ export const renderElement = ( let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftY = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( @@ -900,6 +905,7 @@ const maybeWrapNodesInFrameClipPath = ( export const renderElementToSvg = ( element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, @@ -912,7 +918,7 @@ export const renderElementToSvg = ( let cx = (x2 - x1) / 2 - (element.x - x1); let cy = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { - const container = getContainerElement(element); + const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); @@ -1013,6 +1019,7 @@ export const renderElementToSvg = ( createPlaceholderEmbeddableLabel(element); renderElementToSvg( label, + elementsMap, rsvg, root, files, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index a5b78d3b8..0a066bfd4 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -33,6 +33,7 @@ import { SVGRenderConfig, StaticCanvasRenderConfig, StaticSceneRenderConfig, + RenderableElementsMap, } from "../scene/types"; import { getScrollBars, @@ -61,7 +62,7 @@ import { TransformHandles, TransformHandleType, } from "../element/transformHandles"; -import { throttleRAF } from "../utils"; +import { arrayToMap, throttleRAF } from "../utils"; import { UserIdleState } from "../types"; import { FRAME_STYLE, THEME_FILTER } from "../constants"; import { @@ -75,10 +76,7 @@ import { isIframeLikeElement, isLinearElement, } from "../element/typeChecks"; -import { - isIframeLikeOrItsLabel, - createPlaceholderEmbeddableLabel, -} from "../element/embeddable"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; import { elementOverlapsWithFrame, getTargetFrame, @@ -446,7 +444,7 @@ const bootstrapCanvas = ({ const _renderInteractiveScene = ({ canvas, - elements, + elementsMap, visibleElements, selectedElements, scale, @@ -454,7 +452,7 @@ const _renderInteractiveScene = ({ renderConfig, }: InteractiveSceneRenderConfig) => { if (canvas === null) { - return { atLeastOneVisibleElement: false, elements }; + return { atLeastOneVisibleElement: false, elementsMap }; } const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( @@ -562,75 +560,64 @@ const _renderInteractiveScene = ({ if (showBoundingBox) { // Optimisation for finding quickly relevant element ids - const locallySelectedIds = selectedElements.reduce( - (acc: Record, element) => { - acc[element.id] = true; - return acc; - }, - {}, - ); - - const selections = elements.reduce( - ( - acc: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }[], - element, - ) => { - const selectionColors = []; - // local user - if ( - locallySelectedIds[element.id] && - !isSelectedViaGroup(appState, element) - ) { - selectionColors.push(selectionColor); - } - // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { - selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), - ); - } + const locallySelectedIds = arrayToMap(selectedElements); + + const selections: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }[] = []; + + for (const element of elementsMap.values()) { + const selectionColors = []; + // local user + if ( + locallySelectedIds.has(element.id) && + !isSelectedViaGroup(appState, element) + ) { + selectionColors.push(selectionColor); + } + // remote users + if (renderConfig.remoteSelectedElementIds[element.id]) { + selectionColors.push( + ...renderConfig.remoteSelectedElementIds[element.id].map( + (socketId: string) => { + const background = getClientColor(socketId); + return background; + }, + ), + ); + } - if (selectionColors.length) { - const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, true); - acc.push({ - angle: element.angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], - cx, - cy, - activeEmbeddable: - appState.activeEmbeddable?.element === element && - appState.activeEmbeddable.state === "active", - }); - } - return acc; - }, - [], - ); + if (selectionColors.length) { + const [elementX1, elementY1, elementX2, elementY2, cx, cy] = + getElementAbsoluteCoords(element, true); + selections.push({ + angle: element.angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + dashed: !!renderConfig.remoteSelectedElementIds[element.id], + cx, + cy, + activeEmbeddable: + appState.activeEmbeddable?.element === element && + appState.activeEmbeddable.state === "active", + }); + } + } const addSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); + const groupElements = getElementsInGroup(elementsMap, groupId); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(groupElements); selections.push({ @@ -870,7 +857,7 @@ const _renderInteractiveScene = ({ let scrollBars; if (renderConfig.renderScrollbars) { scrollBars = getScrollBars( - elements, + elementsMap, normalizedWidth, normalizedHeight, appState, @@ -897,14 +884,14 @@ const _renderInteractiveScene = ({ return { scrollBars, atLeastOneVisibleElement: visibleElements.length > 0, - elements, + elementsMap, }; }; const _renderStaticScene = ({ canvas, rc, - elements, + elementsMap, visibleElements, scale, appState, @@ -965,7 +952,7 @@ const _renderStaticScene = ({ // Paint visible elements visibleElements - .filter((el) => !isIframeLikeOrItsLabel(el)) + .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { try { const frameId = element.frameId || appState.frameToHighlight?.id; @@ -977,16 +964,30 @@ const _renderStaticScene = ({ ) { context.save(); - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, elementsMap, appState); // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elements, appState)) { + if (frame && isElementInFrame(element, elementsMap, appState)) { frameClip(frame, context, renderConfig, appState); } - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); context.restore(); } else { - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); } if (!isExporting) { renderLinkIcon(element, context, appState); @@ -998,11 +999,18 @@ const _renderStaticScene = ({ // render embeddables on top visibleElements - .filter((el) => isIframeLikeOrItsLabel(el)) + .filter((el) => isIframeLikeElement(el)) .forEach((element) => { try { const render = () => { - renderElement(element, rc, context, renderConfig, appState); + renderElement( + element, + elementsMap, + rc, + context, + renderConfig, + appState, + ); if ( isIframeLikeElement(element) && @@ -1014,7 +1022,14 @@ const _renderStaticScene = ({ element.height ) { const label = createPlaceholderEmbeddableLabel(element); - renderElement(label, rc, context, renderConfig, appState); + renderElement( + label, + elementsMap, + rc, + context, + renderConfig, + appState, + ); } if (!isExporting) { renderLinkIcon(element, context, appState); @@ -1032,9 +1047,9 @@ const _renderStaticScene = ({ ) { context.save(); - const frame = getTargetFrame(element, appState); + const frame = getTargetFrame(element, elementsMap, appState); - if (frame && isElementInFrame(element, elements, appState)) { + if (frame && isElementInFrame(element, elementsMap, appState)) { frameClip(frame, context, renderConfig, appState); } render(); @@ -1448,6 +1463,7 @@ const renderLinkIcon = ( // This should be only called for exporting purposes export const renderSceneToSvg = ( elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, rsvg: RoughSVG, svgRoot: SVGElement, files: BinaryFiles, @@ -1459,12 +1475,13 @@ export const renderSceneToSvg = ( // render elements elements - .filter((el) => !isIframeLikeOrItsLabel(el)) + .filter((el) => !isIframeLikeElement(el)) .forEach((element) => { if (!element.isDeleted) { try { renderElementToSvg( element, + elementsMap, rsvg, svgRoot, files, @@ -1486,6 +1503,7 @@ export const renderSceneToSvg = ( try { renderElementToSvg( element, + elementsMap, rsvg, svgRoot, files, diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index 05dddadc4..1a97c06e0 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -1,5 +1,6 @@ import { isTextElement, refreshTextDimensions } from "../element"; import { newElementWith } from "../element/mutateElement"; +import { getContainerElement } from "../element/textElement"; import { isBoundToContainer } from "../element/typeChecks"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { getFontString } from "../utils"; @@ -57,7 +58,13 @@ export class Fonts { ShapeCache.delete(element); didUpdate = true; return newElementWith(element, { - ...refreshTextDimensions(element), + ...refreshTextDimensions( + element, + getContainerElement( + element, + this.scene.getElementsMapIncludingDeleted(), + ), + ), }); } return element; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 152224951..1593d6d2e 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -1,10 +1,14 @@ import { isElementInViewport } from "../element/sizeHelpers"; import { isImageElement } from "../element/typeChecks"; -import { NonDeletedExcalidrawElement } from "../element/types"; +import { + NonDeletedElementsMap, + NonDeletedExcalidrawElement, +} from "../element/types"; import { cancelRender } from "../renderer/renderScene"; import { AppState } from "../types"; -import { memoize } from "../utils"; +import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; +import { RenderableElementsMap } from "./types"; export class Renderer { private scene: Scene; @@ -15,7 +19,7 @@ export class Renderer { public getRenderableElements = (() => { const getVisibleCanvasElements = ({ - elements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -24,7 +28,7 @@ export class Renderer { height, width, }: { - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: NonDeletedElementsMap; zoom: AppState["zoom"]; offsetLeft: AppState["offsetLeft"]; offsetTop: AppState["offsetTop"]; @@ -33,43 +37,55 @@ export class Renderer { height: AppState["height"]; width: AppState["width"]; }): readonly NonDeletedExcalidrawElement[] => { - return elements.filter((element) => - isElementInViewport(element, width, height, { - zoom, - offsetLeft, - offsetTop, - scrollX, - scrollY, - }), - ); + const visibleElements: NonDeletedExcalidrawElement[] = []; + for (const element of elementsMap.values()) { + if ( + isElementInViewport(element, width, height, { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }) + ) { + visibleElements.push(element); + } + } + return visibleElements; }; - const getCanvasElements = ({ - editingElement, + const getRenderableElements = ({ elements, + editingElement, pendingImageElementId, }: { elements: readonly NonDeletedExcalidrawElement[]; editingElement: AppState["editingElement"]; pendingImageElementId: AppState["pendingImageElementId"]; }) => { - return elements.filter((element) => { + const elementsMap = toBrandedType(new Map()); + + for (const element of elements) { if (isImageElement(element)) { if ( // => not placed on canvas yet (but in elements array) pendingImageElementId === element.id ) { - return false; + continue; } } + // we don't want to render text element that's being currently edited // (it's rendered on remote only) - return ( + if ( !editingElement || editingElement.type !== "text" || element.id !== editingElement.id - ); - }); + ) { + elementsMap.set(element.id, element); + } + } + return elementsMap; }; return memoize( @@ -100,14 +116,14 @@ export class Renderer { }) => { const elements = this.scene.getNonDeletedElements(); - const canvasElements = getCanvasElements({ + const elementsMap = getRenderableElements({ elements, editingElement, pendingImageElementId, }); const visibleElements = getVisibleCanvasElements({ - elements: canvasElements, + elementsMap, zoom, offsetLeft, offsetTop, @@ -117,7 +133,7 @@ export class Renderer { width, }); - return { canvasElements, visibleElements }; + return { elementsMap, visibleElements }; }, ); })(); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 814638e7e..326f98c7f 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -3,14 +3,18 @@ import { NonDeletedExcalidrawElement, NonDeleted, ExcalidrawFrameLikeElement, + ElementsMapOrArray, + NonDeletedElementsMap, + SceneElementsMap, } from "../element/types"; -import { getNonDeletedElements, isNonDeletedElement } from "../element"; +import { isNonDeletedElement } from "../element"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isFrameLikeElement } from "../element/typeChecks"; import { getSelectedElements } from "./selection"; import { AppState } from "../types"; import { Assert, SameType } from "../utility-types"; import { randomInteger } from "../random"; +import { toBrandedType } from "../utils"; type ElementIdKey = InstanceType["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; @@ -20,6 +24,20 @@ type SceneStateCallbackRemover = () => void; type SelectionHash = string & { __brand: "selectionHash" }; +const getNonDeletedElements = ( + allElements: readonly T[], +) => { + const elementsMap = new Map() as NonDeletedElementsMap; + const elements: T[] = []; + for (const element of allElements) { + if (!element.isDeleted) { + elements.push(element as NonDeleted); + elementsMap.set(element.id, element as NonDeletedExcalidrawElement); + } + } + return { elementsMap, elements }; +}; + const hashSelectionOpts = ( opts: Parameters["getSelectedElements"]>[0], ) => { @@ -102,11 +120,13 @@ class Scene { private callbacks: Set = new Set(); private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; + private nonDeletedElementsMap: NonDeletedElementsMap = + new Map() as NonDeletedElementsMap; private elements: readonly ExcalidrawElement[] = []; private nonDeletedFramesLikes: readonly NonDeleted[] = []; private frames: readonly ExcalidrawFrameLikeElement[] = []; - private elementsMap = new Map(); + private elementsMap = toBrandedType(new Map()); private selectedElementsCache: { selectedElementIds: AppState["selectedElementIds"] | null; elements: readonly NonDeletedExcalidrawElement[] | null; @@ -118,6 +138,14 @@ class Scene { }; private versionNonce: number | undefined; + getElementsMapIncludingDeleted() { + return this.elementsMap; + } + + getNonDeletedElementsMap() { + return this.nonDeletedElementsMap; + } + getElementsIncludingDeleted() { return this.elements; } @@ -138,7 +166,7 @@ class Scene { * scene state. This in effect will likely result in cache-miss, and * the cache won't be updated in this case. */ - elements?: readonly ExcalidrawElement[]; + elements?: ElementsMapOrArray; // selection-related options includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; @@ -227,23 +255,27 @@ class Scene { return didChange; } - replaceAllElements( - nextElements: readonly ExcalidrawElement[], - mapElementIds = true, - ) { - this.elements = nextElements; + replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) { + this.elements = + // ts doesn't like `Array.isArray` of `instanceof Map` + nextElements instanceof Array + ? nextElements + : Array.from(nextElements.values()); const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; this.elementsMap.clear(); - nextElements.forEach((element) => { + this.elements.forEach((element) => { if (isFrameLikeElement(element)) { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); - Scene.mapElementToScene(element, this); + Scene.mapElementToScene(element, this, mapElementIds); }); - this.nonDeletedElements = getNonDeletedElements(this.elements); + const nonDeletedElements = getNonDeletedElements(this.elements); + this.nonDeletedElements = nonDeletedElements.elements; + this.nonDeletedElementsMap = nonDeletedElements.elementsMap; + this.frames = nextFrameLikes; - this.nonDeletedFramesLikes = getNonDeletedElements(this.frames); + this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; this.informMutation(); } @@ -332,6 +364,22 @@ class Scene { getElementIndex(elementId: string) { return this.elements.findIndex((element) => element.id === elementId); } + + getContainerElement = ( + element: + | (ExcalidrawElement & { + containerId: ExcalidrawElement["id"] | null; + }) + | null, + ) => { + if (!element) { + return null; + } + if (element.containerId) { + return this.getElement(element.containerId) || null; + } + return null; + }; } export default Scene; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 9c357a21f..61ba8580d 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -11,7 +11,13 @@ import { getElementAbsoluteCoords, } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { cloneJSON, distance, getFontString } from "../utils"; +import { + arrayToMap, + cloneJSON, + distance, + getFontString, + toBrandedType, +} from "../utils"; import { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -37,6 +43,7 @@ import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; import Scene from "./Scene"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; +import { RenderableElementsMap } from "./types"; const SVG_EXPORT_TAG = ``; @@ -59,7 +66,7 @@ const __createSceneForElementsHack__ = ( // ids to Scene instances so that we don't override the editor elements // mapping. // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements), false); + scene.replaceAllElements(cloneJSON(elements)); return scene; }; @@ -241,10 +248,14 @@ export const exportToCanvas = async ( files, }); + const elementsMap = toBrandedType( + arrayToMap(elementsForRender), + ); + renderStaticScene({ canvas, rc: rough.canvas(canvas), - elements: elementsForRender, + elementsMap, visibleElements: elementsForRender, scale, appState: { @@ -432,22 +443,29 @@ export const exportToSvg = async ( const renderEmbeddables = opts?.renderEmbeddables ?? false; - renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, { - offsetX, - offsetY, - isExporting: true, - exportWithDarkMode, - renderEmbeddables, - frameRendering, - canvasBackgroundColor: viewBackgroundColor, - embedsValidationStatus: renderEmbeddables - ? new Map( - elementsForRender - .filter((element) => isFrameLikeElement(element)) - .map((element) => [element.id, true]), - ) - : new Map(), - }); + renderSceneToSvg( + elementsForRender, + toBrandedType(arrayToMap(elementsForRender)), + rsvg, + svgRoot, + files || {}, + { + offsetX, + offsetY, + isExporting: true, + exportWithDarkMode, + renderEmbeddables, + frameRendering, + canvasBackgroundColor: viewBackgroundColor, + embedsValidationStatus: renderEmbeddables + ? new Map( + elementsForRender + .filter((element) => isFrameLikeElement(element)) + .map((element) => [element.id, true]), + ) + : new Map(), + }, + ); tempScene.destroy(); diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts index 1d93f688f..14009588b 100644 --- a/packages/excalidraw/scene/scrollbars.ts +++ b/packages/excalidraw/scene/scrollbars.ts @@ -1,7 +1,6 @@ -import { ExcalidrawElement } from "../element/types"; import { getCommonBounds } from "../element"; import { InteractiveCanvasAppState } from "../types"; -import { ScrollBars } from "./types"; +import { RenderableElementsMap, ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; @@ -10,12 +9,12 @@ export const SCROLLBAR_WIDTH = 6; export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; export const getScrollBars = ( - elements: readonly ExcalidrawElement[], + elements: RenderableElementsMap, viewportWidth: number, viewportHeight: number, appState: InteractiveCanvasAppState, ): ScrollBars => { - if (elements.length === 0) { + if (!elements.size) { return { horizontal: null, vertical: null, diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 7a620155f..ae021f6aa 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -1,4 +1,5 @@ import { + ElementsMapOrArray, ExcalidrawElement, NonDeletedExcalidrawElement, } from "../element/types"; @@ -166,26 +167,28 @@ export const getCommonAttributeOfSelectedElements = ( }; export const getSelectedElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, opts?: { includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; }, ) => { - const selectedElements = elements.filter((element) => { + const selectedElements: ExcalidrawElement[] = []; + for (const element of elements.values()) { if (appState.selectedElementIds[element.id]) { - return element; + selectedElements.push(element); + continue; } if ( opts?.includeBoundTextElement && isBoundToContainer(element) && appState.selectedElementIds[element?.containerId] ) { - return element; + selectedElements.push(element); + continue; } - return null; - }); + } if (opts?.includeElementsInFrames) { const elementsToInclude: ExcalidrawElement[] = []; @@ -205,7 +208,7 @@ export const getSelectedElements = ( }; export const getTargetElements = ( - elements: readonly NonDeletedExcalidrawElement[], + elements: ElementsMapOrArray, appState: Pick, ) => appState.editingElement diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 57a52fbd4..957b080b3 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -2,6 +2,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { ExcalidrawTextElement, + NonDeletedElementsMap, NonDeletedExcalidrawElement, } from "../element/types"; import { @@ -12,6 +13,10 @@ import { InteractiveCanvasAppState, StaticCanvasAppState, } from "../types"; +import { MakeBrand } from "../utility-types"; + +export type RenderableElementsMap = NonDeletedElementsMap & + MakeBrand<"RenderableElementsMap">; export type StaticCanvasRenderConfig = { canvasBackgroundColor: AppState["viewBackgroundColor"]; @@ -53,14 +58,14 @@ export type InteractiveCanvasRenderConfig = { export type RenderInteractiveSceneCallback = { atLeastOneVisibleElement: boolean; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; scrollBars?: ScrollBars; }; export type StaticSceneRenderConfig = { canvas: HTMLCanvasElement; rc: RoughCanvas; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; scale: number; appState: StaticCanvasAppState; @@ -69,7 +74,7 @@ export type StaticSceneRenderConfig = { export type InteractiveSceneRenderConfig = { canvas: HTMLCanvasElement | null; - elements: readonly NonDeletedExcalidrawElement[]; + elementsMap: RenderableElementsMap; visibleElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[]; scale: number; diff --git a/packages/excalidraw/utility-types.ts b/packages/excalidraw/utility-types.ts index 860d818ef..576769634 100644 --- a/packages/excalidraw/utility-types.ts +++ b/packages/excalidraw/utility-types.ts @@ -54,3 +54,11 @@ export type Assert = T; export type NestedKeyOf = K extends keyof T & (string | number) ? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf}` : never) : never; + +export type SetLike = Set | T[]; +export type ReadonlySetLike = ReadonlySet | readonly T[]; + +export type MakeBrand = { + /** @private using ~ to sort last in intellisense */ + [K in `~brand~${T}`]: T; +}; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 4630c5bce..47fa52311 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -650,8 +650,11 @@ export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now()); * or array of ids (strings), into a Map, keyd by `id`. */ export const arrayToMap = ( - items: readonly T[], + items: readonly T[] | Map, ) => { + if (items instanceof Map) { + return items; + } return items.reduce((acc: Map, element) => { acc.set(typeof element === "string" ? element : element.id, element); return acc; @@ -1050,3 +1053,40 @@ export function getSvgPathFromStroke(points: number[][], closed = true) { export const normalizeEOL = (str: string) => { return str.replace(/\r?\n|\r/g, "\n"); }; + +// ----------------------------------------------------------------------------- +type HasBrand = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T]: K extends `~brand${infer _}` ? true : never; +}[keyof T]; + +type RemoveAllBrands = HasBrand extends true + ? { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K]; + } + : never; + +// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940 +// currently does not cover all types (e.g. tuples, promises...) +type Unbrand = T extends Map + ? Map + : T extends Set + ? Set + : T extends Array + ? Array + : RemoveAllBrands; + +/** + * Makes type into a branded type, ensuring that value is assignable to + * the base ubranded type. Optionally you can explicitly supply current value + * type to combine both (useful for composite branded types. Make sure you + * compose branded types which are not composite themselves.) + */ +export const toBrandedType = ( + value: Unbrand, +) => { + return value as CurrentType & BrandedType; +}; + +// -----------------------------------------------------------------------------