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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import {
ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement,
ElementsMapOrArray,
} from "./element/types";
import {
AppClassProperties,
@ -270,9 +271,17 @@ export const isElementInGroup = (element: ExcalidrawElement, groupId: string) =>
element.groupIds.includes(groupId);
export const getElementsInGroup = (
elements: readonly ExcalidrawElement[],
elements: ElementsMapOrArray,
groupId: string,
) => elements.filter((element) => isElementInGroup(element, groupId));
) => {
const elementsInGroup: ExcalidrawElement[] = [];
for (const element of elements.values()) {
if (isElementInGroup(element, groupId)) {
elementsInGroup.push(element);
}
}
return elementsInGroup;
};
export const getSelectedGroupIdForElement = (
element: ExcalidrawElement,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)
? `${K}` | (T[K] extends object ? `${K}.${NestedKeyOf<T[K]>}` : 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`.
*/
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) => {
acc.set(typeof element === "string" ? element : element.id, element);
return acc;
@ -1050,3 +1053,40 @@ export function getSvgPathFromStroke(points: number[][], closed = true) {
export const normalizeEOL = (str: string) => {
return str.replace(/\r?\n|\r/g, "\n");
};
// -----------------------------------------------------------------------------
type HasBrand<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;
};
// -----------------------------------------------------------------------------