import { ExcalidrawElement, NonDeletedExcalidrawElement, NonDeleted, ExcalidrawFrameLikeElement, ElementsMapOrArray, SceneElementsMap, NonDeletedSceneElementsMap, } from "../element/types"; import { isNonDeletedElement } from "../element"; import { LinearElementEditor } from "../element/linearElementEditor"; import { isFrameLikeElement } from "../element/typeChecks"; import { getSelectedElements } from "./selection"; import { AppState } from "../types"; import { Assert, SameType } from "../utility-types"; import { randomInteger } from "../random"; import { toBrandedType } from "../utils"; type ElementIdKey = InstanceType["elementId"]; type ElementKey = ExcalidrawElement | ElementIdKey; type SceneStateCallback = () => void; type SceneStateCallbackRemover = () => void; type SelectionHash = string & { __brand: "selectionHash" }; const getNonDeletedElements = ( allElements: readonly T[], ) => { const elementsMap = new Map() as NonDeletedSceneElementsMap; const elements: T[] = []; for (const element of allElements) { if (!element.isDeleted) { elements.push(element as NonDeleted); elementsMap.set(element.id, element as NonDeletedExcalidrawElement); } } return { elementsMap, elements }; }; const hashSelectionOpts = ( opts: Parameters["getSelectedElements"]>[0], ) => { const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const; type HashableKeys = Omit; // just to ensure we're hashing all expected keys // eslint-disable-next-line @typescript-eslint/no-unused-vars type _ = Assert< SameType< Required, Pick, typeof keys[number]> > >; let hash = ""; for (const key of keys) { hash += `${key}:${opts[key] ? "1" : "0"}`; } return hash as SelectionHash; }; // ideally this would be a branded type but it'd be insanely hard to work with // in our codebase export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[]; const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => { if (typeof elementKey === "string") { return true; } return false; }; class Scene { // --------------------------------------------------------------------------- // static methods/props // --------------------------------------------------------------------------- private static sceneMapByElement = new WeakMap(); private static sceneMapById = new Map(); static mapElementToScene( elementKey: ElementKey, scene: Scene, /** * needed because of frame exporting hack. * elementId:Scene mapping will be removed completely, soon. */ mapElementIds = true, ) { if (isIdKey(elementKey)) { if (!mapElementIds) { return; } // for cases where we don't have access to the element object // (e.g. restore serialized appState with id references) this.sceneMapById.set(elementKey, scene); } else { this.sceneMapByElement.set(elementKey, scene); if (!mapElementIds) { // if mapping element objects, also cache the id string when later // looking up by id alone this.sceneMapById.set(elementKey.id, scene); } } } static getScene(elementKey: ElementKey): Scene | null { if (isIdKey(elementKey)) { return this.sceneMapById.get(elementKey) || null; } return this.sceneMapByElement.get(elementKey) || null; } // --------------------------------------------------------------------------- // instance methods/props // --------------------------------------------------------------------------- private callbacks: Set = new Set(); private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElementsMap = toBrandedType( new Map(), ); private elements: readonly ExcalidrawElement[] = []; private nonDeletedFramesLikes: readonly NonDeleted[] = []; private frames: readonly ExcalidrawFrameLikeElement[] = []; private elementsMap = toBrandedType(new Map()); private selectedElementsCache: { selectedElementIds: AppState["selectedElementIds"] | null; elements: readonly NonDeletedExcalidrawElement[] | null; cache: Map; } = { selectedElementIds: null, elements: null, cache: new Map(), }; private versionNonce: number | undefined; getElementsMapIncludingDeleted() { return this.elementsMap; } getNonDeletedElementsMap() { return this.nonDeletedElementsMap; } getElementsIncludingDeleted() { return this.elements; } getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] { return this.nonDeletedElements; } getFramesIncludingDeleted() { return this.frames; } getSelectedElements(opts: { // NOTE can be ommitted by making Scene constructor require App instance selectedElementIds: AppState["selectedElementIds"]; /** * for specific cases where you need to use elements not from current * scene state. This in effect will likely result in cache-miss, and * the cache won't be updated in this case. */ elements?: ElementsMapOrArray; // selection-related options includeBoundTextElement?: boolean; includeElementsInFrames?: boolean; }): NonDeleted[] { const hash = hashSelectionOpts(opts); const elements = opts?.elements || this.nonDeletedElements; if ( this.selectedElementsCache.elements === elements && this.selectedElementsCache.selectedElementIds === opts.selectedElementIds ) { const cached = this.selectedElementsCache.cache.get(hash); if (cached) { return cached; } } else if (opts?.elements == null) { // if we're operating on latest scene elements and the cache is not // storing the latest elements, clear the cache this.selectedElementsCache.cache.clear(); } const selectedElements = getSelectedElements( elements, { selectedElementIds: opts.selectedElementIds }, opts, ); // cache only if we're not using custom elements if (opts?.elements == null) { this.selectedElementsCache.selectedElementIds = opts.selectedElementIds; this.selectedElementsCache.elements = this.nonDeletedElements; this.selectedElementsCache.cache.set(hash, selectedElements); } return selectedElements; } getNonDeletedFramesLikes(): readonly NonDeleted[] { return this.nonDeletedFramesLikes; } getElement(id: T["id"]): T | null { return (this.elementsMap.get(id) as T | undefined) || null; } getVersionNonce() { return this.versionNonce; } getNonDeletedElement( id: ExcalidrawElement["id"], ): NonDeleted | null { const element = this.getElement(id); if (element && isNonDeletedElement(element)) { return element; } return null; } /** * A utility method to help with updating all scene elements, with the added * performance optimization of not renewing the array if no change is made. * * Maps all current excalidraw elements, invoking the callback for each * element. The callback should either return a new mapped element, or the * original element if no changes are made. If no changes are made to any * element, this results in a no-op. Otherwise, the newly mapped elements * are set as the next scene's elements. * * @returns whether a change was made */ mapElements( iteratee: (element: ExcalidrawElement) => ExcalidrawElement, ): boolean { let didChange = false; const newElements = this.elements.map((element) => { const nextElement = iteratee(element); if (nextElement !== element) { didChange = true; } return nextElement; }); if (didChange) { this.replaceAllElements(newElements); } return didChange; } 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(); this.elements.forEach((element) => { if (isFrameLikeElement(element)) { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); Scene.mapElementToScene(element, this, mapElementIds); }); const nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = nonDeletedElements.elements; this.nonDeletedElementsMap = nonDeletedElements.elementsMap; this.frames = nextFrameLikes; this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements; this.informMutation(); } informMutation() { this.versionNonce = randomInteger(); for (const callback of Array.from(this.callbacks)) { callback(); } } addCallback(cb: SceneStateCallback): SceneStateCallbackRemover { if (this.callbacks.has(cb)) { throw new Error(); } this.callbacks.add(cb); return () => { if (!this.callbacks.has(cb)) { throw new Error(); } this.callbacks.delete(cb); }; } destroy() { this.nonDeletedElements = []; this.elements = []; this.nonDeletedFramesLikes = []; this.frames = []; this.elementsMap.clear(); this.selectedElementsCache.selectedElementIds = null; this.selectedElementsCache.elements = null; this.selectedElementsCache.cache.clear(); Scene.sceneMapById.forEach((scene, elementKey) => { if (scene === this) { Scene.sceneMapById.delete(elementKey); } }); // done not for memory leaks, but to guard against possible late fires // (I guess?) this.callbacks.clear(); } insertElementAtIndex(element: ExcalidrawElement, index: number) { if (!Number.isFinite(index) || index < 0) { throw new Error( "insertElementAtIndex can only be called with index >= 0", ); } const nextElements = [ ...this.elements.slice(0, index), element, ...this.elements.slice(index), ]; this.replaceAllElements(nextElements); } insertElementsAtIndex(elements: ExcalidrawElement[], index: number) { if (!Number.isFinite(index) || index < 0) { throw new Error( "insertElementAtIndex can only be called with index >= 0", ); } const nextElements = [ ...this.elements.slice(0, index), ...elements, ...this.elements.slice(index), ]; this.replaceAllElements(nextElements); } addNewElement = (element: ExcalidrawElement) => { if (element.frameId) { this.insertElementAtIndex(element, this.getElementIndex(element.frameId)); } else { this.replaceAllElements([...this.elements, element]); } }; 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;