You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
import {
|
|
ExcalidrawElement,
|
|
NonDeletedExcalidrawElement,
|
|
NonDeleted,
|
|
ExcalidrawFrameLikeElement,
|
|
} from "../element/types";
|
|
import { getNonDeletedElements, 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";
|
|
|
|
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
|
type ElementKey = ExcalidrawElement | ElementIdKey;
|
|
|
|
type SceneStateCallback = () => void;
|
|
type SceneStateCallbackRemover = () => void;
|
|
|
|
type SelectionHash = string & { __brand: "selectionHash" };
|
|
|
|
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,
|
|
/**
|
|
* 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<SceneStateCallback> = new Set();
|
|
|
|
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
|
private elements: readonly ExcalidrawElement[] = [];
|
|
private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
|
|
[];
|
|
private frames: readonly ExcalidrawFrameLikeElement[] = [];
|
|
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
|
private selectedElementsCache: {
|
|
selectedElementIds: AppState["selectedElementIds"] | null;
|
|
elements: readonly NonDeletedExcalidrawElement[] | null;
|
|
cache: Map<SelectionHash, NonDeletedExcalidrawElement[]>;
|
|
} = {
|
|
selectedElementIds: null,
|
|
elements: null,
|
|
cache: new Map(),
|
|
};
|
|
private versionNonce: number | undefined;
|
|
|
|
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?: readonly ExcalidrawElement[];
|
|
// 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;
|
|
}
|
|
|
|
getVersionNonce() {
|
|
return this.versionNonce;
|
|
}
|
|
|
|
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: readonly ExcalidrawElement[],
|
|
mapElementIds = true,
|
|
) {
|
|
this.elements = nextElements;
|
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
|
this.elementsMap.clear();
|
|
nextElements.forEach((element) => {
|
|
if (isFrameLikeElement(element)) {
|
|
nextFrameLikes.push(element);
|
|
}
|
|
this.elementsMap.set(element.id, element);
|
|
Scene.mapElementToScene(element, this);
|
|
});
|
|
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
|
this.frames = nextFrameLikes;
|
|
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
export default Scene;
|