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 { ElementConstructorOpts, newFrameElement, newImageElement, newMagicFrameElement, newTextElement, } from "../element/newElement"; import { getDefaultLineHeight, measureText, normalizeText, } from "../element/textElement"; import { ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameElement, ExcalidrawFreeDrawElement, ExcalidrawGenericElement, ExcalidrawIframeLikeElement, ExcalidrawImageElement, ExcalidrawLinearElement, ExcalidrawMagicFrameElement, ExcalidrawSelectionElement, ExcalidrawTextElement, FileId, FontFamilyValues, TextAlign, VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; import { assertNever, cloneJSON, getFontString } from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; export type ValidLinearElement = { type: "arrow" | "line"; x: number; y: number; label?: { text: string; fontSize?: number; fontFamily?: FontFamilyValues; textAlign?: TextAlign; verticalAlign?: VerticalAlign; } & MarkOptional; 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) ) & MarkOptional; 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) ) & MarkOptional; } & Partial; export type ValidContainer = | { type: Exclude; id?: ExcalidrawGenericElement["id"]; label?: { text: string; fontSize?: number; fontFamily?: FontFamilyValues; textAlign?: TextAlign; verticalAlign?: VerticalAlign; } & MarkOptional; } & ElementConstructorOpts; export type ExcalidrawElementSkeleton = | Extract< Exclude, ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement > | ({ type: Extract; x: number; y: number; } & Partial) | ValidContainer | ValidLinearElement | ({ type: "text"; text: string; x: number; y: number; id?: ExcalidrawTextElement["id"]; } & Partial) | ({ type: Extract; x: number; y: number; fileId: FileId; } & Partial) | ({ type: "frame"; children: readonly ExcalidrawElement["id"][]; name?: string; } & Partial) | ({ type: "magicframe"; children: readonly ExcalidrawElement["id"][]; name?: string; } & Partial); const DEFAULT_LINEAR_ELEMENT_PROPS = { width: 100, height: 0, }; const DEFAULT_DIMENSION = 100; const bindTextToContainer = ( container: ExcalidrawElement, textProps: { text: string } & MarkOptional, ) => { 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); return [container, textElement] as const; }; const bindLinearElementToElement = ( linearElement: ExcalidrawArrowElement, start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, ): { 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", ); } } 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", ); } } // 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(linearElement.points) as [number, number][]; // 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(); add = (ele?: ExcalidrawElement) => { if (!ele) { return; } this.excalidrawElements.set(ele.id, ele); }; getElements = () => { return Array.from(this.excalidrawElements.values()); }; 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(); const oldToNewElementIdMap = new Map(); // 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: [ [0, 0], [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 = newLinearElement({ width, height, endArrowhead: "arrow", points: [ [0, 0], [width, height], ], ...element, }); 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 || getDefaultLineHeight(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); } } } // 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, ); elementStore.add(container); elementStore.add(text); if (container.type === "arrow") { 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 as ExcalidrawArrowElement, originalStart, originalEnd, elementStore, ); 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, ); 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 possibe 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; // Take the max of calculated and provided frame dimensions, whichever is higher const width = Math.max(frame?.width, maxX - minX); const height = Math.max(frame?.height, maxY - minY); Object.assign(frame, { x: minX, y: minY, width, height }); } return elementStore.getElements(); };