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,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;