snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
416
packages/excalidraw/scene/Renderer.ts
Normal file
416
packages/excalidraw/scene/Renderer.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import {
|
||||
getCommonFrameId,
|
||||
getFrameChildrenInsertionIndex,
|
||||
getContainerElement,
|
||||
isElementInViewport,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Scene } from "@excalidraw/element";
|
||||
|
||||
import { renderStaticSceneThrottled } from "../renderer/staticScene";
|
||||
|
||||
import type { RenderableElementsMap } from "./types";
|
||||
|
||||
import type { AppState, ElementRenderOffsets } from "../types";
|
||||
|
||||
type GetRenderableElementsOpts = {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
offsetTop: AppState["offsetTop"];
|
||||
scrollX: AppState["scrollX"];
|
||||
scrollY: AppState["scrollY"];
|
||||
height: AppState["height"];
|
||||
width: AppState["width"];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||||
frameToHighlight: AppState["frameToHighlight"];
|
||||
};
|
||||
|
||||
export class Renderer {
|
||||
private scene: Scene;
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the document-visible set for render-override translations: an
|
||||
* element leaves it when its offset moves it out of view and enters it when
|
||||
* its offset brings it in; everything else keeps its document visibility.
|
||||
* Only translated elements go through viewport geometry. The result is
|
||||
* memoized on the offsets' identity so that opacity-only snapshots don't
|
||||
* reach it at all.
|
||||
*/
|
||||
public getVisibleElementsWithRenderOffsets(
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[],
|
||||
elementsMap: RenderableElementsMap,
|
||||
appState: AppState,
|
||||
offsets: ElementRenderOffsets,
|
||||
) {
|
||||
return this._getVisibleElementsWithRenderOffsets({
|
||||
visibleElements,
|
||||
elementsMap,
|
||||
offsets,
|
||||
zoom: appState.zoom,
|
||||
scrollX: appState.scrollX,
|
||||
scrollY: appState.scrollY,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
offsetTop: appState.offsetTop,
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
selectedElements: this.scene.getSelectedElements(appState),
|
||||
frameToHighlight: appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
private _getVisibleElementsWithRenderOffsets = memoize(
|
||||
({
|
||||
visibleElements,
|
||||
elementsMap,
|
||||
offsets,
|
||||
selectedElements,
|
||||
frameToHighlight,
|
||||
...viewport
|
||||
}: Pick<
|
||||
GetRenderableElementsOpts,
|
||||
| "zoom"
|
||||
| "scrollX"
|
||||
| "scrollY"
|
||||
| "offsetLeft"
|
||||
| "offsetTop"
|
||||
| "width"
|
||||
| "height"
|
||||
| "selectedElements"
|
||||
| "frameToHighlight"
|
||||
> & {
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
elementsMap: RenderableElementsMap;
|
||||
offsets: ElementRenderOffsets;
|
||||
}) => {
|
||||
// Shifting the viewport instead of the element also works for arrow
|
||||
// labels, whose coordinates derive from their container.
|
||||
const isVisible = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
offset: ElementRenderOffsets extends ReadonlyMap<string, infer T>
|
||||
? T
|
||||
: never,
|
||||
) =>
|
||||
isElementInViewport(
|
||||
element,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
{
|
||||
...viewport,
|
||||
scrollX: viewport.scrollX + offset.x,
|
||||
scrollY: viewport.scrollY + offset.y,
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const documentVisible = this.getVisibleSet(visibleElements);
|
||||
const added = new Set<NonDeletedExcalidrawElement>();
|
||||
const removed = new Set<NonDeletedExcalidrawElement>();
|
||||
const update = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
visible: boolean,
|
||||
) => {
|
||||
if (documentVisible.has(element) !== visible) {
|
||||
(visible ? added : removed).add(element);
|
||||
}
|
||||
};
|
||||
for (const [id, offset] of offsets) {
|
||||
const element = elementsMap.get(id);
|
||||
// A bound label's own offset is ignored, and it needs no entry of
|
||||
// its own: the static renderer draws it with its container and skips
|
||||
// its entry, so its document visibility can stay as it is.
|
||||
if (
|
||||
!element ||
|
||||
(isTextElement(element) && getContainerElement(element, elementsMap))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
update(element, isVisible(element, offset));
|
||||
}
|
||||
|
||||
if (!added.size && !removed.size) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
const needsFrameReordering =
|
||||
frameToHighlight &&
|
||||
getCommonFrameId(selectedElements) !== frameToHighlight.id;
|
||||
if (!added.size && !needsFrameReordering) {
|
||||
return visibleElements.filter((element) => !removed.has(element));
|
||||
}
|
||||
|
||||
// Rebuild in scene order when elements enter the view. Frame-drag
|
||||
// ordering also needs rebuilding on removals, since the anchoring frame
|
||||
// may have left the view.
|
||||
const result: NonDeletedExcalidrawElement[] = [];
|
||||
for (const element of elementsMap.values()) {
|
||||
if (
|
||||
added.has(element) ||
|
||||
(documentVisible.has(element) && !removed.has(element))
|
||||
) {
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
return needsFrameReordering
|
||||
? this.sortSelectedElementsIntoHighlightedFrame({
|
||||
visibleElements: result,
|
||||
selectedElements,
|
||||
frameToHighlight,
|
||||
})
|
||||
: result;
|
||||
},
|
||||
);
|
||||
|
||||
/** the document-visible elements as a set, cached per visible array */
|
||||
private visibleSets = new WeakMap<
|
||||
readonly NonDeletedExcalidrawElement[],
|
||||
Set<NonDeletedExcalidrawElement>
|
||||
>();
|
||||
|
||||
private getVisibleSet(
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[],
|
||||
) {
|
||||
let set = this.visibleSets.get(visibleElements);
|
||||
if (!set) {
|
||||
set = new Set(visibleElements);
|
||||
this.visibleSets.set(visibleElements, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
|
||||
private getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
editingTextElement: AppState["editingTextElement"];
|
||||
newElement: AppState["newElement"];
|
||||
}) {
|
||||
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
|
||||
const newElementCanvasElement = newElement?.frameId ? null : newElement;
|
||||
|
||||
for (const element of elements) {
|
||||
if (newElementCanvasElement?.id === 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, newElementCanvasElement };
|
||||
}
|
||||
|
||||
private sortSelectedElementsIntoHighlightedFrame<
|
||||
T extends ExcalidrawElement,
|
||||
>({
|
||||
visibleElements,
|
||||
selectedElements,
|
||||
frameToHighlight,
|
||||
}: {
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
visibleElements: readonly T[];
|
||||
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement>;
|
||||
}): readonly T[] {
|
||||
if (!selectedElements.length) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
// we assume all selected elements are eligible frame children if
|
||||
// frameToHighlight is defined
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
// thus, all deselected elements are the ones we won't reorder
|
||||
const deselectedElements = visibleElements.filter(
|
||||
(element) => !selectedElementsMap.has(element.id),
|
||||
);
|
||||
|
||||
const insertionIndex = getFrameChildrenInsertionIndex(
|
||||
deselectedElements,
|
||||
frameToHighlight.id,
|
||||
);
|
||||
|
||||
if (insertionIndex === null) {
|
||||
return visibleElements;
|
||||
}
|
||||
|
||||
return [
|
||||
...deselectedElements.slice(0, insertionIndex),
|
||||
...selectedElements,
|
||||
...deselectedElements.slice(insertionIndex),
|
||||
] as readonly T[];
|
||||
}
|
||||
|
||||
private _getRenderableElements = memoize(
|
||||
({
|
||||
canvasNonce,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
}: Omit<
|
||||
GetRenderableElementsOpts,
|
||||
| "selectedElements"
|
||||
| "selectedElementsAreBeingDragged"
|
||||
| "frameToHighlight"
|
||||
> & {
|
||||
canvasNonce: string;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
|
||||
const { elementsMap, newElementCanvasElement } =
|
||||
this.getRenderableElementsMap({
|
||||
elements,
|
||||
editingTextElement,
|
||||
newElement,
|
||||
});
|
||||
|
||||
const visibleElements = this.getVisibleCanvasElements({
|
||||
elementsMap,
|
||||
zoom,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
scrollX,
|
||||
scrollY,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
|
||||
return {
|
||||
elementsMap,
|
||||
visibleElements,
|
||||
newElementCanvasElement,
|
||||
canvasNonce,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
public getRenderableElements = (opts: GetRenderableElementsOpts) => {
|
||||
const { newElement } = opts;
|
||||
const canvasNonce = `${this.scene.getSceneNonce()}${
|
||||
newElement?.frameId ? `:${newElement.versionNonce}` : ""
|
||||
}`;
|
||||
|
||||
const ret = this._getRenderableElements({
|
||||
canvasNonce,
|
||||
|
||||
// don't spread `opts` because we don't want to memoize on some props
|
||||
|
||||
zoom: opts.zoom,
|
||||
offsetLeft: opts.offsetLeft,
|
||||
offsetTop: opts.offsetTop,
|
||||
scrollX: opts.scrollX,
|
||||
scrollY: opts.scrollY,
|
||||
height: opts.height,
|
||||
width: opts.width,
|
||||
editingTextElement: opts.editingTextElement,
|
||||
newElement: opts.newElement,
|
||||
});
|
||||
|
||||
// if we're dragging elements over a frame, reorder the selected elements
|
||||
// inside the frame during render (we don't set the `element.frameId` until
|
||||
// pointerup else we'd have to painstainly restore the orig index if user
|
||||
// didn't end up adding elements to the frame)
|
||||
if (
|
||||
opts.frameToHighlight &&
|
||||
opts.selectedElementsAreBeingDragged &&
|
||||
// if all dragged elements are already in the frame, don't reorder
|
||||
getCommonFrameId(opts.selectedElements) !== opts.frameToHighlight.id
|
||||
) {
|
||||
const reorderedVisibleElements =
|
||||
this.sortSelectedElementsIntoHighlightedFrame({
|
||||
visibleElements: ret.visibleElements,
|
||||
selectedElements: opts.selectedElements,
|
||||
frameToHighlight: opts.frameToHighlight,
|
||||
});
|
||||
|
||||
return {
|
||||
...ret,
|
||||
visibleElements: reorderedVisibleElements,
|
||||
};
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
|
||||
// safe to break TS contract here (for upstream cases)
|
||||
public destroy() {
|
||||
renderStaticSceneThrottled.cancel();
|
||||
this._getRenderableElements.clear();
|
||||
this._getVisibleElementsWithRenderOffsets.clear();
|
||||
}
|
||||
}
|
||||
587
packages/excalidraw/scene/export.ts
Normal file
587
packages/excalidraw/scene/export.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
FRAME_STYLE,
|
||||
FONT_FAMILY,
|
||||
SVG_NS,
|
||||
THEME,
|
||||
MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
arrayToMap,
|
||||
distance,
|
||||
getFontString,
|
||||
toBrandedType,
|
||||
applyDarkModeFilter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getInitializedImageElements,
|
||||
updateImageCache,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
getElementsOverlappingFrame,
|
||||
getFrameLikeElements,
|
||||
getFrameLikeTitle,
|
||||
getRootElements,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { syncInvalidIndices } from "@excalidraw/element";
|
||||
|
||||
import { type Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { newTextElement } from "@excalidraw/element";
|
||||
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
|
||||
import { Fonts } from "../fonts";
|
||||
|
||||
import { renderStaticScene } from "../renderer/staticScene";
|
||||
import { renderSceneToSvg } from "../renderer/staticSvgScene";
|
||||
|
||||
import type { RenderableElementsMap } from "./types";
|
||||
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
|
||||
const truncateText = (
|
||||
element: NonDeleted<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<NonDeleted<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 NonDeletedExcalidrawElement[];
|
||||
exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
|
||||
frameRendering: AppState["frameRendering"];
|
||||
exportWithDarkMode: AppState["exportWithDarkMode"];
|
||||
}) => {
|
||||
let nextElements: readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
if (exportingFrame) {
|
||||
nextElements = getElementsOverlappingFrame(
|
||||
elements,
|
||||
exportingFrame,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
} 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?: NonDeleted<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,
|
||||
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
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?: NonDeleted<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}`);
|
||||
|
||||
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",
|
||||
applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode),
|
||||
);
|
||||
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,
|
||||
theme: exportWithDarkMode ? THEME.DARK : THEME.LIGHT,
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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];
|
||||
};
|
||||
22
packages/excalidraw/scene/index.ts
Normal file
22
packages/excalidraw/scene/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
isSomeElementSelected,
|
||||
getElementsWithinSelection,
|
||||
getSelectedElements,
|
||||
getTargetElements,
|
||||
} from "@excalidraw/element";
|
||||
export { getScrollToContentState } from "../viewport";
|
||||
export {
|
||||
hasBackground,
|
||||
hasFillStyle,
|
||||
hasRoughness,
|
||||
hasStrokeWidth,
|
||||
hasStrokeStyle,
|
||||
hasFreedrawMode,
|
||||
canHaveArrowheads,
|
||||
canChangeRoundness,
|
||||
} from "@excalidraw/element";
|
||||
export {
|
||||
getNormalizedZoom,
|
||||
getNormalizedGridSize,
|
||||
getNormalizedGridStep,
|
||||
} from "./normalize";
|
||||
17
packages/excalidraw/scene/normalize.ts
Normal file
17
packages/excalidraw/scene/normalize.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MAX_ZOOM, MIN_ZOOM } from "@excalidraw/common";
|
||||
|
||||
import { clamp, round } from "@excalidraw/math";
|
||||
|
||||
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);
|
||||
};
|
||||
155
packages/excalidraw/scene/scrollbars.ts
Normal file
155
packages/excalidraw/scene/scrollbars.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { getGlobalCSSVariable } from "@excalidraw/common";
|
||||
|
||||
import { getCommonBounds } from "@excalidraw/element";
|
||||
|
||||
import { getLanguage } from "../i18n";
|
||||
|
||||
import type { InteractiveCanvasAppState } from "../types";
|
||||
import type { RenderableElementsMap, ScrollBars } from "./types";
|
||||
|
||||
export const SCROLLBAR_MARGIN = 4;
|
||||
export const SCROLLBAR_WIDTH = 6;
|
||||
export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
|
||||
|
||||
// The scrollbar represents where the viewport is in relationship to the scene
|
||||
export const getScrollBars = (
|
||||
elements: RenderableElementsMap,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
appState: InteractiveCanvasAppState,
|
||||
): ScrollBars => {
|
||||
if (!elements.size) {
|
||||
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 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 + safeArea.left;
|
||||
const viewportMinY = -appState.scrollY + 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 elements-only bbox
|
||||
const sceneWidth = elementsMaxX - elementsMinX;
|
||||
const sceneHeight = elementsMaxY - elementsMinY;
|
||||
|
||||
// scene (elements) bbox + the viewport bbox that extends outside of it
|
||||
const extendedSceneWidth = sceneMaxX - sceneMinX;
|
||||
const extendedSceneHeight = sceneMaxY - sceneMinY;
|
||||
|
||||
const scrollWidthOffset =
|
||||
Math.max(SCROLLBAR_MARGIN * 2, safeArea.left + safeArea.right) +
|
||||
SCROLLBAR_WIDTH * 2;
|
||||
|
||||
const scrollbarWidth =
|
||||
viewportWidth * (viewportWidthWithZoom / extendedSceneWidth) -
|
||||
scrollWidthOffset;
|
||||
|
||||
const scrollbarHeightOffset =
|
||||
Math.max(SCROLLBAR_MARGIN * 2, safeArea.top + safeArea.bottom) +
|
||||
SCROLLBAR_WIDTH * 2;
|
||||
|
||||
const scrollbarHeight =
|
||||
viewportHeight * (viewportHeightWithZoom / extendedSceneHeight) -
|
||||
scrollbarHeightOffset;
|
||||
// NOTE the delta multiplier calculation isn't quite correct when viewport
|
||||
// is extended outside the scene (elements) bbox as there's some small
|
||||
// accumulation error. I'll let this be an exercise for others to fix. ^^
|
||||
const horizontalDeltaMultiplier =
|
||||
extendedSceneWidth > sceneWidth
|
||||
? (extendedSceneWidth * appState.zoom.value) /
|
||||
(scrollbarWidth + scrollWidthOffset)
|
||||
: viewportWidth / (scrollbarWidth + scrollWidthOffset);
|
||||
|
||||
const verticalDeltaMultiplier =
|
||||
extendedSceneHeight > sceneHeight
|
||||
? (extendedSceneHeight * appState.zoom.value) /
|
||||
(scrollbarHeight + scrollbarHeightOffset)
|
||||
: viewportHeight / (scrollbarHeight + scrollbarHeightOffset);
|
||||
return {
|
||||
horizontal:
|
||||
viewportMinX === sceneMinX && viewportMaxX === sceneMaxX
|
||||
? null
|
||||
: {
|
||||
x:
|
||||
Math.max(safeArea.left, SCROLLBAR_MARGIN) +
|
||||
SCROLLBAR_WIDTH +
|
||||
((viewportMinX - sceneMinX) / extendedSceneWidth) * viewportWidth,
|
||||
y:
|
||||
viewportHeight -
|
||||
SCROLLBAR_WIDTH -
|
||||
Math.max(SCROLLBAR_MARGIN, safeArea.bottom),
|
||||
width: scrollbarWidth,
|
||||
height: SCROLLBAR_WIDTH,
|
||||
deltaMultiplier: horizontalDeltaMultiplier,
|
||||
},
|
||||
|
||||
vertical:
|
||||
viewportMinY === sceneMinY && viewportMaxY === sceneMaxY
|
||||
? null
|
||||
: {
|
||||
x: isRTL
|
||||
? Math.max(safeArea.left, SCROLLBAR_MARGIN)
|
||||
: viewportWidth -
|
||||
SCROLLBAR_WIDTH -
|
||||
Math.max(safeArea.right, SCROLLBAR_MARGIN),
|
||||
y:
|
||||
Math.max(safeArea.top, SCROLLBAR_MARGIN) +
|
||||
SCROLLBAR_WIDTH +
|
||||
((viewportMinY - sceneMinY) / extendedSceneHeight) *
|
||||
viewportHeight,
|
||||
width: SCROLLBAR_WIDTH,
|
||||
height: scrollbarHeight,
|
||||
deltaMultiplier: verticalDeltaMultiplier,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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 };
|
||||
};
|
||||
179
packages/excalidraw/scene/types.ts
Normal file
179
packages/excalidraw/scene/types.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { UserIdleState, EditorInterface } from "@excalidraw/common";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { MakeBrand } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
ElementRenderOverrides,
|
||||
ElementsPendingErasure,
|
||||
InteractiveCanvasAppState,
|
||||
StaticCanvasAppState,
|
||||
SocketId,
|
||||
PendingExcalidrawElements,
|
||||
} from "../types";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
|
||||
export type RenderableElementsMap = NonDeletedElementsMap &
|
||||
MakeBrand<"RenderableElementsMap">;
|
||||
|
||||
export type StaticCanvasRenderConfig = {
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
renderGrid: boolean;
|
||||
/** whether to render link icons on elements with links (never rendered
|
||||
when exporting). @default true */
|
||||
renderLinks?: 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;
|
||||
theme: AppState["theme"];
|
||||
elementRenderOverrides?: ElementRenderOverrides;
|
||||
};
|
||||
|
||||
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;
|
||||
theme: AppState["theme"];
|
||||
};
|
||||
|
||||
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;
|
||||
lastViewportPosition: { x: number; y: number };
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
renderScrollbars?: boolean;
|
||||
};
|
||||
|
||||
export type RenderInteractiveSceneCallback = {
|
||||
scrollBars?: ScrollBars;
|
||||
};
|
||||
|
||||
export type StaticSceneRenderConfig = {
|
||||
canvas: HTMLCanvasElement;
|
||||
rc: RoughCanvas;
|
||||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
renderConfig: StaticCanvasRenderConfig;
|
||||
};
|
||||
|
||||
export type InteractiveSceneRenderAnimationState = {
|
||||
bindingHighlight: { runtime: number } | undefined;
|
||||
};
|
||||
|
||||
export type InteractiveSceneRenderConfig = {
|
||||
app: AppClassProperties;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementsMap: RenderableElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
renderConfig: InteractiveCanvasRenderConfig;
|
||||
editorInterface: EditorInterface;
|
||||
callback: (data: RenderInteractiveSceneCallback) => void;
|
||||
animationState?: InteractiveSceneRenderAnimationState;
|
||||
deltaTime: number;
|
||||
};
|
||||
|
||||
export type NewElementSceneRenderConfig = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
rc: RoughCanvas;
|
||||
newElement: NonDeletedExcalidrawElement | 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;
|
||||
deltaMultiplier: number;
|
||||
} | null;
|
||||
vertical: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
deltaMultiplier: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SVGPathString = string & { __brand: "SVGPathString" };
|
||||
|
||||
export type ElementShape =
|
||||
| Drawable
|
||||
| Drawable[]
|
||||
| Path2D
|
||||
| (Drawable | SVGPathString)[]
|
||||
| null;
|
||||
|
||||
export type ElementShapes = {
|
||||
rectangle: Drawable;
|
||||
ellipse: Drawable;
|
||||
diamond: Drawable;
|
||||
iframe: Drawable;
|
||||
embeddable: Drawable;
|
||||
freedraw: (Drawable | SVGPathString)[];
|
||||
arrow: Drawable[];
|
||||
line: Drawable[];
|
||||
text: null;
|
||||
stickynote: null;
|
||||
image: null;
|
||||
frame: null;
|
||||
magicframe: null;
|
||||
};
|
||||
Reference in New Issue
Block a user