snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View 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();
}
}

View 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];
};

View 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";

View 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);
};

View 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 };
};

View 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;
};