refactor: decoupling global Scene state part-1 (#7577)

This commit is contained in:
David Luzar 2024-01-22 00:23:02 +01:00 committed by GitHub
parent 740a165452
commit 0415c616b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 630 additions and 384 deletions

View file

@ -1,9 +1,13 @@
import { register } from "./register"; import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, NonDeleted } from "../element/types"; import {
ExcalidrawElement,
NonDeleted,
NonDeletedElementsMap,
} from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements"; import { resizeMultipleElements } from "../element/resizeElements";
import { AppState, PointerDownState } from "../types"; import { AppState } from "../types";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds"; import { getCommonBoundingBox } from "../element/bounds";
@ -20,7 +24,12 @@ export const actionFlipHorizontal = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "horizontal"), flipSelectedElements(
elements,
app.scene.getNonDeletedElementsMap(),
appState,
"horizontal",
),
appState, appState,
app, app,
), ),
@ -38,7 +47,12 @@ export const actionFlipVertical = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
return { return {
elements: updateFrameMembershipOfSelectedElements( elements: updateFrameMembershipOfSelectedElements(
flipSelectedElements(elements, appState, "vertical"), flipSelectedElements(
elements,
app.scene.getNonDeletedElementsMap(),
appState,
"vertical",
),
appState, appState,
app, app,
), ),
@ -53,6 +67,7 @@ export const actionFlipVertical = register({
const flipSelectedElements = ( const flipSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedElementsMap,
appState: Readonly<AppState>, appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
) => { ) => {
@ -67,6 +82,7 @@ const flipSelectedElements = (
const updatedElements = flipElements( const updatedElements = flipElements(
selectedElements, selectedElements,
elementsMap,
appState, appState,
flipDirection, flipDirection,
); );
@ -79,15 +95,17 @@ const flipSelectedElements = (
}; };
const flipElements = ( const flipElements = (
elements: NonDeleted<ExcalidrawElement>[], selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedElementsMap,
appState: AppState, appState: AppState,
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
resizeMultipleElements( resizeMultipleElements(
{ originalElements: arrayToMap(elements) } as PointerDownState, elementsMap,
elements, selectedElements,
elementsMap,
"nw", "nw",
true, true,
flipDirection === "horizontal" ? maxX : minX, flipDirection === "horizontal" ? maxX : minX,
@ -96,7 +114,7 @@ const flipElements = (
(isBindingEnabled(appState) (isBindingEnabled(appState)
? bindOrUnbindSelectedElements ? bindOrUnbindSelectedElements
: unbindLinearElements)(elements); : unbindLinearElements)(selectedElements);
return elements; return selectedElements;
}; };

View file

@ -63,11 +63,7 @@ export const actionRemoveAllElementsFromFrame = register({
if (isFrameLikeElement(selectedElement)) { if (isFrameLikeElement(selectedElement)) {
return { return {
elements: removeAllElementsFromFrame( elements: removeAllElementsFromFrame(elements, selectedElement),
elements,
selectedElement,
appState,
),
appState: { appState: {
...appState, ...appState,
selectedElementIds: { selectedElementIds: {

View file

@ -105,11 +105,7 @@ export const actionGroup = register({
const frameElementsMap = groupByFrameLikes(selectedElements); const frameElementsMap = groupByFrameLikes(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => { frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame( removeElementsFromFrame(elementsInFrame);
nextElements,
elementsInFrame,
appState,
);
}); });
} }
@ -229,7 +225,6 @@ export const actionUngroup = register({
nextElements, nextElements,
getElementsInResizingFrame(nextElements, frame, appState), getElementsInResizingFrame(nextElements, frame, appState),
frame, frame,
appState,
); );
} }
}); });

View file

@ -1,4 +1,4 @@
import { AppState, Primitive } from "../types"; import { AppClassProperties, AppState, Primitive } from "../types";
import { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@ -66,7 +66,6 @@ import {
import { mutateElement, newElementWith } from "../element/mutateElement"; import { mutateElement, newElementWith } from "../element/mutateElement";
import { import {
getBoundTextElement, getBoundTextElement,
getContainerElement,
getDefaultLineHeight, getDefaultLineHeight,
} from "../element/textElement"; } from "../element/textElement";
import { import {
@ -189,6 +188,7 @@ const offsetElementAfterFontResize = (
const changeFontSize = ( const changeFontSize = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
app: AppClassProperties,
getNewFontSize: (element: ExcalidrawTextElement) => number, getNewFontSize: (element: ExcalidrawTextElement) => number,
fallbackValue?: ExcalidrawTextElement["fontSize"], fallbackValue?: ExcalidrawTextElement["fontSize"],
) => { ) => {
@ -206,7 +206,10 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, { let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize, fontSize: newFontSize,
}); });
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
);
newElement = offsetElementAfterFontResize(oldElement, newElement); newElement = offsetElementAfterFontResize(oldElement, newElement);
@ -600,8 +603,8 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({ export const actionChangeFontSize = register({
name: "changeFontSize", name: "changeFontSize",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, () => value, value); return changeFontSize(elements, appState, app, () => value, value);
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<fieldset> <fieldset>
@ -663,8 +666,8 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({ export const actionDecreaseFontSize = register({
name: "decreaseFontSize", name: "decreaseFontSize",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, app, (element) =>
Math.round( Math.round(
// get previous value before relative increase (doesn't work fully // get previous value before relative increase (doesn't work fully
// due to rounding and float precision issues) // due to rounding and float precision issues)
@ -685,8 +688,8 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({ export const actionIncreaseFontSize = register({
name: "increaseFontSize", name: "increaseFontSize",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, (element) => return changeFontSize(elements, appState, app, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)), Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
); );
}, },
@ -703,7 +706,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({ export const actionChangeFontFamily = register({
name: "changeFontFamily", name: "changeFontFamily",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return { return {
elements: changeProperty( elements: changeProperty(
elements, elements,
@ -717,7 +720,10 @@ export const actionChangeFontFamily = register({
lineHeight: getDefaultLineHeight(value), lineHeight: getDefaultLineHeight(value),
}, },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
);
return newElement; return newElement;
} }
@ -795,7 +801,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({ export const actionChangeTextAlign = register({
name: "changeTextAlign", name: "changeTextAlign",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return { return {
elements: changeProperty( elements: changeProperty(
elements, elements,
@ -806,7 +812,10 @@ export const actionChangeTextAlign = register({
oldElement, oldElement,
{ textAlign: value }, { textAlign: value },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
);
return newElement; return newElement;
} }
@ -875,7 +884,7 @@ export const actionChangeTextAlign = register({
export const actionChangeVerticalAlign = register({ export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign", name: "changeVerticalAlign",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return { return {
elements: changeProperty( elements: changeProperty(
elements, elements,
@ -887,7 +896,10 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value }, { verticalAlign: value },
); );
redrawTextBoundingBox(newElement, getContainerElement(oldElement)); redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
);
return newElement; return newElement;
} }

View file

@ -1,7 +1,6 @@
import React, { useState } from "react"; import { useState } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { ExcalidrawElementType, NonDeletedElementsMap } from "../element/types";
import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "./App"; import { useDevice } from "./App";
import { import {
@ -44,17 +43,14 @@ import { useTunnels } from "../context/tunnels";
export const SelectedShapeActions = ({ export const SelectedShapeActions = ({
appState, appState,
elements, elementsMap,
renderAction, renderAction,
}: { }: {
appState: UIAppState; appState: UIAppState;
elements: readonly ExcalidrawElement[]; elementsMap: NonDeletedElementsMap;
renderAction: ActionManager["renderAction"]; renderAction: ActionManager["renderAction"];
}) => { }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(elementsMap, appState);
getNonDeletedElements(elements),
appState,
);
let isSingleElementBoundContainer = false; let isSingleElementBoundContainer = false;
if ( if (
@ -137,12 +133,12 @@ export const SelectedShapeActions = ({
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{(appState.activeTool.type === "text" || {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) && suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
</> </>
)} )}
{shouldAllowVerticalAlign(targetElements) && {shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")} renderAction("changeVerticalAlign")}
{(canHaveArrowheads(appState.activeTool.type) || {(canHaveArrowheads(appState.activeTool.type) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && ( targetElements.some((element) => canHaveArrowheads(element.type))) && (

View file

@ -1417,7 +1417,7 @@ class App extends React.Component<AppProps, AppState> {
const { renderTopRightUI, renderCustomStats } = this.props; const { renderTopRightUI, renderCustomStats } = this.props;
const versionNonce = this.scene.getVersionNonce(); const versionNonce = this.scene.getVersionNonce();
const { canvasElements, visibleElements } = const { elementsMap, visibleElements } =
this.renderer.getRenderableElements({ this.renderer.getRenderableElements({
versionNonce, versionNonce,
zoom: this.state.zoom, zoom: this.state.zoom,
@ -1627,7 +1627,7 @@ class App extends React.Component<AppProps, AppState> {
<StaticCanvas <StaticCanvas
canvas={this.canvas} canvas={this.canvas}
rc={this.rc} rc={this.rc}
elements={canvasElements} elementsMap={elementsMap}
visibleElements={visibleElements} visibleElements={visibleElements}
versionNonce={versionNonce} versionNonce={versionNonce}
selectionNonce={ selectionNonce={
@ -1648,7 +1648,7 @@ class App extends React.Component<AppProps, AppState> {
<InteractiveCanvas <InteractiveCanvas
containerRef={this.excalidrawContainerRef} containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas} canvas={this.interactiveCanvas}
elements={canvasElements} elementsMap={elementsMap}
visibleElements={visibleElements} visibleElements={visibleElements}
selectedElements={selectedElements} selectedElements={selectedElements}
versionNonce={versionNonce} versionNonce={versionNonce}
@ -2780,7 +2780,7 @@ class App extends React.Component<AppProps, AppState> {
private renderInteractiveSceneCallback = ({ private renderInteractiveSceneCallback = ({
atLeastOneVisibleElement, atLeastOneVisibleElement,
scrollBars, scrollBars,
elements, elementsMap,
}: RenderInteractiveSceneCallback) => { }: RenderInteractiveSceneCallback) => {
if (scrollBars) { if (scrollBars) {
currentScrollBars = scrollBars; currentScrollBars = scrollBars;
@ -2789,7 +2789,7 @@ class App extends React.Component<AppProps, AppState> {
// hide when editing text // hide when editing text
isTextElement(this.state.editingElement) isTextElement(this.state.editingElement)
? false ? false
: !atLeastOneVisibleElement && elements.length > 0; : !atLeastOneVisibleElement && elementsMap.size > 0;
if (this.state.scrolledOutside !== scrolledOutside) { if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside }); this.setState({ scrolledOutside });
} }
@ -3119,7 +3119,10 @@ class App extends React.Component<AppProps, AppState> {
newElements.forEach((newElement) => { newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) { if (isTextElement(newElement) && isBoundToContainer(newElement)) {
const container = getContainerElement(newElement); const container = getContainerElement(
newElement,
this.scene.getElementsMapIncludingDeleted(),
);
redrawTextBoundingBox(newElement, container); redrawTextBoundingBox(newElement, container);
} }
}); });
@ -4183,11 +4186,18 @@ class App extends React.Component<AppProps, AppState> {
this.scene.replaceAllElements([ this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted().map((_element) => { ...this.scene.getElementsIncludingDeleted().map((_element) => {
if (_element.id === element.id && isTextElement(_element)) { if (_element.id === element.id && isTextElement(_element)) {
return updateTextElement(_element, { return updateTextElement(
_element,
getContainerElement(
_element,
this.scene.getElementsMapIncludingDeleted(),
),
{
text, text,
isDeleted, isDeleted,
originalText, originalText,
}); },
);
} }
return _element; return _element;
}), }),
@ -7700,13 +7710,9 @@ class App extends React.Component<AppProps, AppState> {
groupIds: [], groupIds: [],
}); });
this.scene.replaceAllElements( removeElementsFromFrame([linearElement]);
removeElementsFromFrame(
this.scene.getElementsIncludingDeleted(), this.scene.informMutation();
[linearElement],
this.state,
),
);
} }
} }
} }
@ -7716,7 +7722,7 @@ class App extends React.Component<AppProps, AppState> {
this.getTopLayerFrameAtSceneCoords(sceneCoords); this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
let nextElements = this.scene.getElementsIncludingDeleted(); let nextElements = this.scene.getElementsMapIncludingDeleted();
const updateGroupIdsAfterEditingGroup = ( const updateGroupIdsAfterEditingGroup = (
elements: ExcalidrawElement[], elements: ExcalidrawElement[],
@ -7809,7 +7815,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.replaceAllElements( this.scene.replaceAllElements(
addElementsToFrame( addElementsToFrame(
this.scene.getElementsIncludingDeleted(), this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame, elementsInsideFrame,
draggingElement, draggingElement,
), ),
@ -7857,7 +7863,6 @@ class App extends React.Component<AppProps, AppState> {
this.state, this.state,
), ),
frame, frame,
this.state,
); );
} }
@ -9137,10 +9142,10 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
transformElements( transformElements(
pointerDownState, pointerDownState.originalElements,
transformHandleType, transformHandleType,
selectedElements, selectedElements,
pointerDownState.resize.arrowDirection, this.scene.getElementsMapIncludingDeleted(),
shouldRotateWithDiscreteAngle(event), shouldRotateWithDiscreteAngle(event),
shouldResizeFromCenter(event), shouldResizeFromCenter(event),
selectedElements.length === 1 && isImageElement(selectedElements[0]) selectedElements.length === 1 && isImageElement(selectedElements[0])
@ -9150,7 +9155,6 @@ class App extends React.Component<AppProps, AppState> {
resizeY, resizeY,
pointerDownState.resize.center.x, pointerDownState.resize.center.x,
pointerDownState.resize.center.y, pointerDownState.resize.center.y,
this.state,
) )
) { ) {
this.maybeSuggestBindingForAll(selectedElements); this.maybeSuggestBindingForAll(selectedElements);

View file

@ -226,7 +226,7 @@ const LayerUI = ({
> >
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
elements={elements} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
/> />
</Island> </Island>

View file

@ -183,7 +183,7 @@ export const MobileMenu = ({
<Section className="App-mobile-menu" heading="selectedShapeActions"> <Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions <SelectedShapeActions
appState={appState} appState={appState}
elements={elements} elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction} renderAction={actionManager.renderAction}
/> />
</Section> </Section>

View file

@ -7,6 +7,7 @@ import type { DOMAttributes } from "react";
import type { AppState, InteractiveCanvasAppState } from "../../types"; import type { AppState, InteractiveCanvasAppState } from "../../types";
import type { import type {
InteractiveCanvasRenderConfig, InteractiveCanvasRenderConfig,
RenderableElementsMap,
RenderInteractiveSceneCallback, RenderInteractiveSceneCallback,
} from "../../scene/types"; } from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types"; import type { NonDeletedExcalidrawElement } from "../../element/types";
@ -15,7 +16,7 @@ import { isRenderThrottlingEnabled } from "../../reactUtils";
type InteractiveCanvasProps = { type InteractiveCanvasProps = {
containerRef: React.RefObject<HTMLDivElement>; containerRef: React.RefObject<HTMLDivElement>;
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined; versionNonce: number | undefined;
@ -113,7 +114,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
renderInteractiveScene( renderInteractiveScene(
{ {
canvas: props.canvas, canvas: props.canvas,
elements: props.elements, elementsMap: props.elementsMap,
visibleElements: props.visibleElements, visibleElements: props.visibleElements,
selectedElements: props.selectedElements, selectedElements: props.selectedElements,
scale: window.devicePixelRatio, scale: window.devicePixelRatio,
@ -201,10 +202,10 @@ const areEqual = (
prevProps.selectionNonce !== nextProps.selectionNonce || prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.versionNonce !== nextProps.versionNonce || prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale || 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 // even if versionNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elements !== nextProps.elements || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements || prevProps.visibleElements !== nextProps.visibleElements ||
prevProps.selectedElements !== nextProps.selectedElements prevProps.selectedElements !== nextProps.selectedElements
) { ) {

View file

@ -3,14 +3,17 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene"; import { renderStaticScene } from "../../renderer/renderScene";
import { isShallowEqual } from "../../utils"; import { isShallowEqual } from "../../utils";
import type { AppState, StaticCanvasAppState } from "../../types"; 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 type { NonDeletedExcalidrawElement } from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils"; import { isRenderThrottlingEnabled } from "../../reactUtils";
type StaticCanvasProps = { type StaticCanvasProps = {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
rc: RoughCanvas; rc: RoughCanvas;
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
versionNonce: number | undefined; versionNonce: number | undefined;
selectionNonce: number | undefined; selectionNonce: number | undefined;
@ -63,7 +66,7 @@ const StaticCanvas = (props: StaticCanvasProps) => {
canvas, canvas,
rc: props.rc, rc: props.rc,
scale: props.scale, scale: props.scale,
elements: props.elements, elementsMap: props.elementsMap,
visibleElements: props.visibleElements, visibleElements: props.visibleElements,
appState: props.appState, appState: props.appState,
renderConfig: props.renderConfig, renderConfig: props.renderConfig,
@ -106,10 +109,10 @@ const areEqual = (
if ( if (
prevProps.versionNonce !== nextProps.versionNonce || prevProps.versionNonce !== nextProps.versionNonce ||
prevProps.scale !== nextProps.scale || 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 // even if versionNonce didn't change (e.g. we filter elements out based
// on appState) // on appState)
prevProps.elements !== nextProps.elements || prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements prevProps.visibleElements !== nextProps.visibleElements
) { ) {
return false; return false;

View file

@ -40,6 +40,7 @@ import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types"; import { MarkOptional, Mutable } from "../utility-types";
import { import {
detectLineHeight, detectLineHeight,
getContainerElement,
getDefaultLineHeight, getDefaultLineHeight,
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
@ -179,7 +180,6 @@ const restoreElementWithProperties = <
const restoreElement = ( const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
refreshDimensions = false,
): typeof element | null => { ): typeof element | null => {
switch (element.type) { switch (element.type) {
case "text": case "text":
@ -232,10 +232,6 @@ const restoreElement = (
element = bumpVersion(element); element = bumpVersion(element);
} }
if (refreshDimensions) {
element = { ...element, ...refreshTextDimensions(element) };
}
return element; return element;
case "freedraw": { case "freedraw": {
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@ -426,10 +422,7 @@ export const restoreElements = (
// filtering out selection, which is legacy, no longer kept in elements, // filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained // and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) { if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement( let migratedElement: ExcalidrawElement | null = restoreElement(element);
element,
opts?.refreshDimensions,
);
if (migratedElement) { if (migratedElement) {
const localElement = localElementsMap?.get(element.id); const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) { if (localElement && localElement.version > migratedElement.version) {
@ -462,6 +455,16 @@ export const restoreElements = (
} else if (element.boundElements) { } else if (element.boundElements) {
repairContainerElement(element, restoredElementsMap); repairContainerElement(element, restoredElementsMap);
} }
if (opts.refreshDimensions && isTextElement(element)) {
Object.assign(
element,
refreshTextDimensions(
element,
getContainerElement(element, restoredElementsMap),
),
);
}
} }
return restoredElements; return restoredElements;

View file

@ -5,6 +5,7 @@ import {
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
NonDeleted, NonDeleted,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ElementsMapOrArray,
} from "./types"; } from "./types";
import { distance2d, rotate, rotatePoint } from "../math"; import { distance2d, rotate, rotatePoint } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
@ -161,7 +162,11 @@ export const getElementAbsoluteCoords = (
includeBoundText, includeBoundText,
); );
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
const container = getContainerElement(element); const elementsMap =
Scene.getScene(element)?.getElementsMapIncludingDeleted();
const container = elementsMap
? getContainerElement(element, elementsMap)
: null;
if (isArrowElement(container)) { if (isArrowElement(container)) {
const coords = LinearElementEditor.getBoundTextElementPosition( const coords = LinearElementEditor.getBoundTextElementPosition(
container, container,
@ -729,10 +734,8 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = (element: ExcalidrawElement): Bounds => { export const getElementBounds = (element: ExcalidrawElement): Bounds => {
return ElementBounds.getBounds(element); return ElementBounds.getBounds(element);
}; };
export const getCommonBounds = ( export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => {
elements: readonly ExcalidrawElement[], if ("size" in elements ? !elements.size : !elements.length) {
): Bounds => {
if (!elements.length) {
return [0, 0, 0, 0]; return [0, 0, 0, 0];
} }

View file

@ -5,17 +5,12 @@ import { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils"; import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement"; import { wrapText } from "./textElement";
import { import { isIframeElement } from "./typeChecks";
isFrameLikeElement,
isIframeElement,
isIframeLikeElement,
} from "./typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
IframeData, IframeData,
NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
const embeddedLinkCache = new Map<string, IframeData>(); const embeddedLinkCache = new Map<string, IframeData>();
@ -217,21 +212,6 @@ export const getEmbedLink = (
return { link, intrinsicSize: aspectRatio, type }; 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 = ( export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawIframeLikeElement, element: ExcalidrawIframeLikeElement,
): ExcalidrawElement => { ): ExcalidrawElement => {

View file

@ -31,7 +31,6 @@ import { getElementAbsoluteCoords } from ".";
import { adjustXYWithRotation } from "../math"; import { adjustXYWithRotation } from "../math";
import { getResizedElementAbsoluteCoords } from "./bounds"; import { getResizedElementAbsoluteCoords } from "./bounds";
import { import {
getContainerElement,
measureText, measureText,
normalizeText, normalizeText,
wrapText, wrapText,
@ -333,12 +332,12 @@ const getAdjustedDimensions = (
export const refreshTextDimensions = ( export const refreshTextDimensions = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
text = textElement.text, text = textElement.text,
) => { ) => {
if (textElement.isDeleted) { if (textElement.isDeleted) {
return; return;
} }
const container = getContainerElement(textElement);
if (container) { if (container) {
text = wrapText( text = wrapText(
text, text,
@ -352,6 +351,7 @@ export const refreshTextDimensions = (
export const updateTextElement = ( export const updateTextElement = (
textElement: ExcalidrawTextElement, textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
{ {
text, text,
isDeleted, isDeleted,
@ -365,7 +365,7 @@ export const updateTextElement = (
return newElementWith(textElement, { return newElementWith(textElement, {
originalText, originalText,
isDeleted: isDeleted ?? textElement.isDeleted, isDeleted: isDeleted ?? textElement.isDeleted,
...refreshTextDimensions(textElement, originalText), ...refreshTextDimensions(textElement, container, originalText),
}); });
}; };

View file

@ -15,6 +15,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap,
} from "./types"; } from "./types";
import type { Mutable } from "../utility-types"; import type { Mutable } from "../utility-types";
import { import {
@ -41,7 +42,7 @@ import {
MaybeTransformHandleType, MaybeTransformHandleType,
TransformHandleDirection, TransformHandleDirection,
} from "./transformHandles"; } from "./transformHandles";
import { AppState, Point, PointerDownState } from "../types"; import { Point, PointerDownState } from "../types";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
getApproxMinLineWidth, getApproxMinLineWidth,
@ -68,10 +69,10 @@ export const normalizeAngle = (angle: number): number => {
// Returns true when transform (resizing/rotation) happened // Returns true when transform (resizing/rotation) happened
export const transformElements = ( export const transformElements = (
pointerDownState: PointerDownState, originalElements: PointerDownState["originalElements"],
transformHandleType: MaybeTransformHandleType, transformHandleType: MaybeTransformHandleType,
selectedElements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[],
resizeArrowDirection: "origin" | "end", elementsMap: ElementsMap,
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean, shouldMaintainAspectRatio: boolean,
@ -79,7 +80,6 @@ export const transformElements = (
pointerY: number, pointerY: number,
centerX: number, centerX: number,
centerY: number, centerY: number,
appState: AppState,
) => { ) => {
if (selectedElements.length === 1) { if (selectedElements.length === 1) {
const [element] = selectedElements; const [element] = selectedElements;
@ -89,7 +89,6 @@ export const transformElements = (
pointerX, pointerX,
pointerY, pointerY,
shouldRotateWithDiscreteAngle, shouldRotateWithDiscreteAngle,
pointerDownState.originalElements,
); );
updateBoundElements(element); updateBoundElements(element);
} else if ( } else if (
@ -101,6 +100,7 @@ export const transformElements = (
) { ) {
resizeSingleTextElement( resizeSingleTextElement(
element, element,
elementsMap,
transformHandleType, transformHandleType,
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
@ -109,9 +109,10 @@ export const transformElements = (
updateBoundElements(element); updateBoundElements(element);
} else if (transformHandleType) { } else if (transformHandleType) {
resizeSingleElement( resizeSingleElement(
pointerDownState.originalElements, originalElements,
shouldMaintainAspectRatio, shouldMaintainAspectRatio,
element, element,
elementsMap,
transformHandleType, transformHandleType,
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
@ -123,7 +124,7 @@ export const transformElements = (
} else if (selectedElements.length > 1) { } else if (selectedElements.length > 1) {
if (transformHandleType === "rotation") { if (transformHandleType === "rotation") {
rotateMultipleElements( rotateMultipleElements(
pointerDownState, originalElements,
selectedElements, selectedElements,
pointerX, pointerX,
pointerY, pointerY,
@ -139,8 +140,9 @@ export const transformElements = (
transformHandleType === "se" transformHandleType === "se"
) { ) {
resizeMultipleElements( resizeMultipleElements(
pointerDownState, originalElements,
selectedElements, selectedElements,
elementsMap,
transformHandleType, transformHandleType,
shouldResizeFromCenter, shouldResizeFromCenter,
pointerX, pointerX,
@ -157,7 +159,6 @@ const rotateSingleElement = (
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
shouldRotateWithDiscreteAngle: boolean, shouldRotateWithDiscreteAngle: boolean,
originalElements: Map<string, NonDeleted<ExcalidrawElement>>,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
@ -207,6 +208,7 @@ const rescalePointsInElement = (
const measureFontSizeFromWidth = ( const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
): { size: number; baseline: number } | null => { ): { size: number; baseline: number } | null => {
@ -215,7 +217,7 @@ const measureFontSizeFromWidth = (
const hasContainer = isBoundToContainer(element); const hasContainer = isBoundToContainer(element);
if (hasContainer) { if (hasContainer) {
const container = getContainerElement(element); const container = getContainerElement(element, elementsMap);
if (container) { if (container) {
width = getBoundTextMaxWidth(container); width = getBoundTextMaxWidth(container);
} }
@ -257,6 +259,7 @@ const getSidesForTransformHandle = (
const resizeSingleTextElement = ( const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>, element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
@ -303,7 +306,12 @@ const resizeSingleTextElement = (
if (scale > 0) { if (scale > 0) {
const nextWidth = element.width * scale; const nextWidth = element.width * scale;
const nextHeight = element.height * scale; const nextHeight = element.height * scale;
const metrics = measureFontSizeFromWidth(element, nextWidth, nextHeight); const metrics = measureFontSizeFromWidth(
element,
elementsMap,
nextWidth,
nextHeight,
);
if (metrics === null) { if (metrics === null) {
return; return;
} }
@ -342,6 +350,7 @@ export const resizeSingleElement = (
originalElements: PointerDownState["originalElements"], originalElements: PointerDownState["originalElements"],
shouldMaintainAspectRatio: boolean, shouldMaintainAspectRatio: boolean,
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
transformHandleDirection: TransformHandleDirection, transformHandleDirection: TransformHandleDirection,
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
@ -448,6 +457,7 @@ export const resizeSingleElement = (
const nextFont = measureFontSizeFromWidth( const nextFont = measureFontSizeFromWidth(
boundTextElement, boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement), getBoundTextMaxWidth(updatedElement),
getBoundTextMaxHeight(updatedElement, boundTextElement), getBoundTextMaxHeight(updatedElement, boundTextElement),
); );
@ -637,8 +647,9 @@ export const resizeSingleElement = (
}; };
export const resizeMultipleElements = ( export const resizeMultipleElements = (
pointerDownState: PointerDownState, originalElements: PointerDownState["originalElements"],
selectedElements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
transformHandleType: "nw" | "ne" | "sw" | "se", transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean, shouldResizeFromCenter: boolean,
pointerX: number, pointerX: number,
@ -658,7 +669,7 @@ export const resizeMultipleElements = (
}[], }[],
element, element,
) => { ) => {
const origElement = pointerDownState.originalElements.get(element.id); const origElement = originalElements.get(element.id);
if (origElement) { if (origElement) {
acc.push({ orig: origElement, latest: element }); acc.push({ orig: origElement, latest: element });
} }
@ -679,7 +690,7 @@ export const resizeMultipleElements = (
if (!textId) { if (!textId) {
return acc; return acc;
} }
const text = pointerDownState.originalElements.get(textId) ?? null; const text = originalElements.get(textId) ?? null;
if (!isBoundToContainer(text)) { if (!isBoundToContainer(text)) {
return acc; return acc;
} }
@ -825,7 +836,12 @@ export const resizeMultipleElements = (
} }
if (isTextElement(orig)) { if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(orig, width, height); const metrics = measureFontSizeFromWidth(
orig,
elementsMap,
width,
height,
);
if (!metrics) { if (!metrics) {
return; return;
} }
@ -833,7 +849,7 @@ export const resizeMultipleElements = (
update.baseline = metrics.baseline; update.baseline = metrics.baseline;
} }
const boundTextElement = pointerDownState.originalElements.get( const boundTextElement = originalElements.get(
getBoundTextElementId(orig) ?? "", getBoundTextElementId(orig) ?? "",
) as ExcalidrawTextElementWithContainer | undefined; ) as ExcalidrawTextElementWithContainer | undefined;
@ -884,7 +900,7 @@ export const resizeMultipleElements = (
}; };
const rotateMultipleElements = ( const rotateMultipleElements = (
pointerDownState: PointerDownState, originalElements: PointerDownState["originalElements"],
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@ -906,8 +922,7 @@ const rotateMultipleElements = (
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
const origAngle = const origAngle =
pointerDownState.originalElements.get(element.id)?.angle ?? originalElements.get(element.id)?.angle ?? element.angle;
element.angle;
const [rotatedCX, rotatedCY] = rotate( const [rotatedCX, rotatedCY] = rotate(
cx, cx,
cy, cy,

View file

@ -1,5 +1,6 @@
import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils"; import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
import { import {
ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawTextContainer, ExcalidrawTextContainer,
@ -682,17 +683,15 @@ export const getBoundTextElement = (element: ExcalidrawElement | null) => {
}; };
export const getContainerElement = ( export const getContainerElement = (
element: element: ExcalidrawTextElement | null,
| (ExcalidrawElement & { elementsMap: ElementsMap,
containerId: ExcalidrawElement["id"] | null; ): ExcalidrawTextContainer | null => {
})
| null,
) => {
if (!element) { if (!element) {
return null; return null;
} }
if (element.containerId) { if (element.containerId) {
return Scene.getScene(element)?.getElement(element.containerId) || null; return (elementsMap.get(element.containerId) ||
null) as ExcalidrawTextContainer | null;
} }
return null; return null;
}; };
@ -752,28 +751,16 @@ export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
}; };
}; };
export const getTextElementAngle = (textElement: ExcalidrawTextElement) => { export const getTextElementAngle = (
const container = getContainerElement(textElement); textElement: ExcalidrawTextElement,
container: ExcalidrawTextContainer | null,
) => {
if (!container || isArrowElement(container)) { if (!container || isArrowElement(container)) {
return textElement.angle; return textElement.angle;
} }
return container.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 = ( export const getBoundTextElementPosition = (
container: ExcalidrawElement, container: ExcalidrawElement,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
@ -788,12 +775,12 @@ export const getBoundTextElementPosition = (
export const shouldAllowVerticalAlign = ( export const shouldAllowVerticalAlign = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
) => { ) => {
return selectedElements.some((element) => { return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element); if (isBoundToContainer(element)) {
if (hasBoundContainer) { const container = getContainerElement(element, elementsMap);
const container = getContainerElement(element); if (isArrowElement(container)) {
if (isTextElement(element) && isArrowElement(container)) {
return false; return false;
} }
return true; return true;
@ -804,12 +791,12 @@ export const shouldAllowVerticalAlign = (
export const suppportsHorizontalAlign = ( export const suppportsHorizontalAlign = (
selectedElements: NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
) => { ) => {
return selectedElements.some((element) => { return selectedElements.some((element) => {
const hasBoundContainer = isBoundToContainer(element); if (isBoundToContainer(element)) {
if (hasBoundContainer) { const container = getContainerElement(element, elementsMap);
const container = getContainerElement(element); if (isArrowElement(container)) {
if (isTextElement(element) && isArrowElement(container)) {
return false; return false;
} }
return true; return true;

View file

@ -153,7 +153,10 @@ export const textWysiwyg = ({
if (updatedTextElement && isTextElement(updatedTextElement)) { if (updatedTextElement && isTextElement(updatedTextElement)) {
let coordX = updatedTextElement.x; let coordX = updatedTextElement.x;
let coordY = updatedTextElement.y; let coordY = updatedTextElement.y;
const container = getContainerElement(updatedTextElement); const container = getContainerElement(
updatedTextElement,
app.scene.getElementsMapIncludingDeleted(),
);
let maxWidth = updatedTextElement.width; let maxWidth = updatedTextElement.width;
let maxHeight = updatedTextElement.height; let maxHeight = updatedTextElement.height;
@ -277,7 +280,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
textElementWidth, textElementWidth,
textElementHeight, textElementHeight,
getTextElementAngle(updatedTextElement), getTextElementAngle(updatedTextElement, container),
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@ -348,7 +351,10 @@ export const textWysiwyg = ({
if (!data) { if (!data) {
return; return;
} }
const container = getContainerElement(element); const container = getContainerElement(
element,
app.scene.getElementsMapIncludingDeleted(),
);
const font = getFontString({ const font = getFontString({
fontSize: app.state.currentItemFontSize, fontSize: app.state.currentItemFontSize,
@ -528,7 +534,10 @@ export const textWysiwyg = ({
return; return;
} }
let text = editable.value; let text = editable.value;
const container = getContainerElement(updateElement); const container = getContainerElement(
updateElement,
app.scene.getElementsMapIncludingDeleted(),
);
if (container) { if (container) {
text = updateElement.text; text = updateElement.text;

View file

@ -6,7 +6,7 @@ import {
THEME, THEME,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types"; import { MakeBrand, MarkNonNullable, ValueOf } from "../utility-types";
import { MagicCacheData } from "../data/magic"; import { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
@ -254,3 +254,31 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
export type ExcalidrawElementType = ExcalidrawElement["type"]; 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<ExcalidrawElement["id"], ExcalidrawElement>;
/**
* 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<ExcalidrawElement["id"], ExcalidrawElement> &
MakeBrand<"SceneElementsMap">;
export type ElementsMapOrArray =
| readonly ExcalidrawElement[]
| Readonly<ElementsMap>;

View file

@ -4,6 +4,8 @@ import {
isTextElement, isTextElement,
} from "./element"; } from "./element";
import { import {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
@ -26,6 +28,7 @@ import {
elementsOverlappingBBox, elementsOverlappingBBox,
} from "../utils/export"; } from "../utils/export";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import { ReadonlySetLike } from "./utility-types";
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
@ -211,9 +214,17 @@ export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
}; };
export const getFrameChildren = ( export const getFrameChildren = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ElementsMapOrArray,
frameId: string, 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 = ( export const getFrameLikeElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
@ -425,23 +436,20 @@ export const filterElementsEligibleAsFrameChildren = (
* Retains (or repairs for target frame) the ordering invriant where children * Retains (or repairs for target frame) the ordering invriant where children
* elements come right before the parent frame: * elements come right before the parent frame:
* [el, el, child, child, frame, el] * [el, el, child, child, frame, el]
*
* @returns mutated allElements (same data structure)
*/ */
export const addElementsToFrame = ( export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: ExcalidrawElementsIncludingDeleted, allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
) => { ): T => {
const { currTargetFrameChildrenMap } = allElements.reduce( const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
(acc, element, index) => { for (const element of allElements.values()) {
if (element.frameId === frame.id) { if (element.frameId === frame.id) {
acc.currTargetFrameChildrenMap.set(element.id, true); currTargetFrameChildrenMap.set(element.id, true);
}
} }
return acc;
},
{
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@ -492,13 +500,12 @@ export const addElementsToFrame = (
false, false,
); );
} }
return allElements.slice();
return allElements;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted, elementsToRemove: ReadonlySetLike<NonDeletedExcalidrawElement>,
elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState,
) => { ) => {
const _elementsToRemove = new Map< const _elementsToRemove = new Map<
ExcalidrawElement["id"], ExcalidrawElement["id"],
@ -536,35 +543,34 @@ export const removeElementsFromFrame = (
false, false,
); );
} }
return allElements.slice();
}; };
export const removeAllElementsFromFrame = ( export const removeAllElementsFromFrame = <T extends ExcalidrawElement>(
allElements: ExcalidrawElementsIncludingDeleted, allElements: readonly T[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState,
) => { ) => {
const elementsInFrame = getFrameChildren(allElements, frame.id); const elementsInFrame = getFrameChildren(allElements, frame.id);
return removeElementsFromFrame(allElements, elementsInFrame, appState); removeElementsFromFrame(elementsInFrame);
return allElements;
}; };
export const replaceAllElementsInFrame = ( export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: ExcalidrawElementsIncludingDeleted, allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, ): T[] => {
) => {
return addElementsToFrame( return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame, appState), removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame, nextElementsInFrame,
frame, frame,
); ).slice();
}; };
/** does not mutate elements, but returns new ones */ /** does not mutate elements, but returns new ones */
export const updateFrameMembershipOfSelectedElements = ( export const updateFrameMembershipOfSelectedElements = <
allElements: ExcalidrawElementsIncludingDeleted, T extends ElementsMapOrArray,
>(
allElements: T,
appState: AppState, appState: AppState,
app: AppClassProperties, app: AppClassProperties,
) => { ) => {
@ -589,19 +595,22 @@ export const updateFrameMembershipOfSelectedElements = (
const elementsToRemove = new Set<ExcalidrawElement>(); const elementsToRemove = new Set<ExcalidrawElement>();
const elementsMap = arrayToMap(allElements);
elementsToFilter.forEach((element) => { elementsToFilter.forEach((element) => {
if ( if (
element.frameId && element.frameId &&
!isFrameLikeElement(element) && !isFrameLikeElement(element) &&
!isElementInFrame(element, allElements, appState) !isElementInFrame(element, elementsMap, appState)
) { ) {
elementsToRemove.add(element); elementsToRemove.add(element);
} }
}); });
return elementsToRemove.size > 0 if (elementsToRemove.size > 0) {
? removeElementsFromFrame(allElements, [...elementsToRemove], appState) removeElementsFromFrame(elementsToRemove);
: allElements; }
return allElements;
}; };
/** /**
@ -609,14 +618,16 @@ export const updateFrameMembershipOfSelectedElements = (
* anywhere in the group tree * anywhere in the group tree
*/ */
export const omitGroupsContainingFrameLikes = ( export const omitGroupsContainingFrameLikes = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ElementsMapOrArray,
/** subset of elements you want to filter. Optional perf optimization so we /** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily * don't have to filter all elements unnecessarily
*/ */
selectedElements?: readonly ExcalidrawElement[], selectedElements?: readonly ExcalidrawElement[],
) => { ) => {
const uniqueGroupIds = new Set<string>(); const uniqueGroupIds = new Set<string>();
for (const el of selectedElements || allElements) { const elements = selectedElements || allElements;
for (const el of elements.values()) {
const topMostGroupId = el.groupIds[el.groupIds.length - 1]; const topMostGroupId = el.groupIds[el.groupIds.length - 1];
if (topMostGroupId) { if (topMostGroupId) {
uniqueGroupIds.add(topMostGroupId); uniqueGroupIds.add(topMostGroupId);
@ -634,9 +645,15 @@ export const omitGroupsContainingFrameLikes = (
} }
} }
return (selectedElements || allElements).filter( const ret: ExcalidrawElement[] = [];
(el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
); 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 = ( export const getTargetFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element) || element ? getContainerElement(element, elementsMap) || element
: element; : element;
return appState.selectedElementIds[_element.id] && return appState.selectedElementIds[_element.id] &&
@ -661,12 +679,12 @@ export const getTargetFrame = (
// given an element, return if the element is in some frame // given an element, return if the element is in some frame
export const isElementInFrame = ( export const isElementInFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
allElements: ExcalidrawElementsIncludingDeleted, allElements: ElementsMap,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
const frame = getTargetFrame(element, appState); const frame = getTargetFrame(element, allElements, appState);
const _element = isTextElement(element) const _element = isTextElement(element)
? getContainerElement(element) || element ? getContainerElement(element, allElements) || element
: element; : element;
if (frame) { if (frame) {

View file

@ -3,6 +3,7 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
ElementsMapOrArray,
} from "./element/types"; } from "./element/types";
import { import {
AppClassProperties, AppClassProperties,
@ -270,9 +271,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
element.groupIds.includes(groupId); element.groupIds.includes(groupId);
export const getElementsInGroup = ( export const getElementsInGroup = (
elements: readonly ExcalidrawElement[], elements: ElementsMapOrArray,
groupId: string, 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 = ( export const getSelectedGroupIdForElement = (
element: ExcalidrawElement, element: ExcalidrawElement,

View file

@ -21,7 +21,11 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Drawable } from "roughjs/bin/core"; import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg"; import type { RoughSVG } from "roughjs/bin/svg";
import { SVGRenderConfig, StaticCanvasRenderConfig } from "../scene/types"; import {
SVGRenderConfig,
StaticCanvasRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import { import {
distance, distance,
getFontString, getFontString,
@ -611,6 +615,7 @@ export const renderSelectionElement = (
export const renderElement = ( export const renderElement = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rc: RoughCanvas, rc: RoughCanvas,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
@ -715,7 +720,7 @@ export const renderElement = (
let shiftX = (x2 - x1) / 2 - (element.x - x1); let shiftX = (x2 - x1) / 2 - (element.x - x1);
let shiftY = (y2 - y1) / 2 - (element.y - y1); let shiftY = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) { if (isTextElement(element)) {
const container = getContainerElement(element); const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) { if (isArrowElement(container)) {
const boundTextCoords = const boundTextCoords =
LinearElementEditor.getBoundTextElementPosition( LinearElementEditor.getBoundTextElementPosition(
@ -900,6 +905,7 @@ const maybeWrapNodesInFrameClipPath = (
export const renderElementToSvg = ( export const renderElementToSvg = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rsvg: RoughSVG, rsvg: RoughSVG,
svgRoot: SVGElement, svgRoot: SVGElement,
files: BinaryFiles, files: BinaryFiles,
@ -912,7 +918,7 @@ export const renderElementToSvg = (
let cx = (x2 - x1) / 2 - (element.x - x1); let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1); let cy = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) { if (isTextElement(element)) {
const container = getContainerElement(element); const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) { if (isArrowElement(container)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); const [x1, y1, x2, y2] = getElementAbsoluteCoords(container);
@ -1013,6 +1019,7 @@ export const renderElementToSvg = (
createPlaceholderEmbeddableLabel(element); createPlaceholderEmbeddableLabel(element);
renderElementToSvg( renderElementToSvg(
label, label,
elementsMap,
rsvg, rsvg,
root, root,
files, files,

View file

@ -33,6 +33,7 @@ import {
SVGRenderConfig, SVGRenderConfig,
StaticCanvasRenderConfig, StaticCanvasRenderConfig,
StaticSceneRenderConfig, StaticSceneRenderConfig,
RenderableElementsMap,
} from "../scene/types"; } from "../scene/types";
import { import {
getScrollBars, getScrollBars,
@ -61,7 +62,7 @@ import {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { throttleRAF } from "../utils"; import { arrayToMap, throttleRAF } from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { FRAME_STYLE, THEME_FILTER } from "../constants"; import { FRAME_STYLE, THEME_FILTER } from "../constants";
import { import {
@ -75,10 +76,7 @@ import {
isIframeLikeElement, isIframeLikeElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
isIframeLikeOrItsLabel,
createPlaceholderEmbeddableLabel,
} from "../element/embeddable";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getTargetFrame, getTargetFrame,
@ -446,7 +444,7 @@ const bootstrapCanvas = ({
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
canvas, canvas,
elements, elementsMap,
visibleElements, visibleElements,
selectedElements, selectedElements,
scale, scale,
@ -454,7 +452,7 @@ const _renderInteractiveScene = ({
renderConfig, renderConfig,
}: InteractiveSceneRenderConfig) => { }: InteractiveSceneRenderConfig) => {
if (canvas === null) { if (canvas === null) {
return { atLeastOneVisibleElement: false, elements }; return { atLeastOneVisibleElement: false, elementsMap };
} }
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
@ -562,17 +560,9 @@ const _renderInteractiveScene = ({
if (showBoundingBox) { if (showBoundingBox) {
// Optimisation for finding quickly relevant element ids // Optimisation for finding quickly relevant element ids
const locallySelectedIds = selectedElements.reduce( const locallySelectedIds = arrayToMap(selectedElements);
(acc: Record<string, boolean>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
const selections = elements.reduce( const selections: {
(
acc: {
angle: number; angle: number;
elementX1: number; elementX1: number;
elementY1: number; elementY1: number;
@ -583,13 +573,13 @@ const _renderInteractiveScene = ({
cx: number; cx: number;
cy: number; cy: number;
activeEmbeddable: boolean; activeEmbeddable: boolean;
}[], }[] = [];
element,
) => { for (const element of elementsMap.values()) {
const selectionColors = []; const selectionColors = [];
// local user // local user
if ( if (
locallySelectedIds[element.id] && locallySelectedIds.has(element.id) &&
!isSelectedViaGroup(appState, element) !isSelectedViaGroup(appState, element)
) { ) {
selectionColors.push(selectionColor); selectionColors.push(selectionColor);
@ -609,7 +599,7 @@ const _renderInteractiveScene = ({
if (selectionColors.length) { if (selectionColors.length) {
const [elementX1, elementY1, elementX2, elementY2, cx, cy] = const [elementX1, elementY1, elementX2, elementY2, cx, cy] =
getElementAbsoluteCoords(element, true); getElementAbsoluteCoords(element, true);
acc.push({ selections.push({
angle: element.angle, angle: element.angle,
elementX1, elementX1,
elementY1, elementY1,
@ -624,13 +614,10 @@ const _renderInteractiveScene = ({
appState.activeEmbeddable.state === "active", appState.activeEmbeddable.state === "active",
}); });
} }
return acc; }
},
[],
);
const addSelectionForGroupId = (groupId: GroupId) => { const addSelectionForGroupId = (groupId: GroupId) => {
const groupElements = getElementsInGroup(elements, groupId); const groupElements = getElementsInGroup(elementsMap, groupId);
const [elementX1, elementY1, elementX2, elementY2] = const [elementX1, elementY1, elementX2, elementY2] =
getCommonBounds(groupElements); getCommonBounds(groupElements);
selections.push({ selections.push({
@ -870,7 +857,7 @@ const _renderInteractiveScene = ({
let scrollBars; let scrollBars;
if (renderConfig.renderScrollbars) { if (renderConfig.renderScrollbars) {
scrollBars = getScrollBars( scrollBars = getScrollBars(
elements, elementsMap,
normalizedWidth, normalizedWidth,
normalizedHeight, normalizedHeight,
appState, appState,
@ -897,14 +884,14 @@ const _renderInteractiveScene = ({
return { return {
scrollBars, scrollBars,
atLeastOneVisibleElement: visibleElements.length > 0, atLeastOneVisibleElement: visibleElements.length > 0,
elements, elementsMap,
}; };
}; };
const _renderStaticScene = ({ const _renderStaticScene = ({
canvas, canvas,
rc, rc,
elements, elementsMap,
visibleElements, visibleElements,
scale, scale,
appState, appState,
@ -965,7 +952,7 @@ const _renderStaticScene = ({
// Paint visible elements // Paint visible elements
visibleElements visibleElements
.filter((el) => !isIframeLikeOrItsLabel(el)) .filter((el) => !isIframeLikeElement(el))
.forEach((element) => { .forEach((element) => {
try { try {
const frameId = element.frameId || appState.frameToHighlight?.id; const frameId = element.frameId || appState.frameToHighlight?.id;
@ -977,16 +964,30 @@ const _renderStaticScene = ({
) { ) {
context.save(); context.save();
const frame = getTargetFrame(element, appState); const frame = getTargetFrame(element, elementsMap, appState);
// TODO do we need to check isElementInFrame here? // 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); frameClip(frame, context, renderConfig, appState);
} }
renderElement(element, rc, context, renderConfig, appState); renderElement(
element,
elementsMap,
rc,
context,
renderConfig,
appState,
);
context.restore(); context.restore();
} else { } else {
renderElement(element, rc, context, renderConfig, appState); renderElement(
element,
elementsMap,
rc,
context,
renderConfig,
appState,
);
} }
if (!isExporting) { if (!isExporting) {
renderLinkIcon(element, context, appState); renderLinkIcon(element, context, appState);
@ -998,11 +999,18 @@ const _renderStaticScene = ({
// render embeddables on top // render embeddables on top
visibleElements visibleElements
.filter((el) => isIframeLikeOrItsLabel(el)) .filter((el) => isIframeLikeElement(el))
.forEach((element) => { .forEach((element) => {
try { try {
const render = () => { const render = () => {
renderElement(element, rc, context, renderConfig, appState); renderElement(
element,
elementsMap,
rc,
context,
renderConfig,
appState,
);
if ( if (
isIframeLikeElement(element) && isIframeLikeElement(element) &&
@ -1014,7 +1022,14 @@ const _renderStaticScene = ({
element.height element.height
) { ) {
const label = createPlaceholderEmbeddableLabel(element); const label = createPlaceholderEmbeddableLabel(element);
renderElement(label, rc, context, renderConfig, appState); renderElement(
label,
elementsMap,
rc,
context,
renderConfig,
appState,
);
} }
if (!isExporting) { if (!isExporting) {
renderLinkIcon(element, context, appState); renderLinkIcon(element, context, appState);
@ -1032,9 +1047,9 @@ const _renderStaticScene = ({
) { ) {
context.save(); 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); frameClip(frame, context, renderConfig, appState);
} }
render(); render();
@ -1448,6 +1463,7 @@ const renderLinkIcon = (
// This should be only called for exporting purposes // This should be only called for exporting purposes
export const renderSceneToSvg = ( export const renderSceneToSvg = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
elementsMap: RenderableElementsMap,
rsvg: RoughSVG, rsvg: RoughSVG,
svgRoot: SVGElement, svgRoot: SVGElement,
files: BinaryFiles, files: BinaryFiles,
@ -1459,12 +1475,13 @@ export const renderSceneToSvg = (
// render elements // render elements
elements elements
.filter((el) => !isIframeLikeOrItsLabel(el)) .filter((el) => !isIframeLikeElement(el))
.forEach((element) => { .forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
renderElementToSvg( renderElementToSvg(
element, element,
elementsMap,
rsvg, rsvg,
svgRoot, svgRoot,
files, files,
@ -1486,6 +1503,7 @@ export const renderSceneToSvg = (
try { try {
renderElementToSvg( renderElementToSvg(
element, element,
elementsMap,
rsvg, rsvg,
svgRoot, svgRoot,
files, files,

View file

@ -1,5 +1,6 @@
import { isTextElement, refreshTextDimensions } from "../element"; import { isTextElement, refreshTextDimensions } from "../element";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { getContainerElement } from "../element/textElement";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
@ -57,7 +58,13 @@ export class Fonts {
ShapeCache.delete(element); ShapeCache.delete(element);
didUpdate = true; didUpdate = true;
return newElementWith(element, { return newElementWith(element, {
...refreshTextDimensions(element), ...refreshTextDimensions(
element,
getContainerElement(
element,
this.scene.getElementsMapIncludingDeleted(),
),
),
}); });
} }
return element; return element;

View file

@ -1,10 +1,14 @@
import { isElementInViewport } from "../element/sizeHelpers"; import { isElementInViewport } from "../element/sizeHelpers";
import { isImageElement } from "../element/typeChecks"; import { isImageElement } from "../element/typeChecks";
import { NonDeletedExcalidrawElement } from "../element/types"; import {
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "../element/types";
import { cancelRender } from "../renderer/renderScene"; import { cancelRender } from "../renderer/renderScene";
import { AppState } from "../types"; import { AppState } from "../types";
import { memoize } from "../utils"; import { memoize, toBrandedType } from "../utils";
import Scene from "./Scene"; import Scene from "./Scene";
import { RenderableElementsMap } from "./types";
export class Renderer { export class Renderer {
private scene: Scene; private scene: Scene;
@ -15,7 +19,7 @@ export class Renderer {
public getRenderableElements = (() => { public getRenderableElements = (() => {
const getVisibleCanvasElements = ({ const getVisibleCanvasElements = ({
elements, elementsMap,
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
@ -24,7 +28,7 @@ export class Renderer {
height, height,
width, width,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: NonDeletedElementsMap;
zoom: AppState["zoom"]; zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"]; offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"]; offsetTop: AppState["offsetTop"];
@ -33,43 +37,55 @@ export class Renderer {
height: AppState["height"]; height: AppState["height"];
width: AppState["width"]; width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] => { }): readonly NonDeletedExcalidrawElement[] => {
return elements.filter((element) => const visibleElements: NonDeletedExcalidrawElement[] = [];
for (const element of elementsMap.values()) {
if (
isElementInViewport(element, width, height, { isElementInViewport(element, width, height, {
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
scrollX, scrollX,
scrollY, scrollY,
}), })
); ) {
visibleElements.push(element);
}
}
return visibleElements;
}; };
const getCanvasElements = ({ const getRenderableElements = ({
editingElement,
elements, elements,
editingElement,
pendingImageElementId, pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingElement: AppState["editingElement"]; editingElement: AppState["editingElement"];
pendingImageElementId: AppState["pendingImageElementId"]; pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
return elements.filter((element) => { const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
for (const element of elements) {
if (isImageElement(element)) { if (isImageElement(element)) {
if ( if (
// => not placed on canvas yet (but in elements array) // => not placed on canvas yet (but in elements array)
pendingImageElementId === element.id pendingImageElementId === element.id
) { ) {
return false; continue;
} }
} }
// we don't want to render text element that's being currently edited // we don't want to render text element that's being currently edited
// (it's rendered on remote only) // (it's rendered on remote only)
return ( if (
!editingElement || !editingElement ||
editingElement.type !== "text" || editingElement.type !== "text" ||
element.id !== editingElement.id element.id !== editingElement.id
); ) {
}); elementsMap.set(element.id, element);
}
}
return elementsMap;
}; };
return memoize( return memoize(
@ -100,14 +116,14 @@ export class Renderer {
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const canvasElements = getCanvasElements({ const elementsMap = getRenderableElements({
elements, elements,
editingElement, editingElement,
pendingImageElementId, pendingImageElementId,
}); });
const visibleElements = getVisibleCanvasElements({ const visibleElements = getVisibleCanvasElements({
elements: canvasElements, elementsMap,
zoom, zoom,
offsetLeft, offsetLeft,
offsetTop, offsetTop,
@ -117,7 +133,7 @@ export class Renderer {
width, width,
}); });
return { canvasElements, visibleElements }; return { elementsMap, visibleElements };
}, },
); );
})(); })();

View file

@ -3,14 +3,18 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
ElementsMapOrArray,
NonDeletedElementsMap,
SceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getNonDeletedElements, isNonDeletedElement } from "../element"; import { isNonDeletedElement } from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameLikeElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { AppState } from "../types"; import { AppState } from "../types";
import { Assert, SameType } from "../utility-types"; import { Assert, SameType } from "../utility-types";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { toBrandedType } from "../utils";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@ -20,6 +24,20 @@ type SceneStateCallbackRemover = () => void;
type SelectionHash = string & { __brand: "selectionHash" }; type SelectionHash = string & { __brand: "selectionHash" };
const getNonDeletedElements = <T extends ExcalidrawElement>(
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<T>);
elementsMap.set(element.id, element as NonDeletedExcalidrawElement);
}
}
return { elementsMap, elements };
};
const hashSelectionOpts = ( const hashSelectionOpts = (
opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0], opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
) => { ) => {
@ -102,11 +120,13 @@ class Scene {
private callbacks: Set<SceneStateCallback> = new Set(); private callbacks: Set<SceneStateCallback> = new Set();
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private nonDeletedElementsMap: NonDeletedElementsMap =
new Map() as NonDeletedElementsMap;
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] = private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
[]; [];
private frames: readonly ExcalidrawFrameLikeElement[] = []; private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = toBrandedType<SceneElementsMap>(new Map());
private selectedElementsCache: { private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null; selectedElementIds: AppState["selectedElementIds"] | null;
elements: readonly NonDeletedExcalidrawElement[] | null; elements: readonly NonDeletedExcalidrawElement[] | null;
@ -118,6 +138,14 @@ class Scene {
}; };
private versionNonce: number | undefined; private versionNonce: number | undefined;
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElementsMap() {
return this.nonDeletedElementsMap;
}
getElementsIncludingDeleted() { getElementsIncludingDeleted() {
return this.elements; return this.elements;
} }
@ -138,7 +166,7 @@ class Scene {
* scene state. This in effect will likely result in cache-miss, and * scene state. This in effect will likely result in cache-miss, and
* the cache won't be updated in this case. * the cache won't be updated in this case.
*/ */
elements?: readonly ExcalidrawElement[]; elements?: ElementsMapOrArray;
// selection-related options // selection-related options
includeBoundTextElement?: boolean; includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean; includeElementsInFrames?: boolean;
@ -227,23 +255,27 @@ class Scene {
return didChange; return didChange;
} }
replaceAllElements( replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) {
nextElements: readonly ExcalidrawElement[], this.elements =
mapElementIds = true, // ts doesn't like `Array.isArray` of `instanceof Map`
) { nextElements instanceof Array
this.elements = nextElements; ? nextElements
: Array.from(nextElements.values());
const nextFrameLikes: ExcalidrawFrameLikeElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();
nextElements.forEach((element) => { this.elements.forEach((element) => {
if (isFrameLikeElement(element)) { if (isFrameLikeElement(element)) {
nextFrameLikes.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, 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.frames = nextFrameLikes;
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames); this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
this.informMutation(); this.informMutation();
} }
@ -332,6 +364,22 @@ class Scene {
getElementIndex(elementId: string) { getElementIndex(elementId: string) {
return this.elements.findIndex((element) => element.id === elementId); 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; export default Scene;

View file

@ -11,7 +11,13 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "../element/bounds"; } from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; 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 { AppState, BinaryFiles } from "../types";
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
@ -37,6 +43,7 @@ import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene"; import Scene from "./Scene";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
import { RenderableElementsMap } from "./types";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -59,7 +66,7 @@ const __createSceneForElementsHack__ = (
// ids to Scene instances so that we don't override the editor elements // ids to Scene instances so that we don't override the editor elements
// mapping. // mapping.
// We still need to clone the objects themselves to regen references. // We still need to clone the objects themselves to regen references.
scene.replaceAllElements(cloneJSON(elements), false); scene.replaceAllElements(cloneJSON(elements));
return scene; return scene;
}; };
@ -241,10 +248,14 @@ export const exportToCanvas = async (
files, files,
}); });
const elementsMap = toBrandedType<RenderableElementsMap>(
arrayToMap(elementsForRender),
);
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
elements: elementsForRender, elementsMap,
visibleElements: elementsForRender, visibleElements: elementsForRender,
scale, scale,
appState: { appState: {
@ -432,7 +443,13 @@ export const exportToSvg = async (
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = opts?.renderEmbeddables ?? false;
renderSceneToSvg(elementsForRender, rsvg, svgRoot, files || {}, { renderSceneToSvg(
elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
rsvg,
svgRoot,
files || {},
{
offsetX, offsetX,
offsetY, offsetY,
isExporting: true, isExporting: true,
@ -447,7 +464,8 @@ export const exportToSvg = async (
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
}); },
);
tempScene.destroy(); tempScene.destroy();

View file

@ -1,7 +1,6 @@
import { ExcalidrawElement } from "../element/types";
import { getCommonBounds } from "../element"; import { getCommonBounds } from "../element";
import { InteractiveCanvasAppState } from "../types"; import { InteractiveCanvasAppState } from "../types";
import { ScrollBars } from "./types"; import { RenderableElementsMap, ScrollBars } from "./types";
import { getGlobalCSSVariable } from "../utils"; import { getGlobalCSSVariable } from "../utils";
import { getLanguage } from "../i18n"; 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 SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
export const getScrollBars = ( export const getScrollBars = (
elements: readonly ExcalidrawElement[], elements: RenderableElementsMap,
viewportWidth: number, viewportWidth: number,
viewportHeight: number, viewportHeight: number,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
): ScrollBars => { ): ScrollBars => {
if (elements.length === 0) { if (!elements.size) {
return { return {
horizontal: null, horizontal: null,
vertical: null, vertical: null,

View file

@ -1,4 +1,5 @@
import { import {
ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
@ -166,26 +167,28 @@ export const getCommonAttributeOfSelectedElements = <T>(
}; };
export const getSelectedElements = ( export const getSelectedElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: ElementsMapOrArray,
appState: Pick<InteractiveCanvasAppState, "selectedElementIds">, appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
opts?: { opts?: {
includeBoundTextElement?: boolean; includeBoundTextElement?: boolean;
includeElementsInFrames?: boolean; includeElementsInFrames?: boolean;
}, },
) => { ) => {
const selectedElements = elements.filter((element) => { const selectedElements: ExcalidrawElement[] = [];
for (const element of elements.values()) {
if (appState.selectedElementIds[element.id]) { if (appState.selectedElementIds[element.id]) {
return element; selectedElements.push(element);
continue;
} }
if ( if (
opts?.includeBoundTextElement && opts?.includeBoundTextElement &&
isBoundToContainer(element) && isBoundToContainer(element) &&
appState.selectedElementIds[element?.containerId] appState.selectedElementIds[element?.containerId]
) { ) {
return element; selectedElements.push(element);
continue;
}
} }
return null;
});
if (opts?.includeElementsInFrames) { if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
@ -205,7 +208,7 @@ export const getSelectedElements = (
}; };
export const getTargetElements = ( export const getTargetElements = (
elements: readonly NonDeletedExcalidrawElement[], elements: ElementsMapOrArray,
appState: Pick<AppState, "selectedElementIds" | "editingElement">, appState: Pick<AppState, "selectedElementIds" | "editingElement">,
) => ) =>
appState.editingElement appState.editingElement

View file

@ -2,6 +2,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
import { Drawable } from "roughjs/bin/core"; import { Drawable } from "roughjs/bin/core";
import { import {
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { import {
@ -12,6 +13,10 @@ import {
InteractiveCanvasAppState, InteractiveCanvasAppState,
StaticCanvasAppState, StaticCanvasAppState,
} from "../types"; } from "../types";
import { MakeBrand } from "../utility-types";
export type RenderableElementsMap = NonDeletedElementsMap &
MakeBrand<"RenderableElementsMap">;
export type StaticCanvasRenderConfig = { export type StaticCanvasRenderConfig = {
canvasBackgroundColor: AppState["viewBackgroundColor"]; canvasBackgroundColor: AppState["viewBackgroundColor"];
@ -53,14 +58,14 @@ export type InteractiveCanvasRenderConfig = {
export type RenderInteractiveSceneCallback = { export type RenderInteractiveSceneCallback = {
atLeastOneVisibleElement: boolean; atLeastOneVisibleElement: boolean;
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: RenderableElementsMap;
scrollBars?: ScrollBars; scrollBars?: ScrollBars;
}; };
export type StaticSceneRenderConfig = { export type StaticSceneRenderConfig = {
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
rc: RoughCanvas; rc: RoughCanvas;
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
scale: number; scale: number;
appState: StaticCanvasAppState; appState: StaticCanvasAppState;
@ -69,7 +74,7 @@ export type StaticSceneRenderConfig = {
export type InteractiveSceneRenderConfig = { export type InteractiveSceneRenderConfig = {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
elements: readonly NonDeletedExcalidrawElement[]; elementsMap: RenderableElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[]; visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[]; selectedElements: readonly NonDeletedExcalidrawElement[];
scale: number; scale: number;

View file

@ -54,3 +54,11 @@ export type Assert<T extends true> = T;
export type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number) export type NestedKeyOf<T, K = keyof T> = K extends keyof T & (string | number)
? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : never) ? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : never)
: never; : never;
export type SetLike<T> = Set<T> | T[];
export type ReadonlySetLike<T> = ReadonlySet<T> | readonly T[];
export type MakeBrand<T extends string> = {
/** @private using ~ to sort last in intellisense */
[K in `~brand~${T}`]: T;
};

View file

@ -650,8 +650,11 @@ export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
* or array of ids (strings), into a Map, keyd by `id`. * or array of ids (strings), into a Map, keyd by `id`.
*/ */
export const arrayToMap = <T extends { id: string } | string>( export const arrayToMap = <T extends { id: string } | string>(
items: readonly T[], items: readonly T[] | Map<string, T>,
) => { ) => {
if (items instanceof Map) {
return items;
}
return items.reduce((acc: Map<string, T>, element) => { return items.reduce((acc: Map<string, T>, element) => {
acc.set(typeof element === "string" ? element : element.id, element); acc.set(typeof element === "string" ? element : element.id, element);
return acc; return acc;
@ -1050,3 +1053,40 @@ export function getSvgPathFromStroke(points: number[][], closed = true) {
export const normalizeEOL = (str: string) => { export const normalizeEOL = (str: string) => {
return str.replace(/\r?\n|\r/g, "\n"); return str.replace(/\r?\n|\r/g, "\n");
}; };
// -----------------------------------------------------------------------------
type HasBrand<T> = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
}[keyof T];
type RemoveAllBrands<T> = HasBrand<T> 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> = T extends Map<infer E, infer F>
? Map<E, F>
: T extends Set<infer E>
? Set<E>
: T extends Array<infer E>
? Array<E>
: RemoveAllBrands<T>;
/**
* 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 = <BrandedType, CurrentType = BrandedType>(
value: Unbrand<BrandedType>,
) => {
return value as CurrentType & BrandedType;
};
// -----------------------------------------------------------------------------