aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/data/transform.ts
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/data/transform.ts
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/data/transform.ts')
-rw-r--r--packages/excalidraw/data/transform.ts791
1 files changed, 791 insertions, 0 deletions
diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts
new file mode 100644
index 0000000..023740c
--- /dev/null
+++ b/packages/excalidraw/data/transform.ts
@@ -0,0 +1,791 @@
+import {
+ DEFAULT_FONT_FAMILY,
+ DEFAULT_FONT_SIZE,
+ TEXT_ALIGN,
+ VERTICAL_ALIGN,
+} from "../constants";
+import {
+ getCommonBounds,
+ newElement,
+ newLinearElement,
+ redrawTextBoundingBox,
+} from "../element";
+import { bindLinearElement } from "../element/binding";
+import type { ElementConstructorOpts } from "../element/newElement";
+import {
+ newArrowElement,
+ newFrameElement,
+ newImageElement,
+ newMagicFrameElement,
+ newTextElement,
+} from "../element/newElement";
+import type {
+ ElementsMap,
+ ExcalidrawArrowElement,
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ ExcalidrawFrameElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawGenericElement,
+ ExcalidrawIframeLikeElement,
+ ExcalidrawImageElement,
+ ExcalidrawLinearElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawSelectionElement,
+ ExcalidrawTextElement,
+ FileId,
+ FontFamilyValues,
+ NonDeletedSceneElementsMap,
+ TextAlign,
+ VerticalAlign,
+} from "../element/types";
+import type { MarkOptional } from "../utility-types";
+import {
+ arrayToMap,
+ assertNever,
+ cloneJSON,
+ getFontString,
+ isDevEnv,
+ toBrandedType,
+} from "../utils";
+import { getSizeFromPoints } from "../points";
+import { randomId } from "../random";
+import { syncInvalidIndices } from "../fractionalIndex";
+import { getLineHeight } from "../fonts";
+import { isArrowElement } from "../element/typeChecks";
+import { pointFrom, type LocalPoint } from "@excalidraw/math";
+import { measureText, normalizeText } from "../element/textMeasurements";
+
+export type ValidLinearElement = {
+ type: "arrow" | "line";
+ x: number;
+ y: number;
+ label?: {
+ text: string;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
+ } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+ end?:
+ | (
+ | (
+ | {
+ type: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ id?: ExcalidrawGenericElement["id"];
+ }
+ | {
+ id: ExcalidrawGenericElement["id"];
+ type?: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ }
+ )
+ | ((
+ | {
+ type: "text";
+ text: string;
+ }
+ | {
+ type?: "text";
+ id: ExcalidrawTextElement["id"];
+ text: string;
+ }
+ ) &
+ Partial<ExcalidrawTextElement>)
+ ) &
+ MarkOptional<ElementConstructorOpts, "x" | "y">;
+ start?:
+ | (
+ | (
+ | {
+ type: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ id?: ExcalidrawGenericElement["id"];
+ }
+ | {
+ id: ExcalidrawGenericElement["id"];
+ type?: Exclude<
+ ExcalidrawBindableElement["type"],
+ | "image"
+ | "text"
+ | "frame"
+ | "magicframe"
+ | "embeddable"
+ | "iframe"
+ >;
+ }
+ )
+ | ((
+ | {
+ type: "text";
+ text: string;
+ }
+ | {
+ type?: "text";
+ id: ExcalidrawTextElement["id"];
+ text: string;
+ }
+ ) &
+ Partial<ExcalidrawTextElement>)
+ ) &
+ MarkOptional<ElementConstructorOpts, "x" | "y">;
+} & Partial<ExcalidrawLinearElement>;
+
+export type ValidContainer =
+ | {
+ type: Exclude<ExcalidrawGenericElement["type"], "selection">;
+ id?: ExcalidrawGenericElement["id"];
+ label?: {
+ text: string;
+ fontSize?: number;
+ fontFamily?: FontFamilyValues;
+ textAlign?: TextAlign;
+ verticalAlign?: VerticalAlign;
+ } & MarkOptional<ElementConstructorOpts, "x" | "y">;
+ } & ElementConstructorOpts;
+
+export type ExcalidrawElementSkeleton =
+ | Extract<
+ Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+ ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
+ >
+ | ({
+ type: Extract<ExcalidrawLinearElement["type"], "line">;
+ x: number;
+ y: number;
+ } & Partial<ExcalidrawLinearElement>)
+ | ValidContainer
+ | ValidLinearElement
+ | ({
+ type: "text";
+ text: string;
+ x: number;
+ y: number;
+ id?: ExcalidrawTextElement["id"];
+ } & Partial<ExcalidrawTextElement>)
+ | ({
+ type: Extract<ExcalidrawImageElement["type"], "image">;
+ x: number;
+ y: number;
+ fileId: FileId;
+ } & Partial<ExcalidrawImageElement>)
+ | ({
+ type: "frame";
+ children: readonly ExcalidrawElement["id"][];
+ name?: string;
+ } & Partial<ExcalidrawFrameElement>)
+ | ({
+ type: "magicframe";
+ children: readonly ExcalidrawElement["id"][];
+ name?: string;
+ } & Partial<ExcalidrawMagicFrameElement>);
+
+const DEFAULT_LINEAR_ELEMENT_PROPS = {
+ width: 100,
+ height: 0,
+};
+
+const DEFAULT_DIMENSION = 100;
+
+const bindTextToContainer = (
+ container: ExcalidrawElement,
+ textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
+ elementsMap: ElementsMap,
+) => {
+ const textElement: ExcalidrawTextElement = newTextElement({
+ x: 0,
+ y: 0,
+ textAlign: TEXT_ALIGN.CENTER,
+ verticalAlign: VERTICAL_ALIGN.MIDDLE,
+ ...textProps,
+ containerId: container.id,
+ strokeColor: textProps.strokeColor || container.strokeColor,
+ });
+
+ Object.assign(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: textElement.id,
+ }),
+ });
+
+ redrawTextBoundingBox(textElement, container, elementsMap);
+ return [container, textElement] as const;
+};
+
+const bindLinearElementToElement = (
+ linearElement: ExcalidrawArrowElement,
+ start: ValidLinearElement["start"],
+ end: ValidLinearElement["end"],
+ elementStore: ElementStore,
+ elementsMap: NonDeletedSceneElementsMap,
+): {
+ linearElement: ExcalidrawLinearElement;
+ startBoundElement?: ExcalidrawElement;
+ endBoundElement?: ExcalidrawElement;
+} => {
+ let startBoundElement;
+ let endBoundElement;
+
+ Object.assign(linearElement, {
+ startBinding: linearElement?.startBinding || null,
+ endBinding: linearElement.endBinding || null,
+ });
+
+ if (start) {
+ const width = start?.width ?? DEFAULT_DIMENSION;
+ const height = start?.height ?? DEFAULT_DIMENSION;
+
+ let existingElement;
+ if (start.id) {
+ existingElement = elementStore.getElement(start.id);
+ if (!existingElement) {
+ console.error(`No element for start binding with id ${start.id} found`);
+ }
+ }
+
+ const startX = start.x || linearElement.x - width;
+ const startY = start.y || linearElement.y - height / 2;
+ const startType = existingElement ? existingElement.type : start.type;
+
+ if (startType) {
+ if (startType === "text") {
+ let text = "";
+ if (existingElement && existingElement.type === "text") {
+ text = existingElement.text;
+ } else if (start.type === "text") {
+ text = start.text;
+ }
+ if (!text) {
+ console.error(
+ `No text found for start binding text element for ${linearElement.id}`,
+ );
+ }
+ startBoundElement = newTextElement({
+ x: startX,
+ y: startY,
+ type: "text",
+ ...existingElement,
+ ...start,
+ text,
+ });
+ // to position the text correctly when coordinates not provided
+ Object.assign(startBoundElement, {
+ x: start.x || linearElement.x - startBoundElement.width,
+ y: start.y || linearElement.y - startBoundElement.height / 2,
+ });
+ } else {
+ switch (startType) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ startBoundElement = newElement({
+ x: startX,
+ y: startY,
+ width,
+ height,
+ ...existingElement,
+ ...start,
+ type: startType,
+ });
+ break;
+ }
+ default: {
+ assertNever(
+ linearElement as never,
+ `Unhandled element start type "${start.type}"`,
+ true,
+ );
+ }
+ }
+ }
+
+ bindLinearElement(
+ linearElement,
+ startBoundElement as ExcalidrawBindableElement,
+ "start",
+ elementsMap,
+ );
+ }
+ }
+ if (end) {
+ const height = end?.height ?? DEFAULT_DIMENSION;
+ const width = end?.width ?? DEFAULT_DIMENSION;
+
+ let existingElement;
+ if (end.id) {
+ existingElement = elementStore.getElement(end.id);
+ if (!existingElement) {
+ console.error(`No element for end binding with id ${end.id} found`);
+ }
+ }
+ const endX = end.x || linearElement.x + linearElement.width;
+ const endY = end.y || linearElement.y - height / 2;
+ const endType = existingElement ? existingElement.type : end.type;
+
+ if (endType) {
+ if (endType === "text") {
+ let text = "";
+ if (existingElement && existingElement.type === "text") {
+ text = existingElement.text;
+ } else if (end.type === "text") {
+ text = end.text;
+ }
+
+ if (!text) {
+ console.error(
+ `No text found for end binding text element for ${linearElement.id}`,
+ );
+ }
+ endBoundElement = newTextElement({
+ x: endX,
+ y: endY,
+ type: "text",
+ ...existingElement,
+ ...end,
+ text,
+ });
+ // to position the text correctly when coordinates not provided
+ Object.assign(endBoundElement, {
+ y: end.y || linearElement.y - endBoundElement.height / 2,
+ });
+ } else {
+ switch (endType) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ endBoundElement = newElement({
+ x: endX,
+ y: endY,
+ width,
+ height,
+ ...existingElement,
+ ...end,
+ type: endType,
+ });
+ break;
+ }
+ default: {
+ assertNever(
+ linearElement as never,
+ `Unhandled element end type "${endType}"`,
+ true,
+ );
+ }
+ }
+ }
+
+ bindLinearElement(
+ linearElement,
+ endBoundElement as ExcalidrawBindableElement,
+ "end",
+ elementsMap,
+ );
+ }
+ }
+
+ // Safe check to early return for single point
+ if (linearElement.points.length < 2) {
+ return {
+ linearElement,
+ startBoundElement,
+ endBoundElement,
+ };
+ }
+
+ // Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
+ const endPointIndex = linearElement.points.length - 1;
+ const delta = 0.5;
+
+ const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
+
+ // left to right so shift the arrow towards right
+ if (
+ linearElement.points[endPointIndex][0] >
+ linearElement.points[endPointIndex - 1][0]
+ ) {
+ newPoints[0][0] = delta;
+ newPoints[endPointIndex][0] -= delta;
+ }
+
+ // right to left so shift the arrow towards left
+ if (
+ linearElement.points[endPointIndex][0] <
+ linearElement.points[endPointIndex - 1][0]
+ ) {
+ newPoints[0][0] = -delta;
+ newPoints[endPointIndex][0] += delta;
+ }
+ // top to bottom so shift the arrow towards top
+ if (
+ linearElement.points[endPointIndex][1] >
+ linearElement.points[endPointIndex - 1][1]
+ ) {
+ newPoints[0][1] = delta;
+ newPoints[endPointIndex][1] -= delta;
+ }
+
+ // bottom to top so shift the arrow towards bottom
+ if (
+ linearElement.points[endPointIndex][1] <
+ linearElement.points[endPointIndex - 1][1]
+ ) {
+ newPoints[0][1] = -delta;
+ newPoints[endPointIndex][1] += delta;
+ }
+
+ Object.assign(linearElement, { points: newPoints });
+
+ return {
+ linearElement,
+ startBoundElement,
+ endBoundElement,
+ };
+};
+
+class ElementStore {
+ excalidrawElements = new Map<string, ExcalidrawElement>();
+
+ add = (ele?: ExcalidrawElement) => {
+ if (!ele) {
+ return;
+ }
+
+ this.excalidrawElements.set(ele.id, ele);
+ };
+
+ getElements = () => {
+ return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
+ };
+
+ getElementsMap = () => {
+ return toBrandedType<NonDeletedSceneElementsMap>(
+ arrayToMap(this.getElements()),
+ );
+ };
+
+ getElement = (id: string) => {
+ return this.excalidrawElements.get(id);
+ };
+}
+
+export const convertToExcalidrawElements = (
+ elementsSkeleton: ExcalidrawElementSkeleton[] | null,
+ opts?: { regenerateIds: boolean },
+) => {
+ if (!elementsSkeleton) {
+ return [];
+ }
+ const elements = cloneJSON(elementsSkeleton);
+ const elementStore = new ElementStore();
+ const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
+ const oldToNewElementIdMap = new Map<string, string>();
+
+ // Create individual elements
+ for (const element of elements) {
+ let excalidrawElement: ExcalidrawElement;
+ const originalId = element.id;
+ if (opts?.regenerateIds !== false) {
+ Object.assign(element, { id: randomId() });
+ }
+
+ switch (element.type) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond": {
+ const width =
+ element?.label?.text && element.width === undefined
+ ? 0
+ : element?.width || DEFAULT_DIMENSION;
+ const height =
+ element?.label?.text && element.height === undefined
+ ? 0
+ : element?.height || DEFAULT_DIMENSION;
+ excalidrawElement = newElement({
+ ...element,
+ width,
+ height,
+ });
+
+ break;
+ }
+ case "line": {
+ const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+ const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+ excalidrawElement = newLinearElement({
+ width,
+ height,
+ points: [pointFrom(0, 0), pointFrom(width, height)],
+ ...element,
+ });
+
+ break;
+ }
+ case "arrow": {
+ const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
+ const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
+ excalidrawElement = newArrowElement({
+ width,
+ height,
+ endArrowhead: "arrow",
+ points: [pointFrom(0, 0), pointFrom(width, height)],
+ ...element,
+ type: "arrow",
+ });
+
+ Object.assign(
+ excalidrawElement,
+ getSizeFromPoints(excalidrawElement.points),
+ );
+ break;
+ }
+ case "text": {
+ const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
+ const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
+ const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
+ const text = element.text ?? "";
+ const normalizedText = normalizeText(text);
+ const metrics = measureText(
+ normalizedText,
+ getFontString({ fontFamily, fontSize }),
+ lineHeight,
+ );
+
+ excalidrawElement = newTextElement({
+ width: metrics.width,
+ height: metrics.height,
+ fontFamily,
+ fontSize,
+ ...element,
+ });
+ break;
+ }
+ case "image": {
+ excalidrawElement = newImageElement({
+ width: element?.width || DEFAULT_DIMENSION,
+ height: element?.height || DEFAULT_DIMENSION,
+ ...element,
+ });
+
+ break;
+ }
+ case "frame": {
+ excalidrawElement = newFrameElement({
+ x: 0,
+ y: 0,
+ ...element,
+ });
+ break;
+ }
+ case "magicframe": {
+ excalidrawElement = newMagicFrameElement({
+ x: 0,
+ y: 0,
+ ...element,
+ });
+ break;
+ }
+ case "freedraw":
+ case "iframe":
+ case "embeddable": {
+ excalidrawElement = element;
+ break;
+ }
+
+ default: {
+ excalidrawElement = element;
+ assertNever(
+ element,
+ `Unhandled element type "${(element as any).type}"`,
+ true,
+ );
+ }
+ }
+ const existingElement = elementStore.getElement(excalidrawElement.id);
+ if (existingElement) {
+ console.error(`Duplicate id found for ${excalidrawElement.id}`);
+ } else {
+ elementStore.add(excalidrawElement);
+ elementsWithIds.set(excalidrawElement.id, element);
+ if (originalId) {
+ oldToNewElementIdMap.set(originalId, excalidrawElement.id);
+ }
+ }
+ }
+
+ const elementsMap = elementStore.getElementsMap();
+ // Add labels and arrow bindings
+ for (const [id, element] of elementsWithIds) {
+ const excalidrawElement = elementStore.getElement(id)!;
+
+ switch (element.type) {
+ case "rectangle":
+ case "ellipse":
+ case "diamond":
+ case "arrow": {
+ if (element.label?.text) {
+ let [container, text] = bindTextToContainer(
+ excalidrawElement,
+ element?.label,
+ elementsMap,
+ );
+ elementStore.add(container);
+ elementStore.add(text);
+
+ if (isArrowElement(container)) {
+ const originalStart =
+ element.type === "arrow" ? element?.start : undefined;
+ const originalEnd =
+ element.type === "arrow" ? element?.end : undefined;
+ if (originalStart && originalStart.id) {
+ const newStartId = oldToNewElementIdMap.get(originalStart.id);
+ if (newStartId) {
+ Object.assign(originalStart, { id: newStartId });
+ }
+ }
+ if (originalEnd && originalEnd.id) {
+ const newEndId = oldToNewElementIdMap.get(originalEnd.id);
+ if (newEndId) {
+ Object.assign(originalEnd, { id: newEndId });
+ }
+ }
+ const { linearElement, startBoundElement, endBoundElement } =
+ bindLinearElementToElement(
+ container,
+ originalStart,
+ originalEnd,
+ elementStore,
+ elementsMap,
+ );
+ container = linearElement;
+ elementStore.add(linearElement);
+ elementStore.add(startBoundElement);
+ elementStore.add(endBoundElement);
+ }
+ } else {
+ switch (element.type) {
+ case "arrow": {
+ const { start, end } = element;
+ if (start && start.id) {
+ const newStartId = oldToNewElementIdMap.get(start.id);
+ Object.assign(start, { id: newStartId });
+ }
+ if (end && end.id) {
+ const newEndId = oldToNewElementIdMap.get(end.id);
+ Object.assign(end, { id: newEndId });
+ }
+ const { linearElement, startBoundElement, endBoundElement } =
+ bindLinearElementToElement(
+ excalidrawElement as ExcalidrawArrowElement,
+ start,
+ end,
+ elementStore,
+ elementsMap,
+ );
+
+ elementStore.add(linearElement);
+ elementStore.add(startBoundElement);
+ elementStore.add(endBoundElement);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ // Once all the excalidraw elements are created, we can add frames since we
+ // need to calculate coordinates and dimensions of frame which is possible after all
+ // frame children are processed.
+ for (const [id, element] of elementsWithIds) {
+ if (element.type !== "frame" && element.type !== "magicframe") {
+ continue;
+ }
+ const frame = elementStore.getElement(id);
+
+ if (!frame) {
+ throw new Error(`Excalidraw element with id ${id} doesn't exist`);
+ }
+ const childrenElements: ExcalidrawElement[] = [];
+
+ element.children.forEach((id) => {
+ const newElementId = oldToNewElementIdMap.get(id);
+ if (!newElementId) {
+ throw new Error(`Element with ${id} wasn't mapped correctly`);
+ }
+
+ const elementInFrame = elementStore.getElement(newElementId);
+ if (!elementInFrame) {
+ throw new Error(`Frame element with id ${newElementId} doesn't exist`);
+ }
+ Object.assign(elementInFrame, { frameId: frame.id });
+
+ elementInFrame?.boundElements?.forEach((boundElement) => {
+ const ele = elementStore.getElement(boundElement.id);
+ if (!ele) {
+ throw new Error(
+ `Bound element with id ${boundElement.id} doesn't exist`,
+ );
+ }
+ Object.assign(ele, { frameId: frame.id });
+ childrenElements.push(ele);
+ });
+
+ childrenElements.push(elementInFrame);
+ });
+
+ let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
+
+ const PADDING = 10;
+ minX = minX - PADDING;
+ minY = minY - PADDING;
+ maxX = maxX + PADDING;
+ maxY = maxY + PADDING;
+
+ const frameX = frame?.x || minX;
+ const frameY = frame?.y || minY;
+ const frameWidth = frame?.width || maxX - minX;
+ const frameHeight = frame?.height || maxY - minY;
+
+ Object.assign(frame, {
+ x: frameX,
+ y: frameY,
+ width: frameWidth,
+ height: frameHeight,
+ });
+ if (
+ isDevEnv() &&
+ element.children.length &&
+ (frame?.x || frame?.y || frame?.width || frame?.height)
+ ) {
+ console.info(
+ "User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
+ );
+ }
+ }
+
+ return elementStore.getElements();
+};