diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/data/transform.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/data/transform.ts')
| -rw-r--r-- | packages/excalidraw/data/transform.ts | 791 |
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(); +}; |
