diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/scene | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/scene')
| -rw-r--r-- | packages/excalidraw/scene/Renderer.ts | 167 | ||||
| -rw-r--r-- | packages/excalidraw/scene/Scene.ts | 455 | ||||
| -rw-r--r-- | packages/excalidraw/scene/Shape.ts | 603 | ||||
| -rw-r--r-- | packages/excalidraw/scene/ShapeCache.ts | 86 | ||||
| -rw-r--r-- | packages/excalidraw/scene/comparisons.ts | 44 | ||||
| -rw-r--r-- | packages/excalidraw/scene/export.ts | 558 | ||||
| -rw-r--r-- | packages/excalidraw/scene/index.ts | 20 | ||||
| -rw-r--r-- | packages/excalidraw/scene/normalize.ts | 15 | ||||
| -rw-r--r-- | packages/excalidraw/scene/scroll.ts | 91 | ||||
| -rw-r--r-- | packages/excalidraw/scene/scrollbars.ts | 124 | ||||
| -rw-r--r-- | packages/excalidraw/scene/selection.test.ts | 35 | ||||
| -rw-r--r-- | packages/excalidraw/scene/selection.ts | 250 | ||||
| -rw-r--r-- | packages/excalidraw/scene/types.ts | 155 | ||||
| -rw-r--r-- | packages/excalidraw/scene/zoom.ts | 35 |
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, + }, + }; +}; |
