aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/scene
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
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/scene')
-rw-r--r--packages/excalidraw/scene/Renderer.ts167
-rw-r--r--packages/excalidraw/scene/Scene.ts455
-rw-r--r--packages/excalidraw/scene/Shape.ts603
-rw-r--r--packages/excalidraw/scene/ShapeCache.ts86
-rw-r--r--packages/excalidraw/scene/comparisons.ts44
-rw-r--r--packages/excalidraw/scene/export.ts558
-rw-r--r--packages/excalidraw/scene/index.ts20
-rw-r--r--packages/excalidraw/scene/normalize.ts15
-rw-r--r--packages/excalidraw/scene/scroll.ts91
-rw-r--r--packages/excalidraw/scene/scrollbars.ts124
-rw-r--r--packages/excalidraw/scene/selection.test.ts35
-rw-r--r--packages/excalidraw/scene/selection.ts250
-rw-r--r--packages/excalidraw/scene/types.ts155
-rw-r--r--packages/excalidraw/scene/zoom.ts35
14 files changed, 2638 insertions, 0 deletions
diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts
new file mode 100644
index 0000000..8048d20
--- /dev/null
+++ b/packages/excalidraw/scene/Renderer.ts
@@ -0,0 +1,167 @@
+import { isElementInViewport } from "../element/sizeHelpers";
+import { isImageElement } from "../element/typeChecks";
+import type {
+ ExcalidrawElement,
+ NonDeletedElementsMap,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
+import { renderStaticSceneThrottled } from "../renderer/staticScene";
+
+import type { AppState } from "../types";
+import { memoize, toBrandedType } from "../utils";
+import type Scene from "./Scene";
+import type { RenderableElementsMap } from "./types";
+
+export class Renderer {
+ private scene: Scene;
+
+ constructor(scene: Scene) {
+ this.scene = scene;
+ }
+
+ public getRenderableElements = (() => {
+ const getVisibleCanvasElements = ({
+ elementsMap,
+ zoom,
+ offsetLeft,
+ offsetTop,
+ scrollX,
+ scrollY,
+ height,
+ width,
+ }: {
+ elementsMap: NonDeletedElementsMap;
+ zoom: AppState["zoom"];
+ offsetLeft: AppState["offsetLeft"];
+ offsetTop: AppState["offsetTop"];
+ scrollX: AppState["scrollX"];
+ scrollY: AppState["scrollY"];
+ height: AppState["height"];
+ width: AppState["width"];
+ }): readonly NonDeletedExcalidrawElement[] => {
+ const visibleElements: NonDeletedExcalidrawElement[] = [];
+ for (const element of elementsMap.values()) {
+ if (
+ isElementInViewport(
+ element,
+ width,
+ height,
+ {
+ zoom,
+ offsetLeft,
+ offsetTop,
+ scrollX,
+ scrollY,
+ },
+ elementsMap,
+ )
+ ) {
+ visibleElements.push(element);
+ }
+ }
+ return visibleElements;
+ };
+
+ const getRenderableElements = ({
+ elements,
+ editingTextElement,
+ newElementId,
+ pendingImageElementId,
+ }: {
+ elements: readonly NonDeletedExcalidrawElement[];
+ editingTextElement: AppState["editingTextElement"];
+ newElementId: ExcalidrawElement["id"] | undefined;
+ pendingImageElementId: AppState["pendingImageElementId"];
+ }) => {
+ 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
+ ) {
+ continue;
+ }
+ }
+
+ if (newElementId === element.id) {
+ continue;
+ }
+
+ // we don't want to render text element that's being currently edited
+ // (it's rendered on remote only)
+ if (
+ !editingTextElement ||
+ editingTextElement.type !== "text" ||
+ element.id !== editingTextElement.id
+ ) {
+ elementsMap.set(element.id, element);
+ }
+ }
+ return elementsMap;
+ };
+
+ return memoize(
+ ({
+ zoom,
+ offsetLeft,
+ offsetTop,
+ scrollX,
+ scrollY,
+ height,
+ width,
+ editingTextElement,
+ newElementId,
+ pendingImageElementId,
+ // cache-invalidation nonce
+ sceneNonce: _sceneNonce,
+ }: {
+ zoom: AppState["zoom"];
+ offsetLeft: AppState["offsetLeft"];
+ offsetTop: AppState["offsetTop"];
+ scrollX: AppState["scrollX"];
+ scrollY: AppState["scrollY"];
+ height: AppState["height"];
+ width: AppState["width"];
+ editingTextElement: AppState["editingTextElement"];
+ /** note: first render of newElement will always bust the cache
+ * (we'd have to prefilter elements outside of this function) */
+ newElementId: ExcalidrawElement["id"] | undefined;
+ pendingImageElementId: AppState["pendingImageElementId"];
+ sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
+ }) => {
+ const elements = this.scene.getNonDeletedElements();
+
+ const elementsMap = getRenderableElements({
+ elements,
+ editingTextElement,
+ newElementId,
+ pendingImageElementId,
+ });
+
+ const visibleElements = getVisibleCanvasElements({
+ elementsMap,
+ zoom,
+ offsetLeft,
+ offsetTop,
+ scrollX,
+ scrollY,
+ height,
+ width,
+ });
+
+ return { elementsMap, visibleElements };
+ },
+ );
+ })();
+
+ // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
+ // safe to break TS contract here (for upstream cases)
+ public destroy() {
+ renderInteractiveSceneThrottled.cancel();
+ renderStaticSceneThrottled.cancel();
+ this.getRenderableElements.clear();
+ }
+}
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;
diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts
new file mode 100644
index 0000000..9c49db0
--- /dev/null
+++ b/packages/excalidraw/scene/Shape.ts
@@ -0,0 +1,603 @@
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
+import type { Drawable, Options } from "roughjs/bin/core";
+import type { RoughGenerator } from "roughjs/bin/generator";
+import { getDiamondPoints, getArrowheadPoints } from "../element";
+import type { ElementShapes } from "./types";
+import type {
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawLinearElement,
+ Arrowhead,
+} from "../element/types";
+import { generateFreeDrawShape } from "../renderer/renderElement";
+import { isTransparent, assertNever } from "../utils";
+import { simplify } from "points-on-curve";
+import { ROUGHNESS } from "../constants";
+import {
+ isElbowArrow,
+ isEmbeddableElement,
+ isIframeElement,
+ isIframeLikeElement,
+ isLinearElement,
+} from "../element/typeChecks";
+import { canChangeRoundness } from "./comparisons";
+import type { EmbedsValidationStatus } from "../types";
+import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
+import { getCornerRadius, isPathALoop } from "../shapes";
+import { headingForPointIsHorizontal } from "../element/heading";
+
+const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
+
+const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
+
+function adjustRoughness(element: ExcalidrawElement): number {
+ const roughness = element.roughness;
+
+ const maxSize = Math.max(element.width, element.height);
+ const minSize = Math.min(element.width, element.height);
+
+ // don't reduce roughness if
+ if (
+ // both sides relatively big
+ (minSize >= 20 && maxSize >= 50) ||
+ // is round & both sides above 15px
+ (minSize >= 15 &&
+ !!element.roundness &&
+ canChangeRoundness(element.type)) ||
+ // relatively long linear element
+ (isLinearElement(element) && maxSize >= 50)
+ ) {
+ return roughness;
+ }
+
+ return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
+}
+
+export const generateRoughOptions = (
+ element: ExcalidrawElement,
+ continuousPath = false,
+): Options => {
+ const options: Options = {
+ seed: element.seed,
+ strokeLineDash:
+ element.strokeStyle === "dashed"
+ ? getDashArrayDashed(element.strokeWidth)
+ : element.strokeStyle === "dotted"
+ ? getDashArrayDotted(element.strokeWidth)
+ : undefined,
+ // for non-solid strokes, disable multiStroke because it tends to make
+ // dashes/dots overlay each other
+ disableMultiStroke: element.strokeStyle !== "solid",
+ // for non-solid strokes, increase the width a bit to make it visually
+ // similar to solid strokes, because we're also disabling multiStroke
+ strokeWidth:
+ element.strokeStyle !== "solid"
+ ? element.strokeWidth + 0.5
+ : element.strokeWidth,
+ // when increasing strokeWidth, we must explicitly set fillWeight and
+ // hachureGap because if not specified, roughjs uses strokeWidth to
+ // calculate them (and we don't want the fills to be modified)
+ fillWeight: element.strokeWidth / 2,
+ hachureGap: element.strokeWidth * 4,
+ roughness: adjustRoughness(element),
+ stroke: element.strokeColor,
+ preserveVertices:
+ continuousPath || element.roughness < ROUGHNESS.cartoonist,
+ };
+
+ switch (element.type) {
+ case "rectangle":
+ case "iframe":
+ case "embeddable":
+ case "diamond":
+ case "ellipse": {
+ options.fillStyle = element.fillStyle;
+ options.fill = isTransparent(element.backgroundColor)
+ ? undefined
+ : element.backgroundColor;
+ if (element.type === "ellipse") {
+ options.curveFitting = 1;
+ }
+ return options;
+ }
+ case "line":
+ case "freedraw": {
+ if (isPathALoop(element.points)) {
+ options.fillStyle = element.fillStyle;
+ options.fill =
+ element.backgroundColor === "transparent"
+ ? undefined
+ : element.backgroundColor;
+ }
+ return options;
+ }
+ case "arrow":
+ return options;
+ default: {
+ throw new Error(`Unimplemented type ${element.type}`);
+ }
+ }
+};
+
+const modifyIframeLikeForRoughOptions = (
+ element: NonDeletedExcalidrawElement,
+ isExporting: boolean,
+ embedsValidationStatus: EmbedsValidationStatus | null,
+) => {
+ if (
+ isIframeLikeElement(element) &&
+ (isExporting ||
+ (isEmbeddableElement(element) &&
+ embedsValidationStatus?.get(element.id) !== true)) &&
+ isTransparent(element.backgroundColor) &&
+ isTransparent(element.strokeColor)
+ ) {
+ return {
+ ...element,
+ roughness: 0,
+ backgroundColor: "#d3d3d3",
+ fillStyle: "solid",
+ } as const;
+ } else if (isIframeElement(element)) {
+ return {
+ ...element,
+ strokeColor: isTransparent(element.strokeColor)
+ ? "#000000"
+ : element.strokeColor,
+ backgroundColor: isTransparent(element.backgroundColor)
+ ? "#f4f4f6"
+ : element.backgroundColor,
+ };
+ }
+ return element;
+};
+
+const getArrowheadShapes = (
+ element: ExcalidrawLinearElement,
+ shape: Drawable[],
+ position: "start" | "end",
+ arrowhead: Arrowhead,
+ generator: RoughGenerator,
+ options: Options,
+ canvasBackgroundColor: string,
+) => {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const generateCrowfootOne = (
+ arrowheadPoints: number[] | null,
+ options: Options,
+ ) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [, , x3, y3, x4, y4] = arrowheadPoints;
+
+ return [generator.line(x3, y3, x4, y4, options)];
+ };
+
+ switch (arrowhead) {
+ case "dot":
+ case "circle":
+ case "circle_outline": {
+ const [x, y, diameter] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.circle(x, y, diameter, {
+ ...options,
+ fill:
+ arrowhead === "circle_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+
+ fillStyle: "solid",
+ stroke: element.strokeColor,
+ roughness: Math.min(0.5, options.roughness || 0),
+ }),
+ ];
+ }
+ case "triangle":
+ case "triangle_outline": {
+ const [x, y, x2, y2, x3, y3] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.polygon(
+ [
+ [x, y],
+ [x2, y2],
+ [x3, y3],
+ [x, y],
+ ],
+ {
+ ...options,
+ fill:
+ arrowhead === "triangle_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+ fillStyle: "solid",
+ roughness: Math.min(1, options.roughness || 0),
+ },
+ ),
+ ];
+ }
+ case "diamond":
+ case "diamond_outline": {
+ const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ // always use solid stroke for arrowhead
+ delete options.strokeLineDash;
+
+ return [
+ generator.polygon(
+ [
+ [x, y],
+ [x2, y2],
+ [x3, y3],
+ [x4, y4],
+ [x, y],
+ ],
+ {
+ ...options,
+ fill:
+ arrowhead === "diamond_outline"
+ ? canvasBackgroundColor
+ : element.strokeColor,
+ fillStyle: "solid",
+ roughness: Math.min(1, options.roughness || 0),
+ },
+ ),
+ ];
+ }
+ case "crowfoot_one":
+ return generateCrowfootOne(arrowheadPoints, options);
+ case "bar":
+ case "arrow":
+ case "crowfoot_many":
+ case "crowfoot_one_or_many":
+ default: {
+ const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ if (element.strokeStyle === "dotted") {
+ // for dotted arrows caps, reduce gap to make it more legible
+ const dash = getDashArrayDotted(element.strokeWidth - 1);
+ options.strokeLineDash = [dash[0], dash[1] - 1];
+ } else {
+ // for solid/dashed, keep solid arrow cap
+ delete options.strokeLineDash;
+ }
+ options.roughness = Math.min(1, options.roughness || 0);
+ return [
+ generator.line(x3, y3, x2, y2, options),
+ generator.line(x4, y4, x2, y2, options),
+ ...(arrowhead === "crowfoot_one_or_many"
+ ? generateCrowfootOne(
+ getArrowheadPoints(element, shape, position, "crowfoot_one"),
+ options,
+ )
+ : []),
+ ];
+ }
+ }
+};
+
+/**
+ * Generates the roughjs shape for given element.
+ *
+ * Low-level. Use `ShapeCache.generateElementShape` instead.
+ *
+ * @private
+ */
+export const _generateElementShape = (
+ element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
+ generator: RoughGenerator,
+ {
+ isExporting,
+ canvasBackgroundColor,
+ embedsValidationStatus,
+ }: {
+ isExporting: boolean;
+ canvasBackgroundColor: string;
+ embedsValidationStatus: EmbedsValidationStatus | null;
+ },
+): Drawable | Drawable[] | null => {
+ switch (element.type) {
+ case "rectangle":
+ case "iframe":
+ case "embeddable": {
+ let shape: ElementShapes[typeof element.type];
+ // this is for rendering the stroke/bg of the embeddable, especially
+ // when the src url is not set
+
+ if (element.roundness) {
+ const w = element.width;
+ const h = element.height;
+ const r = getCornerRadius(Math.min(w, h), element);
+ shape = generator.path(
+ `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
+ h - r
+ } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
+ h - r
+ } L 0 ${r} Q 0 0, ${r} 0`,
+ generateRoughOptions(
+ modifyIframeLikeForRoughOptions(
+ element,
+ isExporting,
+ embedsValidationStatus,
+ ),
+ true,
+ ),
+ );
+ } else {
+ shape = generator.rectangle(
+ 0,
+ 0,
+ element.width,
+ element.height,
+ generateRoughOptions(
+ modifyIframeLikeForRoughOptions(
+ element,
+ isExporting,
+ embedsValidationStatus,
+ ),
+ false,
+ ),
+ );
+ }
+ return shape;
+ }
+ case "diamond": {
+ let shape: ElementShapes[typeof element.type];
+
+ const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
+ getDiamondPoints(element);
+ if (element.roundness) {
+ const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
+
+ const horizontalRadius = getCornerRadius(
+ Math.abs(rightY - topY),
+ element,
+ );
+
+ shape = generator.path(
+ `M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
+ rightX - verticalRadius
+ } ${rightY - horizontalRadius}
+ C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
+ rightX - verticalRadius
+ } ${rightY + horizontalRadius}
+ L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
+ C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
+ bottomX - verticalRadius
+ } ${bottomY - horizontalRadius}
+ L ${leftX + verticalRadius} ${leftY + horizontalRadius}
+ C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
+ leftY - horizontalRadius
+ }
+ L ${topX - verticalRadius} ${topY + horizontalRadius}
+ C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
+ topY + horizontalRadius
+ }`,
+ generateRoughOptions(element, true),
+ );
+ } else {
+ shape = generator.polygon(
+ [
+ [topX, topY],
+ [rightX, rightY],
+ [bottomX, bottomY],
+ [leftX, leftY],
+ ],
+ generateRoughOptions(element),
+ );
+ }
+ return shape;
+ }
+ case "ellipse": {
+ const shape: ElementShapes[typeof element.type] = generator.ellipse(
+ element.width / 2,
+ element.height / 2,
+ element.width,
+ element.height,
+ generateRoughOptions(element),
+ );
+ return shape;
+ }
+ case "line":
+ case "arrow": {
+ let shape: ElementShapes[typeof element.type];
+ const options = generateRoughOptions(element);
+
+ // points array can be empty in the beginning, so it is important to add
+ // initial position to it
+ const points = element.points.length
+ ? element.points
+ : [pointFrom<LocalPoint>(0, 0)];
+
+ if (isElbowArrow(element)) {
+ // NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
+ if (
+ !points.every(
+ (point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
+ )
+ ) {
+ console.error(
+ `Elbow arrow with extreme point positions detected. Arrow not rendered.`,
+ element.id,
+ JSON.stringify(points),
+ );
+ shape = [];
+ } else {
+ shape = [
+ generator.path(
+ generateElbowArrowShape(points, 16),
+ generateRoughOptions(element, true),
+ ),
+ ];
+ }
+ } else if (!element.roundness) {
+ // curve is always the first element
+ // this simplifies finding the curve for an element
+ if (options.fill) {
+ shape = [
+ generator.polygon(points as unknown as RoughPoint[], options),
+ ];
+ } else {
+ shape = [
+ generator.linearPath(points as unknown as RoughPoint[], options),
+ ];
+ }
+ } else {
+ shape = [generator.curve(points as unknown as RoughPoint[], options)];
+ }
+
+ // add lines only in arrow
+ if (element.type === "arrow") {
+ const { startArrowhead = null, endArrowhead = "arrow" } = element;
+
+ if (startArrowhead !== null) {
+ const shapes = getArrowheadShapes(
+ element,
+ shape,
+ "start",
+ startArrowhead,
+ generator,
+ options,
+ canvasBackgroundColor,
+ );
+ shape.push(...shapes);
+ }
+
+ if (endArrowhead !== null) {
+ if (endArrowhead === undefined) {
+ // Hey, we have an old arrow here!
+ }
+
+ const shapes = getArrowheadShapes(
+ element,
+ shape,
+ "end",
+ endArrowhead,
+ generator,
+ options,
+ canvasBackgroundColor,
+ );
+ shape.push(...shapes);
+ }
+ }
+ return shape;
+ }
+ case "freedraw": {
+ let shape: ElementShapes[typeof element.type];
+ generateFreeDrawShape(element);
+
+ if (isPathALoop(element.points)) {
+ // generate rough polygon to fill freedraw shape
+ const simplifiedPoints = simplify(element.points, 0.75);
+ shape = generator.curve(simplifiedPoints as [number, number][], {
+ ...generateRoughOptions(element),
+ stroke: "none",
+ });
+ } else {
+ shape = null;
+ }
+ return shape;
+ }
+ case "frame":
+ case "magicframe":
+ case "text":
+ case "image": {
+ const shape: ElementShapes[typeof element.type] = null;
+ // we return (and cache) `null` to make sure we don't regenerate
+ // `element.canvas` on rerenders
+ return shape;
+ }
+ default: {
+ assertNever(
+ element,
+ `generateElementShape(): Unimplemented type ${(element as any)?.type}`,
+ );
+ return null;
+ }
+ }
+};
+
+const generateElbowArrowShape = (
+ points: readonly LocalPoint[],
+ radius: number,
+) => {
+ const subpoints = [] as [number, number][];
+ for (let i = 1; i < points.length - 1; i += 1) {
+ const prev = points[i - 1];
+ const next = points[i + 1];
+ const point = points[i];
+ const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
+ const nextIsHorizontal = headingForPointIsHorizontal(next, point);
+ const corner = Math.min(
+ radius,
+ pointDistance(points[i], next) / 2,
+ pointDistance(points[i], prev) / 2,
+ );
+
+ if (prevIsHorizontal) {
+ if (prev[0] < point[0]) {
+ // LEFT
+ subpoints.push([points[i][0] - corner, points[i][1]]);
+ } else {
+ // RIGHT
+ subpoints.push([points[i][0] + corner, points[i][1]]);
+ }
+ } else if (prev[1] < point[1]) {
+ // UP
+ subpoints.push([points[i][0], points[i][1] - corner]);
+ } else {
+ subpoints.push([points[i][0], points[i][1] + corner]);
+ }
+
+ subpoints.push(points[i] as [number, number]);
+
+ if (nextIsHorizontal) {
+ if (next[0] < point[0]) {
+ // LEFT
+ subpoints.push([points[i][0] - corner, points[i][1]]);
+ } else {
+ // RIGHT
+ subpoints.push([points[i][0] + corner, points[i][1]]);
+ }
+ } else if (next[1] < point[1]) {
+ // UP
+ subpoints.push([points[i][0], points[i][1] - corner]);
+ } else {
+ // DOWN
+ subpoints.push([points[i][0], points[i][1] + corner]);
+ }
+ }
+
+ const d = [`M ${points[0][0]} ${points[0][1]}`];
+ for (let i = 0; i < subpoints.length; i += 3) {
+ d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
+ d.push(
+ `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
+ subpoints[i + 2][0]
+ } ${subpoints[i + 2][1]}`,
+ );
+ }
+ d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
+
+ return d.join(" ");
+};
diff --git a/packages/excalidraw/scene/ShapeCache.ts b/packages/excalidraw/scene/ShapeCache.ts
new file mode 100644
index 0000000..39d388a
--- /dev/null
+++ b/packages/excalidraw/scene/ShapeCache.ts
@@ -0,0 +1,86 @@
+import type { Drawable } from "roughjs/bin/core";
+import { RoughGenerator } from "roughjs/bin/generator";
+import type {
+ ExcalidrawElement,
+ ExcalidrawSelectionElement,
+} from "../element/types";
+import { elementWithCanvasCache } from "../renderer/renderElement";
+import { _generateElementShape } from "./Shape";
+import type { ElementShape, ElementShapes } from "./types";
+import { COLOR_PALETTE } from "../colors";
+import type { AppState, EmbedsValidationStatus } from "../types";
+
+export class ShapeCache {
+ private static rg = new RoughGenerator();
+ private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
+
+ /**
+ * Retrieves shape from cache if available. Use this only if shape
+ * is optional and you have a fallback in case it's not cached.
+ */
+ public static get = <T extends ExcalidrawElement>(element: T) => {
+ return ShapeCache.cache.get(
+ element,
+ ) as T["type"] extends keyof ElementShapes
+ ? ElementShapes[T["type"]] | undefined
+ : ElementShape | undefined;
+ };
+
+ public static set = <T extends ExcalidrawElement>(
+ element: T,
+ shape: T["type"] extends keyof ElementShapes
+ ? ElementShapes[T["type"]]
+ : Drawable,
+ ) => ShapeCache.cache.set(element, shape);
+
+ public static delete = (element: ExcalidrawElement) =>
+ ShapeCache.cache.delete(element);
+
+ public static destroy = () => {
+ ShapeCache.cache = new WeakMap();
+ };
+
+ /**
+ * Generates & caches shape for element if not already cached, otherwise
+ * returns cached shape.
+ */
+ public static generateElementShape = <
+ T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+ >(
+ element: T,
+ renderConfig: {
+ isExporting: boolean;
+ canvasBackgroundColor: AppState["viewBackgroundColor"];
+ embedsValidationStatus: EmbedsValidationStatus;
+ } | null,
+ ) => {
+ // when exporting, always regenerated to guarantee the latest shape
+ const cachedShape = renderConfig?.isExporting
+ ? undefined
+ : ShapeCache.get(element);
+
+ // `null` indicates no rc shape applicable for this element type,
+ // but it's considered a valid cache value (= do not regenerate)
+ if (cachedShape !== undefined) {
+ return cachedShape;
+ }
+
+ elementWithCanvasCache.delete(element);
+
+ const shape = _generateElementShape(
+ element,
+ ShapeCache.rg,
+ renderConfig || {
+ isExporting: false,
+ canvasBackgroundColor: COLOR_PALETTE.white,
+ embedsValidationStatus: null,
+ },
+ ) as T["type"] extends keyof ElementShapes
+ ? ElementShapes[T["type"]]
+ : Drawable | null;
+
+ ShapeCache.cache.set(element, shape);
+
+ return shape;
+ };
+}
diff --git a/packages/excalidraw/scene/comparisons.ts b/packages/excalidraw/scene/comparisons.ts
new file mode 100644
index 0000000..9af3e66
--- /dev/null
+++ b/packages/excalidraw/scene/comparisons.ts
@@ -0,0 +1,44 @@
+import type { ElementOrToolType } from "../types";
+
+export const hasBackground = (type: ElementOrToolType) =>
+ type === "rectangle" ||
+ type === "iframe" ||
+ type === "embeddable" ||
+ type === "ellipse" ||
+ type === "diamond" ||
+ type === "line" ||
+ type === "freedraw";
+
+export const hasStrokeColor = (type: ElementOrToolType) =>
+ type !== "image" && type !== "frame" && type !== "magicframe";
+
+export const hasStrokeWidth = (type: ElementOrToolType) =>
+ type === "rectangle" ||
+ type === "iframe" ||
+ type === "embeddable" ||
+ type === "ellipse" ||
+ type === "diamond" ||
+ type === "freedraw" ||
+ type === "arrow" ||
+ type === "line";
+
+export const hasStrokeStyle = (type: ElementOrToolType) =>
+ type === "rectangle" ||
+ type === "iframe" ||
+ type === "embeddable" ||
+ type === "ellipse" ||
+ type === "diamond" ||
+ type === "arrow" ||
+ type === "line";
+
+export const canChangeRoundness = (type: ElementOrToolType) =>
+ type === "rectangle" ||
+ type === "iframe" ||
+ type === "embeddable" ||
+ type === "line" ||
+ type === "diamond" ||
+ type === "image";
+
+export const toolIsArrow = (type: ElementOrToolType) => type === "arrow";
+
+export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts
new file mode 100644
index 0000000..7b33256
--- /dev/null
+++ b/packages/excalidraw/scene/export.ts
@@ -0,0 +1,558 @@
+import rough from "roughjs/bin/rough";
+import type {
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawTextElement,
+ NonDeletedExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../element/types";
+import type { Bounds } from "../element/bounds";
+import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
+import { renderSceneToSvg } from "../renderer/staticSvgScene";
+import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
+import type { AppState, BinaryFiles } from "../types";
+import {
+ DEFAULT_EXPORT_PADDING,
+ FRAME_STYLE,
+ FONT_FAMILY,
+ SVG_NS,
+ THEME,
+ THEME_FILTER,
+ MIME_TYPES,
+ EXPORT_DATA_TYPES,
+} from "../constants";
+import { getDefaultAppState } from "../appState";
+import { serializeAsJSON } from "../data/json";
+import {
+ getInitializedImageElements,
+ updateImageCache,
+} from "../element/image";
+import {
+ getElementsOverlappingFrame,
+ getFrameLikeElements,
+ getFrameLikeTitle,
+ getRootElements,
+} from "../frame";
+import { newTextElement } from "../element";
+import { type Mutable } from "../utility-types";
+import { newElementWith } from "../element/mutateElement";
+import { isFrameLikeElement } from "../element/typeChecks";
+import type { RenderableElementsMap } from "./types";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { renderStaticScene } from "../renderer/staticScene";
+import { Fonts } from "../fonts";
+import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
+
+const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
+ if (element.width <= maxWidth) {
+ return element;
+ }
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d")!;
+ ctx.font = getFontString({
+ fontFamily: element.fontFamily,
+ fontSize: element.fontSize,
+ });
+
+ let text = element.text;
+
+ const metrics = ctx.measureText(text);
+
+ if (metrics.width > maxWidth) {
+ // we iterate from the right, removing characters one by one instead
+ // of bulding the string up. This assumes that it's more likely
+ // your frame names will overflow by not that many characters
+ // (if ever), so it sohuld be faster this way.
+ for (let i = text.length; i > 0; i--) {
+ const newText = `${text.slice(0, i)}...`;
+ if (ctx.measureText(newText).width <= maxWidth) {
+ text = newText;
+ break;
+ }
+ }
+ }
+ return newElementWith(element, { text, width: maxWidth });
+};
+
+/**
+ * When exporting frames, we need to render frame labels which are currently
+ * being rendered in DOM when editing. Adding the labels as regular text
+ * elements seems like a simple hack. In the future we'll want to move to
+ * proper canvas rendering, even within editor (instead of DOM).
+ */
+const addFrameLabelsAsTextElements = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ opts: Pick<AppState, "exportWithDarkMode">,
+) => {
+ const nextElements: NonDeletedExcalidrawElement[] = [];
+ for (const element of elements) {
+ if (isFrameLikeElement(element)) {
+ let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
+ x: element.x,
+ y: element.y - FRAME_STYLE.nameOffsetY,
+ fontFamily: FONT_FAMILY.Helvetica,
+ fontSize: FRAME_STYLE.nameFontSize,
+ lineHeight:
+ FRAME_STYLE.nameLineHeight as ExcalidrawTextElement["lineHeight"],
+ strokeColor: opts.exportWithDarkMode
+ ? FRAME_STYLE.nameColorDarkTheme
+ : FRAME_STYLE.nameColorLightTheme,
+ text: getFrameLikeTitle(element),
+ });
+ textElement.y -= textElement.height;
+
+ textElement = truncateText(textElement, element.width);
+
+ nextElements.push(textElement);
+ }
+ nextElements.push(element);
+ }
+
+ return nextElements;
+};
+
+const getFrameRenderingConfig = (
+ exportingFrame: ExcalidrawFrameLikeElement | null,
+ frameRendering: AppState["frameRendering"] | null,
+): AppState["frameRendering"] => {
+ frameRendering = frameRendering || getDefaultAppState().frameRendering;
+ return {
+ enabled: exportingFrame ? true : frameRendering.enabled,
+ outline: exportingFrame ? false : frameRendering.outline,
+ name: exportingFrame ? false : frameRendering.name,
+ clip: exportingFrame ? true : frameRendering.clip,
+ };
+};
+
+const prepareElementsForRender = ({
+ elements,
+ exportingFrame,
+ frameRendering,
+ exportWithDarkMode,
+}: {
+ elements: readonly ExcalidrawElement[];
+ exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
+ frameRendering: AppState["frameRendering"];
+ exportWithDarkMode: AppState["exportWithDarkMode"];
+}) => {
+ let nextElements: readonly ExcalidrawElement[];
+
+ if (exportingFrame) {
+ nextElements = getElementsOverlappingFrame(elements, exportingFrame);
+ } else if (frameRendering.enabled && frameRendering.name) {
+ nextElements = addFrameLabelsAsTextElements(elements, {
+ exportWithDarkMode,
+ });
+ } else {
+ nextElements = elements;
+ }
+
+ return nextElements;
+};
+
+export const exportToCanvas = async (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ {
+ exportBackground,
+ exportPadding = DEFAULT_EXPORT_PADDING,
+ viewBackgroundColor,
+ exportingFrame,
+ }: {
+ exportBackground: boolean;
+ exportPadding?: number;
+ viewBackgroundColor: string;
+ exportingFrame?: ExcalidrawFrameLikeElement | null;
+ },
+ createCanvas: (
+ width: number,
+ height: number,
+ ) => { canvas: HTMLCanvasElement; scale: number } = (width, height) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = width * appState.exportScale;
+ canvas.height = height * appState.exportScale;
+ return { canvas, scale: appState.exportScale };
+ },
+ loadFonts: () => Promise<void> = async () => {
+ await Fonts.loadElementsFonts(elements);
+ },
+) => {
+ // load font faces before continuing, by default leverages browsers' [FontFace API](https://developer.mozilla.org/en-US/docs/Web/API/FontFace)
+ await loadFonts();
+
+ const frameRendering = getFrameRenderingConfig(
+ exportingFrame ?? null,
+ appState.frameRendering ?? null,
+ );
+ // for canvas export, don't clip if exporting a specific frame as it would
+ // clip the corners of the content
+ if (exportingFrame) {
+ frameRendering.clip = false;
+ }
+
+ const elementsForRender = prepareElementsForRender({
+ elements,
+ exportingFrame,
+ exportWithDarkMode: appState.exportWithDarkMode,
+ frameRendering,
+ });
+
+ if (exportingFrame) {
+ exportPadding = 0;
+ }
+
+ const [minX, minY, width, height] = getCanvasSize(
+ exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
+ exportPadding,
+ );
+
+ const { canvas, scale = 1 } = createCanvas(width, height);
+
+ const defaultAppState = getDefaultAppState();
+
+ const { imageCache } = await updateImageCache({
+ imageCache: new Map(),
+ fileIds: getInitializedImageElements(elementsForRender).map(
+ (element) => element.fileId,
+ ),
+ files,
+ });
+
+ renderStaticScene({
+ canvas,
+ rc: rough.canvas(canvas),
+ elementsMap: toBrandedType<RenderableElementsMap>(
+ arrayToMap(elementsForRender),
+ ),
+ allElementsMap: toBrandedType<NonDeletedSceneElementsMap>(
+ arrayToMap(syncInvalidIndices(elements)),
+ ),
+ visibleElements: elementsForRender,
+ scale,
+ appState: {
+ ...appState,
+ frameRendering,
+ viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+ scrollX: -minX + exportPadding,
+ scrollY: -minY + exportPadding,
+ zoom: defaultAppState.zoom,
+ shouldCacheIgnoreZoom: false,
+ theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
+ },
+ renderConfig: {
+ canvasBackgroundColor: viewBackgroundColor,
+ imageCache,
+ renderGrid: false,
+ isExporting: true,
+ // empty disables embeddable rendering
+ embedsValidationStatus: new Map(),
+ elementsPendingErasure: new Set(),
+ pendingFlowchartNodes: null,
+ },
+ });
+
+ return canvas;
+};
+
+const createHTMLComment = (text: string) => {
+ // surrounding with spaces to maintain prettified consistency with previous
+ // iterations
+ // <!-- comment -->
+ return document.createComment(` ${text} `);
+};
+
+export const exportToSvg = async (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: {
+ exportBackground: boolean;
+ exportPadding?: number;
+ exportScale?: number;
+ viewBackgroundColor: string;
+ exportWithDarkMode?: boolean;
+ exportEmbedScene?: boolean;
+ frameRendering?: AppState["frameRendering"];
+ },
+ files: BinaryFiles | null,
+ opts?: {
+ /**
+ * if true, all embeddables passed in will be rendered when possible.
+ */
+ renderEmbeddables?: boolean;
+ exportingFrame?: ExcalidrawFrameLikeElement | null;
+ skipInliningFonts?: true;
+ reuseImages?: boolean;
+ },
+): Promise<SVGSVGElement> => {
+ const frameRendering = getFrameRenderingConfig(
+ opts?.exportingFrame ?? null,
+ appState.frameRendering ?? null,
+ );
+
+ let {
+ exportPadding = DEFAULT_EXPORT_PADDING,
+ exportWithDarkMode = false,
+ viewBackgroundColor,
+ exportScale = 1,
+ exportEmbedScene,
+ } = appState;
+
+ const { exportingFrame = null } = opts || {};
+
+ const elementsForRender = prepareElementsForRender({
+ elements,
+ exportingFrame,
+ exportWithDarkMode,
+ frameRendering,
+ });
+
+ if (exportingFrame) {
+ exportPadding = 0;
+ }
+
+ const [minX, minY, width, height] = getCanvasSize(
+ exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
+ exportPadding,
+ );
+
+ const offsetX = -minX + exportPadding;
+ const offsetY = -minY + exportPadding;
+
+ // ---------------------------------------------------------------------------
+ // initialize SVG root element
+ // ---------------------------------------------------------------------------
+
+ const svgRoot = document.createElementNS(SVG_NS, "svg");
+
+ svgRoot.setAttribute("version", "1.1");
+ svgRoot.setAttribute("xmlns", SVG_NS);
+ svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svgRoot.setAttribute("width", `${width * exportScale}`);
+ svgRoot.setAttribute("height", `${height * exportScale}`);
+ if (exportWithDarkMode) {
+ svgRoot.setAttribute("filter", THEME_FILTER);
+ }
+
+ const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
+
+ const metadataElement = svgRoot.ownerDocument.createElementNS(
+ SVG_NS,
+ "metadata",
+ );
+
+ svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
+ svgRoot.appendChild(metadataElement);
+ svgRoot.appendChild(defsElement);
+
+ // ---------------------------------------------------------------------------
+ // scene embed
+ // ---------------------------------------------------------------------------
+
+ // we need to serialize the "original" elements before we put them through
+ // the tempScene hack which duplicates and regenerates ids
+ if (exportEmbedScene) {
+ try {
+ encodeSvgBase64Payload({
+ metadataElement,
+ // when embedding scene, we want to embed the origionally supplied
+ // elements which don't contain the temp frame labels.
+ // But it also requires that the exportToSvg is being supplied with
+ // only the elements that we're exporting, and no extra.
+ payload: serializeAsJSON(elements, appState, files || {}, "local"),
+ });
+ } catch (error: any) {
+ console.error(error);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // frame clip paths
+ // ---------------------------------------------------------------------------
+
+ const frameElements = getFrameLikeElements(elements);
+
+ if (frameElements.length) {
+ const elementsMap = arrayToMap(elements);
+
+ for (const frame of frameElements) {
+ const clipPath = svgRoot.ownerDocument.createElementNS(
+ SVG_NS,
+ "clipPath",
+ );
+
+ clipPath.setAttribute("id", frame.id);
+
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
+ const cx = (x2 - x1) / 2 - (frame.x - x1);
+ const cy = (y2 - y1) / 2 - (frame.y - y1);
+
+ const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
+ rect.setAttribute(
+ "transform",
+ `translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
+ frame.angle
+ } ${cx} ${cy})`,
+ );
+ rect.setAttribute("width", `${frame.width}`);
+ rect.setAttribute("height", `${frame.height}`);
+
+ if (!exportingFrame) {
+ rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
+ rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
+ }
+
+ clipPath.appendChild(rect);
+
+ defsElement.appendChild(clipPath);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // inline font faces
+ // ---------------------------------------------------------------------------
+
+ const fontFaces = !opts?.skipInliningFonts
+ ? await Fonts.generateFontFaceDeclarations(elements)
+ : [];
+
+ const delimiter = "\n "; // 6 spaces
+
+ const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
+ style.classList.add("style-fonts");
+ style.appendChild(
+ document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
+ );
+
+ defsElement.appendChild(style);
+
+ // ---------------------------------------------------------------------------
+ // background
+ // ---------------------------------------------------------------------------
+
+ // render background rect
+ if (appState.exportBackground && viewBackgroundColor) {
+ const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
+ rect.setAttribute("x", "0");
+ rect.setAttribute("y", "0");
+ rect.setAttribute("width", `${width}`);
+ rect.setAttribute("height", `${height}`);
+ rect.setAttribute("fill", viewBackgroundColor);
+ svgRoot.appendChild(rect);
+ }
+
+ // ---------------------------------------------------------------------------
+ // render elements
+ // ---------------------------------------------------------------------------
+
+ const rsvg = rough.svg(svgRoot);
+
+ const renderEmbeddables = opts?.renderEmbeddables ?? false;
+
+ 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(),
+ reuseImages: opts?.reuseImages ?? true,
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+
+ return svgRoot;
+};
+
+export const encodeSvgBase64Payload = ({
+ payload,
+ metadataElement,
+}: {
+ payload: string;
+ metadataElement: SVGMetadataElement;
+}) => {
+ const base64 = stringToBase64(
+ JSON.stringify(encode({ text: payload })),
+ true /* is already byte string */,
+ );
+
+ metadataElement.appendChild(
+ createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
+ );
+ metadataElement.appendChild(createHTMLComment("payload-version:2"));
+ metadataElement.appendChild(createHTMLComment("payload-start"));
+ metadataElement.appendChild(document.createTextNode(base64));
+ metadataElement.appendChild(createHTMLComment("payload-end"));
+};
+
+export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
+ if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
+ const match = svg.match(
+ /<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
+ );
+ if (!match) {
+ throw new Error("INVALID");
+ }
+ const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
+ const version = versionMatch?.[1] || "1";
+ const isByteString = version !== "1";
+
+ try {
+ const json = base64ToString(match[1], isByteString);
+ const encodedData = JSON.parse(json);
+ if (!("encoded" in encodedData)) {
+ // legacy, un-encoded scene JSON
+ if (
+ "type" in encodedData &&
+ encodedData.type === EXPORT_DATA_TYPES.excalidraw
+ ) {
+ return json;
+ }
+ throw new Error("FAILED");
+ }
+ return decode(encodedData);
+ } catch (error: any) {
+ console.error(error);
+ throw new Error("FAILED");
+ }
+ }
+ throw new Error("INVALID");
+};
+
+// calculate smallest area to fit the contents in
+const getCanvasSize = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ exportPadding: number,
+): Bounds => {
+ const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+ const width = distance(minX, maxX) + exportPadding * 2;
+ const height = distance(minY, maxY) + exportPadding * 2;
+
+ return [minX, minY, width, height];
+};
+
+export const getExportSize = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ exportPadding: number,
+ scale: number,
+): [number, number] => {
+ const [, , width, height] = getCanvasSize(elements, exportPadding).map(
+ (dimension) => Math.trunc(dimension * scale),
+ );
+
+ return [width, height];
+};
diff --git a/packages/excalidraw/scene/index.ts b/packages/excalidraw/scene/index.ts
new file mode 100644
index 0000000..1c0b795
--- /dev/null
+++ b/packages/excalidraw/scene/index.ts
@@ -0,0 +1,20 @@
+export {
+ isSomeElementSelected,
+ getElementsWithinSelection,
+ getCommonAttributeOfSelectedElements,
+ getSelectedElements,
+ getTargetElements,
+} from "./selection";
+export { calculateScrollCenter } from "./scroll";
+export {
+ hasBackground,
+ hasStrokeWidth,
+ hasStrokeStyle,
+ canHaveArrowheads,
+ canChangeRoundness,
+} from "./comparisons";
+export {
+ getNormalizedZoom,
+ getNormalizedGridSize,
+ getNormalizedGridStep,
+} from "./normalize";
diff --git a/packages/excalidraw/scene/normalize.ts b/packages/excalidraw/scene/normalize.ts
new file mode 100644
index 0000000..2a025fc
--- /dev/null
+++ b/packages/excalidraw/scene/normalize.ts
@@ -0,0 +1,15 @@
+import { clamp, round } from "@excalidraw/math";
+import { MAX_ZOOM, MIN_ZOOM } from "../constants";
+import type { NormalizedZoomValue } from "../types";
+
+export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {
+ return clamp(round(zoom, 6), MIN_ZOOM, MAX_ZOOM) as NormalizedZoomValue;
+};
+
+export const getNormalizedGridSize = (gridStep: number) => {
+ return clamp(Math.round(gridStep), 1, 100);
+};
+
+export const getNormalizedGridStep = (gridStep: number) => {
+ return clamp(Math.round(gridStep), 1, 100);
+};
diff --git a/packages/excalidraw/scene/scroll.ts b/packages/excalidraw/scene/scroll.ts
new file mode 100644
index 0000000..5d059e5
--- /dev/null
+++ b/packages/excalidraw/scene/scroll.ts
@@ -0,0 +1,91 @@
+import type { AppState, Offsets, PointerCoords, Zoom } from "../types";
+import type { ExcalidrawElement } from "../element/types";
+import {
+ getCommonBounds,
+ getClosestElementBounds,
+ getVisibleElements,
+} from "../element";
+
+import {
+ sceneCoordsToViewportCoords,
+ viewportCoordsToSceneCoords,
+} from "../utils";
+
+const isOutsideViewPort = (appState: AppState, cords: Array<number>) => {
+ const [x1, y1, x2, y2] = cords;
+ const { x: viewportX1, y: viewportY1 } = sceneCoordsToViewportCoords(
+ { sceneX: x1, sceneY: y1 },
+ appState,
+ );
+ const { x: viewportX2, y: viewportY2 } = sceneCoordsToViewportCoords(
+ { sceneX: x2, sceneY: y2 },
+ appState,
+ );
+ return (
+ viewportX2 - viewportX1 > appState.width ||
+ viewportY2 - viewportY1 > appState.height
+ );
+};
+
+export const centerScrollOn = ({
+ scenePoint,
+ viewportDimensions,
+ zoom,
+ offsets,
+}: {
+ scenePoint: PointerCoords;
+ viewportDimensions: { height: number; width: number };
+ zoom: Zoom;
+ offsets?: Offsets;
+}) => {
+ let scrollX =
+ (viewportDimensions.width - (offsets?.right ?? 0)) / 2 / zoom.value -
+ scenePoint.x;
+
+ scrollX += (offsets?.left ?? 0) / 2 / zoom.value;
+
+ let scrollY =
+ (viewportDimensions.height - (offsets?.bottom ?? 0)) / 2 / zoom.value -
+ scenePoint.y;
+
+ scrollY += (offsets?.top ?? 0) / 2 / zoom.value;
+
+ return {
+ scrollX,
+ scrollY,
+ };
+};
+
+export const calculateScrollCenter = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+): { scrollX: number; scrollY: number } => {
+ elements = getVisibleElements(elements);
+
+ if (!elements.length) {
+ return {
+ scrollX: 0,
+ scrollY: 0,
+ };
+ }
+ let [x1, y1, x2, y2] = getCommonBounds(elements);
+
+ if (isOutsideViewPort(appState, [x1, y1, x2, y2])) {
+ [x1, y1, x2, y2] = getClosestElementBounds(
+ elements,
+ viewportCoordsToSceneCoords(
+ { clientX: appState.scrollX, clientY: appState.scrollY },
+ appState,
+ ),
+ );
+ }
+
+ const centerX = (x1 + x2) / 2;
+ const centerY = (y1 + y2) / 2;
+
+ return centerScrollOn({
+ scenePoint: { x: centerX, y: centerY },
+ viewportDimensions: { width: appState.width, height: appState.height },
+ zoom: appState.zoom,
+ });
+};
diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts
new file mode 100644
index 0000000..70f7033
--- /dev/null
+++ b/packages/excalidraw/scene/scrollbars.ts
@@ -0,0 +1,124 @@
+import { getCommonBounds } from "../element";
+import type { InteractiveCanvasAppState } from "../types";
+import type { ScrollBars } from "./types";
+import { getGlobalCSSVariable } from "../utils";
+import { getLanguage } from "../i18n";
+import type { ExcalidrawElement } from "../element/types";
+
+export const SCROLLBAR_MARGIN = 4;
+export const SCROLLBAR_WIDTH = 6;
+export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
+
+export const getScrollBars = (
+ elements: readonly ExcalidrawElement[],
+ viewportWidth: number,
+ viewportHeight: number,
+ appState: InteractiveCanvasAppState,
+): ScrollBars => {
+ if (!elements.length) {
+ return {
+ horizontal: null,
+ vertical: null,
+ };
+ }
+ // This is the bounding box of all the elements
+ const [elementsMinX, elementsMinY, elementsMaxX, elementsMaxY] =
+ getCommonBounds(elements);
+
+ // Apply zoom
+ const viewportWidthWithZoom = viewportWidth / appState.zoom.value;
+ const viewportHeightWithZoom = viewportHeight / appState.zoom.value;
+
+ const viewportWidthDiff = viewportWidth - viewportWidthWithZoom;
+ const viewportHeightDiff = viewportHeight - viewportHeightWithZoom;
+
+ const safeArea = {
+ top: parseInt(getGlobalCSSVariable("sat")) || 0,
+ bottom: parseInt(getGlobalCSSVariable("sab")) || 0,
+ left: parseInt(getGlobalCSSVariable("sal")) || 0,
+ right: parseInt(getGlobalCSSVariable("sar")) || 0,
+ };
+
+ const isRTL = getLanguage().rtl;
+
+ // The viewport is the rectangle currently visible for the user
+ const viewportMinX =
+ -appState.scrollX + viewportWidthDiff / 2 + safeArea.left;
+ const viewportMinY =
+ -appState.scrollY + viewportHeightDiff / 2 + safeArea.top;
+ const viewportMaxX = viewportMinX + viewportWidthWithZoom - safeArea.right;
+ const viewportMaxY = viewportMinY + viewportHeightWithZoom - safeArea.bottom;
+
+ // The scene is the bounding box of both the elements and viewport
+ const sceneMinX = Math.min(elementsMinX, viewportMinX);
+ const sceneMinY = Math.min(elementsMinY, viewportMinY);
+ const sceneMaxX = Math.max(elementsMaxX, viewportMaxX);
+ const sceneMaxY = Math.max(elementsMaxY, viewportMaxY);
+
+ // The scrollbar represents where the viewport is in relationship to the scene
+
+ return {
+ horizontal:
+ viewportMinX === sceneMinX && viewportMaxX === sceneMaxX
+ ? null
+ : {
+ x:
+ Math.max(safeArea.left, SCROLLBAR_MARGIN) +
+ ((viewportMinX - sceneMinX) / (sceneMaxX - sceneMinX)) *
+ viewportWidth,
+ y:
+ viewportHeight -
+ SCROLLBAR_WIDTH -
+ Math.max(SCROLLBAR_MARGIN, safeArea.bottom),
+ width:
+ ((viewportMaxX - viewportMinX) / (sceneMaxX - sceneMinX)) *
+ viewportWidth -
+ Math.max(SCROLLBAR_MARGIN * 2, safeArea.left + safeArea.right),
+ height: SCROLLBAR_WIDTH,
+ },
+ vertical:
+ viewportMinY === sceneMinY && viewportMaxY === sceneMaxY
+ ? null
+ : {
+ x: isRTL
+ ? Math.max(safeArea.left, SCROLLBAR_MARGIN)
+ : viewportWidth -
+ SCROLLBAR_WIDTH -
+ Math.max(safeArea.right, SCROLLBAR_MARGIN),
+ y:
+ ((viewportMinY - sceneMinY) / (sceneMaxY - sceneMinY)) *
+ viewportHeight +
+ Math.max(safeArea.top, SCROLLBAR_MARGIN),
+ width: SCROLLBAR_WIDTH,
+ height:
+ ((viewportMaxY - viewportMinY) / (sceneMaxY - sceneMinY)) *
+ viewportHeight -
+ Math.max(SCROLLBAR_MARGIN * 2, safeArea.top + safeArea.bottom),
+ },
+ };
+};
+
+export const isOverScrollBars = (
+ scrollBars: ScrollBars,
+ x: number,
+ y: number,
+): {
+ isOverEither: boolean;
+ isOverHorizontal: boolean;
+ isOverVertical: boolean;
+} => {
+ const [isOverHorizontal, isOverVertical] = [
+ scrollBars.horizontal,
+ scrollBars.vertical,
+ ].map((scrollBar) => {
+ return (
+ scrollBar != null &&
+ scrollBar.x <= x &&
+ x <= scrollBar.x + scrollBar.width &&
+ scrollBar.y <= y &&
+ y <= scrollBar.y + scrollBar.height
+ );
+ });
+ const isOverEither = isOverHorizontal || isOverVertical;
+ return { isOverEither, isOverHorizontal, isOverVertical };
+};
diff --git a/packages/excalidraw/scene/selection.test.ts b/packages/excalidraw/scene/selection.test.ts
new file mode 100644
index 0000000..644d212
--- /dev/null
+++ b/packages/excalidraw/scene/selection.test.ts
@@ -0,0 +1,35 @@
+import { makeNextSelectedElementIds } from "./selection";
+
+describe("makeNextSelectedElementIds", () => {
+ const _makeNextSelectedElementIds = (
+ selectedElementIds: { [id: string]: true },
+ prevSelectedElementIds: { [id: string]: true },
+ expectUpdated: boolean,
+ ) => {
+ const ret = makeNextSelectedElementIds(selectedElementIds, {
+ selectedElementIds: prevSelectedElementIds,
+ });
+ expect(ret === selectedElementIds).toBe(expectUpdated);
+ };
+ it("should return prevState selectedElementIds if no change", () => {
+ _makeNextSelectedElementIds({}, {}, false);
+ _makeNextSelectedElementIds({ 1: true }, { 1: true }, false);
+ _makeNextSelectedElementIds(
+ { 1: true, 2: true },
+ { 1: true, 2: true },
+ false,
+ );
+ });
+ it("should return new selectedElementIds if changed", () => {
+ // _makeNextSelectedElementIds({ 1: true }, { 1: false }, true);
+ _makeNextSelectedElementIds({ 1: true }, {}, true);
+ _makeNextSelectedElementIds({}, { 1: true }, true);
+ _makeNextSelectedElementIds({ 1: true }, { 2: true }, true);
+ _makeNextSelectedElementIds({ 1: true }, { 1: true, 2: true }, true);
+ _makeNextSelectedElementIds(
+ { 1: true, 2: true },
+ { 1: true, 3: true },
+ true,
+ );
+ });
+});
diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts
new file mode 100644
index 0000000..3ca91cd
--- /dev/null
+++ b/packages/excalidraw/scene/selection.ts
@@ -0,0 +1,250 @@
+import type {
+ ElementsMap,
+ ElementsMapOrArray,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+} from "../element/types";
+import { getElementAbsoluteCoords, getElementBounds } from "../element";
+import type { AppState, InteractiveCanvasAppState } from "../types";
+import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
+import {
+ elementOverlapsWithFrame,
+ getContainingFrame,
+ getFrameChildren,
+} from "../frame";
+import { isShallowEqual } from "../utils";
+import { isElementInViewport } from "../element/sizeHelpers";
+
+/**
+ * Frames and their containing elements are not to be selected at the same time.
+ * Given an array of selected elements, if there are frames and their containing elements
+ * we only keep the frames.
+ * @param selectedElements
+ */
+export const excludeElementsInFramesFromSelection = <
+ T extends ExcalidrawElement,
+>(
+ selectedElements: readonly T[],
+) => {
+ const framesInSelection = new Set<T["id"]>();
+
+ selectedElements.forEach((element) => {
+ if (isFrameLikeElement(element)) {
+ framesInSelection.add(element.id);
+ }
+ });
+
+ return selectedElements.filter((element) => {
+ if (element.frameId && framesInSelection.has(element.frameId)) {
+ return false;
+ }
+ return true;
+ });
+};
+
+export const getElementsWithinSelection = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ selection: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ excludeElementsInFrames: boolean = true,
+) => {
+ const [selectionX1, selectionY1, selectionX2, selectionY2] =
+ getElementAbsoluteCoords(selection, elementsMap);
+
+ let elementsInSelection = elements.filter((element) => {
+ let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
+ element,
+ elementsMap,
+ );
+
+ const containingFrame = getContainingFrame(element, elementsMap);
+ if (containingFrame) {
+ const [fx1, fy1, fx2, fy2] = getElementBounds(
+ containingFrame,
+ elementsMap,
+ );
+
+ elementX1 = Math.max(fx1, elementX1);
+ elementY1 = Math.max(fy1, elementY1);
+ elementX2 = Math.min(fx2, elementX2);
+ elementY2 = Math.min(fy2, elementY2);
+ }
+
+ return (
+ element.locked === false &&
+ element.type !== "selection" &&
+ !isBoundToContainer(element) &&
+ selectionX1 <= elementX1 &&
+ selectionY1 <= elementY1 &&
+ selectionX2 >= elementX2 &&
+ selectionY2 >= elementY2
+ );
+ });
+
+ elementsInSelection = excludeElementsInFrames
+ ? excludeElementsInFramesFromSelection(elementsInSelection)
+ : elementsInSelection;
+
+ elementsInSelection = elementsInSelection.filter((element) => {
+ const containingFrame = getContainingFrame(element, elementsMap);
+
+ if (containingFrame) {
+ return elementOverlapsWithFrame(element, containingFrame, elementsMap);
+ }
+
+ return true;
+ });
+
+ return elementsInSelection;
+};
+
+export const getVisibleAndNonSelectedElements = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ selectedElements: readonly NonDeletedExcalidrawElement[],
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const selectedElementsSet = new Set(
+ selectedElements.map((element) => element.id),
+ );
+ return elements.filter((element) => {
+ const isVisible = isElementInViewport(
+ element,
+ appState.width,
+ appState.height,
+ appState,
+ elementsMap,
+ );
+
+ return !selectedElementsSet.has(element.id) && isVisible;
+ });
+};
+
+// FIXME move this into the editor instance to keep utility methods stateless
+export const isSomeElementSelected = (function () {
+ let lastElements: readonly NonDeletedExcalidrawElement[] | null = null;
+ let lastSelectedElementIds: AppState["selectedElementIds"] | null = null;
+ let isSelected: boolean | null = null;
+
+ const ret = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: Pick<AppState, "selectedElementIds">,
+ ): boolean => {
+ if (
+ isSelected != null &&
+ elements === lastElements &&
+ appState.selectedElementIds === lastSelectedElementIds
+ ) {
+ return isSelected;
+ }
+
+ isSelected = elements.some(
+ (element) => appState.selectedElementIds[element.id],
+ );
+ lastElements = elements;
+ lastSelectedElementIds = appState.selectedElementIds;
+
+ return isSelected;
+ };
+
+ ret.clearCache = () => {
+ lastElements = null;
+ lastSelectedElementIds = null;
+ isSelected = null;
+ };
+
+ return ret;
+})();
+
+/**
+ * Returns common attribute (picked by `getAttribute` callback) of selected
+ * elements. If elements don't share the same value, returns `null`.
+ */
+export const getCommonAttributeOfSelectedElements = <T>(
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: Pick<AppState, "selectedElementIds">,
+ getAttribute: (element: ExcalidrawElement) => T,
+): T | null => {
+ const attributes = Array.from(
+ new Set(
+ getSelectedElements(elements, appState).map((element) =>
+ getAttribute(element),
+ ),
+ ),
+ );
+ return attributes.length === 1 ? attributes[0] : null;
+};
+
+export const getSelectedElements = (
+ elements: ElementsMapOrArray,
+ appState: Pick<InteractiveCanvasAppState, "selectedElementIds">,
+ opts?: {
+ includeBoundTextElement?: boolean;
+ includeElementsInFrames?: boolean;
+ },
+) => {
+ const addedElements = new Set<ExcalidrawElement["id"]>();
+ const selectedElements: ExcalidrawElement[] = [];
+ for (const element of elements.values()) {
+ if (appState.selectedElementIds[element.id]) {
+ selectedElements.push(element);
+ addedElements.add(element.id);
+ continue;
+ }
+ if (
+ opts?.includeBoundTextElement &&
+ isBoundToContainer(element) &&
+ appState.selectedElementIds[element?.containerId]
+ ) {
+ selectedElements.push(element);
+ addedElements.add(element.id);
+ continue;
+ }
+ }
+
+ if (opts?.includeElementsInFrames) {
+ const elementsToInclude: ExcalidrawElement[] = [];
+ selectedElements.forEach((element) => {
+ if (isFrameLikeElement(element)) {
+ getFrameChildren(elements, element.id).forEach(
+ (e) => !addedElements.has(e.id) && elementsToInclude.push(e),
+ );
+ }
+ elementsToInclude.push(element);
+ });
+
+ return elementsToInclude;
+ }
+
+ return selectedElements;
+};
+
+export const getTargetElements = (
+ elements: ElementsMapOrArray,
+ appState: Pick<
+ AppState,
+ "selectedElementIds" | "editingTextElement" | "newElement"
+ >,
+) =>
+ appState.editingTextElement
+ ? [appState.editingTextElement]
+ : appState.newElement
+ ? [appState.newElement]
+ : getSelectedElements(elements, appState, {
+ includeBoundTextElement: true,
+ });
+
+/**
+ * returns prevState's selectedElementids if no change from previous, so as to
+ * retain reference identity for memoization
+ */
+export const makeNextSelectedElementIds = (
+ nextSelectedElementIds: AppState["selectedElementIds"],
+ prevState: Pick<AppState, "selectedElementIds">,
+) => {
+ if (isShallowEqual(prevState.selectedElementIds, nextSelectedElementIds)) {
+ return prevState.selectedElementIds;
+ }
+
+ return nextSelectedElementIds;
+};
diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts
new file mode 100644
index 0000000..c0bfd1b
--- /dev/null
+++ b/packages/excalidraw/scene/types.ts
@@ -0,0 +1,155 @@
+import type { RoughCanvas } from "roughjs/bin/canvas";
+import type { Drawable } from "roughjs/bin/core";
+import type {
+ ExcalidrawElement,
+ NonDeletedElementsMap,
+ NonDeletedExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../element/types";
+import type {
+ AppClassProperties,
+ AppState,
+ EmbedsValidationStatus,
+ ElementsPendingErasure,
+ InteractiveCanvasAppState,
+ StaticCanvasAppState,
+ SocketId,
+ Device,
+ PendingExcalidrawElements,
+} from "../types";
+import type { MakeBrand } from "../utility-types";
+import type { UserIdleState } from "../constants";
+
+export type RenderableElementsMap = NonDeletedElementsMap &
+ MakeBrand<"RenderableElementsMap">;
+
+export type StaticCanvasRenderConfig = {
+ canvasBackgroundColor: AppState["viewBackgroundColor"];
+ // extra options passed to the renderer
+ // ---------------------------------------------------------------------------
+ imageCache: AppClassProperties["imageCache"];
+ renderGrid: boolean;
+ /** when exporting the behavior is slightly different (e.g. we can't use
+ CSS filters), and we disable render optimizations for best output */
+ isExporting: boolean;
+ embedsValidationStatus: EmbedsValidationStatus;
+ elementsPendingErasure: ElementsPendingErasure;
+ pendingFlowchartNodes: PendingExcalidrawElements | null;
+};
+
+export type SVGRenderConfig = {
+ offsetX: number;
+ offsetY: number;
+ isExporting: boolean;
+ exportWithDarkMode: boolean;
+ renderEmbeddables: boolean;
+ frameRendering: AppState["frameRendering"];
+ canvasBackgroundColor: AppState["viewBackgroundColor"];
+ embedsValidationStatus: EmbedsValidationStatus;
+ /**
+ * whether to attempt to reuse images as much as possible through symbols
+ * (reduces SVG size, but may be incompoatible with some SVG renderers)
+ *
+ * @default true
+ */
+ reuseImages: boolean;
+};
+
+export type InteractiveCanvasRenderConfig = {
+ // collab-related state
+ // ---------------------------------------------------------------------------
+ remoteSelectedElementIds: Map<ExcalidrawElement["id"], SocketId[]>;
+ remotePointerViewportCoords: Map<SocketId, { x: number; y: number }>;
+ remotePointerUserStates: Map<SocketId, UserIdleState>;
+ remotePointerUsernames: Map<SocketId, string>;
+ remotePointerButton: Map<SocketId, string | undefined>;
+ selectionColor: string;
+ // extra options passed to the renderer
+ // ---------------------------------------------------------------------------
+ renderScrollbars?: boolean;
+};
+
+export type RenderInteractiveSceneCallback = {
+ atLeastOneVisibleElement: boolean;
+ elementsMap: RenderableElementsMap;
+ scrollBars?: ScrollBars;
+};
+
+export type StaticSceneRenderConfig = {
+ canvas: HTMLCanvasElement;
+ rc: RoughCanvas;
+ elementsMap: RenderableElementsMap;
+ allElementsMap: NonDeletedSceneElementsMap;
+ visibleElements: readonly NonDeletedExcalidrawElement[];
+ scale: number;
+ appState: StaticCanvasAppState;
+ renderConfig: StaticCanvasRenderConfig;
+};
+
+export type InteractiveSceneRenderConfig = {
+ canvas: HTMLCanvasElement | null;
+ elementsMap: RenderableElementsMap;
+ visibleElements: readonly NonDeletedExcalidrawElement[];
+ selectedElements: readonly NonDeletedExcalidrawElement[];
+ allElementsMap: NonDeletedSceneElementsMap;
+ scale: number;
+ appState: InteractiveCanvasAppState;
+ renderConfig: InteractiveCanvasRenderConfig;
+ device: Device;
+ callback: (data: RenderInteractiveSceneCallback) => void;
+};
+
+export type NewElementSceneRenderConfig = {
+ canvas: HTMLCanvasElement | null;
+ rc: RoughCanvas;
+ newElement: ExcalidrawElement | null;
+ elementsMap: RenderableElementsMap;
+ allElementsMap: NonDeletedSceneElementsMap;
+ scale: number;
+ appState: AppState;
+ renderConfig: StaticCanvasRenderConfig;
+};
+
+export type SceneScroll = {
+ scrollX: number;
+ scrollY: number;
+};
+
+export type ExportType =
+ | "png"
+ | "clipboard"
+ | "clipboard-svg"
+ | "backend"
+ | "svg";
+
+export type ScrollBars = {
+ horizontal: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ } | null;
+ vertical: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ } | null;
+};
+
+export type ElementShape = Drawable | Drawable[] | null;
+
+export type ElementShapes = {
+ rectangle: Drawable;
+ ellipse: Drawable;
+ diamond: Drawable;
+ iframe: Drawable;
+ embeddable: Drawable;
+ freedraw: Drawable | null;
+ arrow: Drawable[];
+ line: Drawable[];
+ text: null;
+ image: null;
+ frame: null;
+ magicframe: null;
+};
diff --git a/packages/excalidraw/scene/zoom.ts b/packages/excalidraw/scene/zoom.ts
new file mode 100644
index 0000000..5decf11
--- /dev/null
+++ b/packages/excalidraw/scene/zoom.ts
@@ -0,0 +1,35 @@
+import type { AppState, NormalizedZoomValue } from "../types";
+
+export const getStateForZoom = (
+ {
+ viewportX,
+ viewportY,
+ nextZoom,
+ }: {
+ viewportX: number;
+ viewportY: number;
+ nextZoom: NormalizedZoomValue;
+ },
+ appState: AppState,
+) => {
+ const appLayerX = viewportX - appState.offsetLeft;
+ const appLayerY = viewportY - appState.offsetTop;
+
+ const currentZoom = appState.zoom.value;
+
+ // get original scroll position without zoom
+ const baseScrollX = appState.scrollX + (appLayerX - appLayerX / currentZoom);
+ const baseScrollY = appState.scrollY + (appLayerY - appLayerY / currentZoom);
+
+ // get scroll offsets for target zoom level
+ const zoomOffsetScrollX = -(appLayerX - appLayerX / nextZoom);
+ const zoomOffsetScrollY = -(appLayerY - appLayerY / nextZoom);
+
+ return {
+ scrollX: baseScrollX + zoomOffsetScrollX,
+ scrollY: baseScrollY + zoomOffsetScrollY,
+ zoom: {
+ value: nextZoom,
+ },
+ };
+};