mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
refactor: decoupling global Scene state part-1 (#7577)
This commit is contained in:
parent
740a165452
commit
0415c616b1
31 changed files with 630 additions and 384 deletions
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue