aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/scene/Scene.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/scene/Scene.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/scene/Scene.ts')
-rw-r--r--packages/excalidraw/scene/Scene.ts455
1 files changed, 455 insertions, 0 deletions
diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts
new file mode 100644
index 0000000..99bb9e1
--- /dev/null
+++ b/packages/excalidraw/scene/Scene.ts
@@ -0,0 +1,455 @@
+import throttle from "lodash.throttle";
+import type {
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ NonDeleted,
+ ExcalidrawFrameLikeElement,
+ ElementsMapOrArray,
+ SceneElementsMap,
+ NonDeletedSceneElementsMap,
+ OrderedExcalidrawElement,
+ Ordered,
+} from "../element/types";
+import { isNonDeletedElement } from "../element";
+import type { LinearElementEditor } from "../element/linearElementEditor";
+import { isFrameLikeElement } from "../element/typeChecks";
+import { getSelectedElements } from "./selection";
+import type { AppState } from "../types";
+import type { Assert, SameType } from "../utility-types";
+import { randomInteger } from "../random";
+import {
+ syncInvalidIndices,
+ syncMovedIndices,
+ validateFractionalIndices,
+} from "../fractionalIndex";
+import { arrayToMap } from "../utils";
+import { toBrandedType } from "../utils";
+import { ENV } from "../constants";
+import { getElementsInGroup } from "../groups";
+
+type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
+type ElementKey = ExcalidrawElement | ElementIdKey;
+
+type SceneStateCallback = () => void;
+type SceneStateCallbackRemover = () => void;
+
+type SelectionHash = string & { __brand: "selectionHash" };
+
+const getNonDeletedElements = <T extends ExcalidrawElement>(
+ 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<T>);
+ elementsMap.set(
+ element.id,
+ element as Ordered<NonDeletedExcalidrawElement>,
+ );
+ }
+ }
+ return { elementsMap, elements };
+};
+
+const validateIndicesThrottled = throttle(
+ (elements: readonly ExcalidrawElement[]) => {
+ if (
+ import.meta.env.DEV ||
+ import.meta.env.MODE === ENV.TEST ||
+ window?.DEBUG_FRACTIONAL_INDICES
+ ) {
+ validateFractionalIndices(elements, {
+ // throw only in dev & test, to remain functional on `DEBUG_FRACTIONAL_INDICES`
+ shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
+ includeBoundTextValidation: true,
+ });
+ }
+ },
+ 1000 * 60,
+ { leading: true, trailing: false },
+);
+
+const hashSelectionOpts = (
+ opts: Parameters<InstanceType<typeof Scene>["getSelectedElements"]>[0],
+) => {
+ const keys = ["includeBoundTextElement", "includeElementsInFrames"] as const;
+
+ type HashableKeys = Omit<typeof opts, "selectedElementIds" | "elements">;
+
+ // just to ensure we're hashing all expected keys
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ type _ = Assert<
+ SameType<
+ Required<HashableKeys>,
+ Pick<Required<HashableKeys>, 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<ExcalidrawElement, Scene>();
+ private static sceneMapById = new Map<string, Scene>();
+
+ static mapElementToScene(elementKey: ElementKey, scene: Scene) {
+ if (isIdKey(elementKey)) {
+ // 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 mapping element objects, also cache the id string when later
+ // looking up by id alone
+ this.sceneMapById.set(elementKey.id, scene);
+ }
+ }
+
+ /**
+ * @deprecated pass down `app.scene` and use it directly
+ */
+ 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<SceneStateCallback> = new Set();
+
+ private nonDeletedElements: readonly Ordered<NonDeletedExcalidrawElement>[] =
+ [];
+ private nonDeletedElementsMap = toBrandedType<NonDeletedSceneElementsMap>(
+ new Map(),
+ );
+ // ideally all elements within the scene should be wrapped around with `Ordered` type, but right now there is no real benefit doing so
+ private elements: readonly OrderedExcalidrawElement[] = [];
+ private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
+ [];
+ private frames: readonly ExcalidrawFrameLikeElement[] = [];
+ private elementsMap = toBrandedType<SceneElementsMap>(new Map());
+ private selectedElementsCache: {
+ selectedElementIds: AppState["selectedElementIds"] | null;
+ elements: readonly NonDeletedExcalidrawElement[] | null;
+ cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
+ } = {
+ selectedElementIds: null,
+ elements: null,
+ cache: new Map(),
+ };
+ /**
+ * Random integer regenerated each scene update.
+ *
+ * Does not relate to elements versions, it's only a renderer
+ * cache-invalidation nonce at the moment.
+ */
+ private sceneNonce: number | undefined;
+
+ getSceneNonce() {
+ return this.sceneNonce;
+ }
+
+ getNonDeletedElementsMap() {
+ return this.nonDeletedElementsMap;
+ }
+
+ getElementsIncludingDeleted() {
+ return this.elements;
+ }
+
+ getElementsMapIncludingDeleted() {
+ return this.elementsMap;
+ }
+
+ getNonDeletedElements() {
+ 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<ExcalidrawElement>[] {
+ 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<ExcalidrawFrameLikeElement>[] {
+ return this.nonDeletedFramesLikes;
+ }
+
+ getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
+ return (this.elementsMap.get(id) as T | undefined) || null;
+ }
+
+ getNonDeletedElement(
+ id: ExcalidrawElement["id"],
+ ): NonDeleted<ExcalidrawElement> | 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) {
+ const _nextElements =
+ // ts doesn't like `Array.isArray` of `instanceof Map`
+ nextElements instanceof Array
+ ? nextElements
+ : Array.from(nextElements.values());
+ const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
+
+ validateIndicesThrottled(_nextElements);
+
+ this.elements = syncInvalidIndices(_nextElements);
+ this.elementsMap.clear();
+ this.elements.forEach((element) => {
+ if (isFrameLikeElement(element)) {
+ nextFrameLikes.push(element);
+ }
+ this.elementsMap.set(element.id, element);
+ Scene.mapElementToScene(element, this);
+ });
+ const nonDeletedElements = getNonDeletedElements(this.elements);
+ this.nonDeletedElements = nonDeletedElements.elements;
+ this.nonDeletedElementsMap = nonDeletedElements.elementsMap;
+
+ this.frames = nextFrameLikes;
+ this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
+
+ this.triggerUpdate();
+ }
+
+ triggerUpdate() {
+ this.sceneNonce = randomInteger();
+
+ for (const callback of Array.from(this.callbacks)) {
+ callback();
+ }
+ }
+
+ onUpdate(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.elements = [];
+ this.nonDeletedElements = [];
+ 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),
+ ];
+
+ syncMovedIndices(nextElements, arrayToMap([element]));
+
+ this.replaceAllElements(nextElements);
+ }
+
+ insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
+ if (!elements.length) {
+ return;
+ }
+
+ 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),
+ ];
+
+ syncMovedIndices(nextElements, arrayToMap(elements));
+
+ this.replaceAllElements(nextElements);
+ }
+
+ insertElement = (element: ExcalidrawElement) => {
+ const index = element.frameId
+ ? this.getElementIndex(element.frameId)
+ : this.elements.length;
+
+ this.insertElementAtIndex(element, index);
+ };
+
+ insertElements = (elements: ExcalidrawElement[]) => {
+ if (!elements.length) {
+ return;
+ }
+
+ const index = elements[0]?.frameId
+ ? this.getElementIndex(elements[0].frameId)
+ : this.elements.length;
+
+ this.insertElementsAtIndex(elements, index);
+ };
+
+ 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;
+ };
+
+ getElementsFromId = (id: string): ExcalidrawElement[] => {
+ const elementsMap = this.getNonDeletedElementsMap();
+ // first check if the id is an element
+ const el = elementsMap.get(id);
+ if (el) {
+ return [el];
+ }
+
+ // then, check if the id is a group
+ return getElementsInGroup(elementsMap, id);
+ };
+}
+
+export default Scene;