mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-19 12:14:24 +01:00
Introducing independent change detection for appState and elements Generalizing object change, cleanup, refactoring, comments, solving typing issues Shaping increment, change, delta hierarchy Structural clone of elements Introducing store and incremental API Disabling buttons for canvas actions, smaller store and changes improvements Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands Solving concurrency issues, solving (partly) linear element issues, introducing commitToStore breaking change Fixing existing tests, updating snapshots Trying to be smarter on the appstate change detection Extending collab test, refactoring action / updateScene params, bugfixes Resetting snapshots Resetting snapshots UI / API tests for history - WIP Changing actions related to the observed appstate to at least update the store snapshot - WIP Adding skipping of snapshot update flag for most no-breaking changes compatible solution Ignoring uncomitted elements from local async actions, updating store directly in updateScene Bound element issues - WIP
363 lines
11 KiB
TypeScript
363 lines
11 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";
|
|
import {
|
|
fixFractionalIndices,
|
|
validateFractionalIndicies,
|
|
} from "../fractionalIndex";
|
|
import { arrayToMap } from "../utils";
|
|
|
|
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;
|
|
}
|
|
|
|
getElementsMapIncludingDeleted() {
|
|
return this.elementsMap;
|
|
}
|
|
|
|
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[],
|
|
mapOfIndicesToFix?: Map<string, ExcalidrawElement>,
|
|
) {
|
|
let _nextElements;
|
|
if (mapOfIndicesToFix) {
|
|
_nextElements = fixFractionalIndices(nextElements, mapOfIndicesToFix);
|
|
} else {
|
|
_nextElements = nextElements;
|
|
}
|
|
|
|
if (import.meta.env.DEV) {
|
|
if (!validateFractionalIndicies(_nextElements)) {
|
|
console.error("fractional indices consistency has been compromised");
|
|
}
|
|
}
|
|
|
|
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, arrayToMap([element]));
|
|
}
|
|
|
|
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, arrayToMap(elements));
|
|
}
|
|
|
|
addNewElement = (element: ExcalidrawElement) => {
|
|
if (element.frameId) {
|
|
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
|
|
} else {
|
|
this.replaceAllElements(
|
|
[...this.elements, element],
|
|
arrayToMap([element]),
|
|
);
|
|
}
|
|
};
|
|
|
|
getElementIndex(elementId: string) {
|
|
return this.elements.findIndex((element) => element.id === elementId);
|
|
}
|
|
}
|
|
|
|
export default Scene;
|