From 5516e7c8198e6e434f0bfbf04ac19dbfdd4f9b8d Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 26 May 2025 10:04:22 +1000 Subject: [PATCH] feat: bind to mid point --- packages/element/src/binding.ts | 438 +++++++++++++ packages/element/src/bounds.ts | 3 +- packages/element/src/elbowArrow.ts | 2 - .../TTDDialog/MermaidToExcalidraw.tsx | 15 + .../excalidraw/components/TTDDialog/common.ts | 1 + packages/excalidraw/data/transform.ts | 575 ++++++++++-------- 6 files changed, 778 insertions(+), 256 deletions(-) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 2ea05510b4..569a319041 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -59,6 +59,7 @@ import { isFixedPointBinding, isFrameLikeElement, isLinearElement, + isRectangularElement, isRectanguloidElement, isTextElement, } from "./typeChecks"; @@ -66,6 +67,11 @@ import { import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { updateElbowArrowPoints } from "./elbowArrow"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + import type { Scene } from "./Scene"; import type { Bounds } from "./bounds"; @@ -85,6 +91,7 @@ import type { FixedPoint, FixedPointBinding, PointsPositionUpdates, + ExcalidrawRectanguloidElement, } from "./types"; export type SuggestedBinding = @@ -2223,3 +2230,434 @@ export const normalizeFixedPoint = ( } return fixedPoint as any as T extends null ? null : FixedPoint; }; + +type Side = + | "top" + | "top-right" + | "right" + | "bottom-right" + | "bottom" + | "bottom-left" + | "left" + | "top-left"; +type ShapeType = "rectangle" | "ellipse" | "diamond"; +const getShapeType = (element: ExcalidrawBindableElement): ShapeType => { + if (element.type === "ellipse" || element.type === "diamond") { + return element.type; + } + return "rectangle"; +}; + +interface SectorConfig { + // center angle of the sector in degrees + centerAngle: number; + // width of the sector in degrees + sectorWidth: number; + side: Side; +} + +// Define sector configurations for different shape types +const SHAPE_CONFIGS: Record = { + // rectangle: 15° corners, 75° edges + rectangle: [ + { centerAngle: 0, sectorWidth: 75, side: "right" }, + { centerAngle: 45, sectorWidth: 15, side: "bottom-right" }, + { centerAngle: 90, sectorWidth: 75, side: "bottom" }, + { centerAngle: 135, sectorWidth: 15, side: "bottom-left" }, + { centerAngle: 180, sectorWidth: 75, side: "left" }, + { centerAngle: 225, sectorWidth: 15, side: "top-left" }, + { centerAngle: 270, sectorWidth: 75, side: "top" }, + { centerAngle: 315, sectorWidth: 15, side: "top-right" }, + ], + + // diamond: 15° vertices, 75° edges + diamond: [ + { centerAngle: 0, sectorWidth: 15, side: "right" }, + { centerAngle: 45, sectorWidth: 75, side: "bottom-right" }, + { centerAngle: 90, sectorWidth: 15, side: "bottom" }, + { centerAngle: 135, sectorWidth: 75, side: "bottom-left" }, + { centerAngle: 180, sectorWidth: 15, side: "left" }, + { centerAngle: 225, sectorWidth: 75, side: "top-left" }, + { centerAngle: 270, sectorWidth: 15, side: "top" }, + { centerAngle: 315, sectorWidth: 75, side: "top-right" }, + ], + + // ellipse: 15° cardinal points, 75° diagonals + ellipse: [ + { centerAngle: 0, sectorWidth: 15, side: "right" }, + { centerAngle: 45, sectorWidth: 75, side: "bottom-right" }, + { centerAngle: 90, sectorWidth: 15, side: "bottom" }, + { centerAngle: 135, sectorWidth: 75, side: "bottom-left" }, + { centerAngle: 180, sectorWidth: 15, side: "left" }, + { centerAngle: 225, sectorWidth: 75, side: "top-left" }, + { centerAngle: 270, sectorWidth: 15, side: "top" }, + { centerAngle: 315, sectorWidth: 75, side: "top-right" }, + ], +}; + +const getSectorBoundaries = ( + config: SectorConfig[], +): Array<{ start: number; end: number; side: Side }> => { + return config.map((sector, index) => { + const halfWidth = sector.sectorWidth / 2; + let start = sector.centerAngle - halfWidth; + let end = sector.centerAngle + halfWidth; + + // normalize angles to [0, 360) range + start = ((start % 360) + 360) % 360; + end = ((end % 360) + 360) % 360; + + return { start, end, side: sector.side }; + }); +}; + +// determine which side a point falls into using adaptive sectors +const getShapeSideAdaptive = ( + fixedPoint: FixedPoint, + shapeType: ShapeType, +): Side => { + const [x, y] = fixedPoint; + + // convert to centered coordinates + const centerX = x - 0.5; + const centerY = y - 0.5; + + // calculate angle + let angle = Math.atan2(centerY, centerX); + if (angle < 0) { + angle += 2 * Math.PI; + } + const degrees = (angle * 180) / Math.PI; + + // get sector configuration for this shape type + const config = SHAPE_CONFIGS[shapeType]; + const boundaries = getSectorBoundaries(config); + + // find which sector the angle falls into + for (const boundary of boundaries) { + if (boundary.start <= boundary.end) { + // Normal case: sector doesn't cross 0° + if (degrees >= boundary.start && degrees <= boundary.end) { + return boundary.side; + } + } else if (degrees >= boundary.start || degrees <= boundary.end) { + return boundary.side; + } + } + + // fallback - find nearest sector center + let minDiff = Infinity; + let nearestSide = config[0].side; + + for (const sector of config) { + let diff = Math.abs(degrees - sector.centerAngle); + // handle wraparound + if (diff > 180) { + diff = 360 - diff; + } + + if (diff < minDiff) { + minDiff = diff; + nearestSide = sector.side; + } + } + + return nearestSide; +}; + +export const getBindingSideMidPoint = ( + binding: FixedPointBinding, + elementsMap: ElementsMap, +) => { + const bindableElement = elementsMap.get(binding.elementId); + if ( + !bindableElement || + bindableElement.isDeleted || + !isBindableElement(bindableElement) + ) { + return null; + } + + const center = elementCenterPoint(bindableElement); + const shapeType = getShapeType(bindableElement); + const side = getShapeSideAdaptive( + normalizeFixedPoint(binding.fixedPoint), + shapeType, + ); + + // small offset to avoid precision issues in elbow + const OFFSET = 0.01; + + if (bindableElement.type === "diamond") { + const [sides, corners] = deconstructDiamondElement(bindableElement); + const [topRight, bottomRight, bottomLeft, topLeft] = sides; + + let x: number; + let y: number; + switch (side) { + case "left": { + // left vertex - use the center of the left corner curve + if (corners.length >= 3) { + const leftCorner = corners[2]; + const midPoint = leftCorner[1]; + x = midPoint[0] - OFFSET; + y = midPoint[1]; + } else { + // fallback for non-rounded diamond + const midPoint = getMidPoint(bottomLeft[1], topLeft[0]); + x = midPoint[0] - OFFSET; + y = midPoint[1]; + } + break; + } + case "right": { + if (corners.length >= 1) { + const rightCorner = corners[0]; + const midPoint = rightCorner[1]; + x = midPoint[0] + OFFSET; + y = midPoint[1]; + } else { + const midPoint = getMidPoint(topRight[1], bottomRight[0]); + x = midPoint[0] + OFFSET; + y = midPoint[1]; + } + break; + } + case "top": { + if (corners.length >= 4) { + const topCorner = corners[3]; + const midPoint = topCorner[1]; + x = midPoint[0]; + y = midPoint[1] - OFFSET; + } else { + const midPoint = getMidPoint(topLeft[1], topRight[0]); + x = midPoint[0]; + y = midPoint[1] - OFFSET; + } + break; + } + case "bottom": { + if (corners.length >= 2) { + const bottomCorner = corners[1]; + const midPoint = bottomCorner[1]; + x = midPoint[0]; + y = midPoint[1] + OFFSET; + } else { + const midPoint = getMidPoint(bottomRight[1], bottomLeft[0]); + x = midPoint[0]; + y = midPoint[1] + OFFSET; + } + break; + } + case "top-right": { + const midPoint = getMidPoint(topRight[0], topRight[1]); + + x = midPoint[0] + OFFSET * 0.707; + y = midPoint[1] - OFFSET * 0.707; + break; + } + case "bottom-right": { + const midPoint = getMidPoint(bottomRight[0], bottomRight[1]); + + x = midPoint[0] + OFFSET * 0.707; + y = midPoint[1] + OFFSET * 0.707; + break; + } + case "bottom-left": { + const midPoint = getMidPoint(bottomLeft[0], bottomLeft[1]); + x = midPoint[0] - OFFSET * 0.707; + y = midPoint[1] + OFFSET * 0.707; + break; + } + case "top-left": { + const midPoint = getMidPoint(topLeft[0], topLeft[1]); + x = midPoint[0] - OFFSET * 0.707; + y = midPoint[1] - OFFSET * 0.707; + break; + } + default: { + return null; + } + } + + return pointRotateRads(pointFrom(x, y), center, bindableElement.angle); + } + + if (bindableElement.type === "ellipse") { + const ellipseCenterX = bindableElement.x + bindableElement.width / 2; + const ellipseCenterY = bindableElement.y + bindableElement.height / 2; + const radiusX = bindableElement.width / 2; + const radiusY = bindableElement.height / 2; + + let x: number; + let y: number; + + switch (side) { + case "top": { + x = ellipseCenterX; + y = ellipseCenterY - radiusY - OFFSET; + break; + } + case "right": { + x = ellipseCenterX + radiusX + OFFSET; + y = ellipseCenterY; + break; + } + case "bottom": { + x = ellipseCenterX; + y = ellipseCenterY + radiusY + OFFSET; + break; + } + case "left": { + x = ellipseCenterX - radiusX - OFFSET; + y = ellipseCenterY; + break; + } + case "top-right": { + const angle = -Math.PI / 4; + const ellipseX = radiusX * Math.cos(angle); + const ellipseY = radiusY * Math.sin(angle); + x = ellipseCenterX + ellipseX + OFFSET * 0.707; + y = ellipseCenterY + ellipseY - OFFSET * 0.707; + break; + } + case "bottom-right": { + const angle = Math.PI / 4; + const ellipseX = radiusX * Math.cos(angle); + const ellipseY = radiusY * Math.sin(angle); + x = ellipseCenterX + ellipseX + OFFSET * 0.707; + y = ellipseCenterY + ellipseY + OFFSET * 0.707; + break; + } + case "bottom-left": { + const angle = (3 * Math.PI) / 4; + const ellipseX = radiusX * Math.cos(angle); + const ellipseY = radiusY * Math.sin(angle); + x = ellipseCenterX + ellipseX - OFFSET * 0.707; + y = ellipseCenterY + ellipseY + OFFSET * 0.707; + break; + } + case "top-left": { + const angle = (-3 * Math.PI) / 4; + const ellipseX = radiusX * Math.cos(angle); + const ellipseY = radiusY * Math.sin(angle); + x = ellipseCenterX + ellipseX - OFFSET * 0.707; + y = ellipseCenterY + ellipseY - OFFSET * 0.707; + break; + } + default: { + return null; + } + } + + return pointRotateRads(pointFrom(x, y), center, bindableElement.angle); + } + + if (isRectangularElement(bindableElement)) { + const [sides, corners] = deconstructRectanguloidElement( + bindableElement as ExcalidrawRectanguloidElement, + ); + const [top, right, bottom, left] = sides; + + let x: number; + let y: number; + switch (side) { + case "top": { + const midPoint = getMidPoint(top[0], top[1]); + x = midPoint[0]; + y = midPoint[1] - OFFSET; + break; + } + case "right": { + const midPoint = getMidPoint(right[0], right[1]); + x = midPoint[0] + OFFSET; + y = midPoint[1]; + break; + } + case "bottom": { + const midPoint = getMidPoint(bottom[0], bottom[1]); + x = midPoint[0]; + y = midPoint[1] + OFFSET; + break; + } + case "left": { + const midPoint = getMidPoint(left[0], left[1]); + x = midPoint[0] - OFFSET; + y = midPoint[1]; + break; + } + case "top-left": { + if (corners.length >= 1) { + const corner = corners[0]; + + const p1 = corner[0]; + const p2 = corner[3]; + const midPoint = getMidPoint(p1, p2); + + x = midPoint[0] - OFFSET * 0.707; + y = midPoint[1] - OFFSET * 0.707; + } else { + x = bindableElement.x - OFFSET; + y = bindableElement.y - OFFSET; + } + break; + } + case "top-right": { + if (corners.length >= 2) { + const corner = corners[1]; + const p1 = corner[0]; + const p2 = corner[3]; + const midPoint = getMidPoint(p1, p2); + + x = midPoint[0] + OFFSET * 0.707; + y = midPoint[1] - OFFSET * 0.707; + } else { + x = bindableElement.x + bindableElement.width + OFFSET; + y = bindableElement.y - OFFSET; + } + break; + } + case "bottom-right": { + if (corners.length >= 3) { + const corner = corners[2]; + const p1 = corner[0]; + const p2 = corner[3]; + const midPoint = getMidPoint(p1, p2); + + x = midPoint[0] + OFFSET * 0.707; + y = midPoint[1] + OFFSET * 0.707; + } else { + x = bindableElement.x + bindableElement.width + OFFSET; + y = bindableElement.y + bindableElement.height + OFFSET; + } + break; + } + case "bottom-left": { + if (corners.length >= 4) { + const corner = corners[3]; + const p1 = corner[0]; + const p2 = corner[3]; + const midPoint = getMidPoint(p1, p2); + + x = midPoint[0] - OFFSET * 0.707; + y = midPoint[1] + OFFSET * 0.707; + } else { + x = bindableElement.x - OFFSET; + y = bindableElement.y + bindableElement.height + OFFSET; + } + break; + } + default: { + return null; + } + } + + return pointRotateRads(pointFrom(x, y), center, bindableElement.angle); + } + + return null; +}; + +const getMidPoint = (p1: GlobalPoint, p2: GlobalPoint): GlobalPoint => { + return pointFrom((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2); +}; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a5b91922b4..4f522ecfdb 100644 --- a/packages/element/src/bounds.ts +++ b/packages/element/src/bounds.ts @@ -33,7 +33,6 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import type { Mutable } from "@excalidraw/common/utility-types"; -import { generateRoughOptions } from "./Shape"; import { ShapeCache } from "./ShapeCache"; import { LinearElementEditor } from "./linearElementEditor"; import { getBoundTextElement, getContainerElement } from "./textElement"; @@ -52,6 +51,8 @@ import { deconstructRectanguloidElement, } from "./utils"; +import { generateRoughOptions } from "./Shape"; + import type { Drawable, Op } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 50a4d1f3c1..73c82a8980 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -22,8 +22,6 @@ import { isDevEnv, } from "@excalidraw/common"; -import { debugDrawBounds } from "@excalidraw/utils/visualdebug"; - import type { AppState } from "@excalidraw/excalidraw/types"; import { diff --git a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx index a95a0c7087..44f76671e1 100644 --- a/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx +++ b/packages/excalidraw/components/TTDDialog/MermaidToExcalidraw.tsx @@ -2,6 +2,8 @@ import { useState, useRef, useEffect, useDeferredValue } from "react"; import { EDITOR_LS_KEYS, debounce, isDevEnv } from "@excalidraw/common"; +import { isElbowArrow } from "@excalidraw/element"; + import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { useApp } from "../App"; @@ -80,6 +82,19 @@ const MermaidToExcalidraw = ({ ); const onInsertToEditor = () => { + convertMermaidToExcalidraw({ + canvasRef, + data, + mermaidToExcalidrawLib, + setError, + mermaidDefinition: deferredText, + useElbow: arrowType === "elbow", + }).catch((err) => { + if (isDevEnv()) { + console.error("Failed to parse mermaid definition", err); + } + }); + insertToEditor({ app, data, diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index fd0631fa83..e057abd769 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -89,6 +89,7 @@ export const convertMermaidToExcalidraw = async ({ const { elements, files } = ret; setError(null); + // Store the converted elements (which now include adjusted elbow arrow points) data.current = { elements: convertToExcalidrawElements(elements, { regenerateIds: true, diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 90bc4040b7..3ff9675666 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -16,7 +16,11 @@ import { getLineHeight, } from "@excalidraw/common"; -import { bindLinearElement } from "@excalidraw/element"; +import { + bindLinearElement, + getBindingSideMidPoint, + isElbowArrow, +} from "@excalidraw/element"; import { newArrowElement, newElement, @@ -31,8 +35,6 @@ import { isArrowElement } from "@excalidraw/element"; import { syncInvalidIndices } from "@excalidraw/element"; -import { redrawTextBoundingBox } from "@excalidraw/element"; - import { LinearElementEditor } from "@excalidraw/element"; import { getCommonBounds } from "@excalidraw/element"; @@ -62,6 +64,7 @@ import type { } from "@excalidraw/element/types"; import type { MarkOptional } from "@excalidraw/common/utility-types"; + import { adjustBoundTextSize } from "../components/ConvertElementTypePopup"; export type ValidLinearElement = { @@ -244,242 +247,6 @@ const bindTextToContainer = ( return [container, textElement] as const; }; -const bindLinearElementToElement = ( - linearElement: ExcalidrawArrowElement, - start: ValidLinearElement["start"], - end: ValidLinearElement["end"], - elementStore: ElementStore, - scene: Scene, -): { - 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", - scene, - ); - } - } - 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", - scene, - ); - } - } - - // 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(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, - LinearElementEditor.getNormalizeElementPointsAndCoords({ - ...linearElement, - points: newPoints, - }), - ); - - return { - linearElement, - startBoundElement, - endBoundElement, - }; -}; - class ElementStore { excalidrawElements = new Map(); @@ -506,6 +273,289 @@ class ElementStore { }; } +const createBoundElement = ( + binding: ValidLinearElement["start"] | ValidLinearElement["end"], + linearElement: ExcalidrawArrowElement, + edge: "start" | "end", + elementStore: ElementStore, +): ExcalidrawElement | undefined => { + if (!binding) { + return undefined; + } + + const width = binding?.width ?? DEFAULT_DIMENSION; + const height = binding?.height ?? DEFAULT_DIMENSION; + + let existingElement; + if (binding.id) { + existingElement = elementStore.getElement(binding.id); + if (!existingElement) { + console.error( + `No element for ${edge} binding with id ${binding.id} found`, + ); + return undefined; + } + } + + const x = + binding.x || + (edge === "start" + ? linearElement.x - width + : linearElement.x + linearElement.width); + const y = binding.y || linearElement.y - height / 2; + const elementType = existingElement ? existingElement.type : binding.type; + + if (!elementType) { + return undefined; + } + + if (elementType === "text") { + let text = ""; + if (existingElement && existingElement.type === "text") { + text = existingElement.text; + } else if (binding.type === "text") { + text = binding.text; + } + if (!text) { + console.error( + `No text found for ${edge} binding text element for ${linearElement.id}`, + ); + return undefined; + } + const textElement = newTextElement({ + x, + y, + type: "text", + ...existingElement, + ...binding, + text, + }); + // to position the text correctly when coordinates not provided + Object.assign(textElement, { + x: + binding.x || + (edge === "start" ? linearElement.x - textElement.width : x), + y: binding.y || linearElement.y - textElement.height / 2, + }); + return textElement; + } + switch (elementType) { + case "rectangle": + case "ellipse": + case "diamond": { + return newElement({ + x, + y, + width, + height, + ...existingElement, + ...binding, + type: elementType, + }); + } + default: { + assertNever( + elementType as never, + `Unhandled element ${edge} type "${elementType}"`, + true, + ); + return undefined; + } + } +}; + +const bindLinearElementToElement = ( + linearElement: ExcalidrawArrowElement, + start: ValidLinearElement["start"], + end: ValidLinearElement["end"], + elementStore: ElementStore, + scene: Scene, +): { + linearElement: ExcalidrawLinearElement; + startBoundElement?: ExcalidrawElement; + endBoundElement?: ExcalidrawElement; +} => { + let startBoundElement; + let endBoundElement; + + Object.assign(linearElement, { + startBinding: linearElement?.startBinding || null, + endBinding: linearElement.endBinding || null, + }); + + if (start) { + startBoundElement = createBoundElement( + start, + linearElement, + "start", + elementStore, + ); + if (startBoundElement) { + elementStore.add(startBoundElement); + scene.replaceAllElements(elementStore.getElementsMap()); + bindLinearElement( + linearElement, + startBoundElement as ExcalidrawBindableElement, + "start", + scene, + ); + } + } + + if (end) { + endBoundElement = createBoundElement( + end, + linearElement, + "end", + elementStore, + ); + if (endBoundElement) { + elementStore.add(endBoundElement); + scene.replaceAllElements(elementStore.getElementsMap()); + bindLinearElement( + linearElement, + endBoundElement as ExcalidrawBindableElement, + "end", + scene, + ); + } + } + + 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. + if (!isElbowArrow(linearElement)) { + const endPointIndex = linearElement.points.length - 1; + const delta = 0.5; + + const newPoints = cloneJSON(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, + LinearElementEditor.getNormalizeElementPointsAndCoords({ + ...linearElement, + points: newPoints, + }), + ); + } + + return { + linearElement, + startBoundElement, + endBoundElement, + }; +}; + +const adjustElbowArrowPoints = (elements: ExcalidrawElement[]) => { + const elementsMap = arrayToMap(elements) as NonDeletedSceneElementsMap; + const scene = new Scene(elementsMap); + + elements.forEach((element) => { + if (isElbowArrow(element) && (element.startBinding || element.endBinding)) { + if (element.endBinding && element.endBinding.elementId) { + const midPoint = getBindingSideMidPoint( + element.endBinding, + elementsMap, + ); + + const endBindableElement = elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement; + + if (midPoint) { + LinearElementEditor.movePoints( + element, + scene, + new Map([ + [ + element.points.length - 1, + { + point: pointFrom( + midPoint[0] - element.x, + midPoint[1] - element.y, + ), + isDragging: true, + }, + ], + ]), + ); + } + } + + if (element.startBinding && element.startBinding.elementId) { + const midPoint = getBindingSideMidPoint( + element.startBinding, + elementsMap, + ); + + const startBindableElement = elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement; + + if (midPoint) { + LinearElementEditor.movePoints( + element, + scene, + new Map([ + [ + 0, + { + point: pointFrom( + midPoint[0] - element.x, + midPoint[1] - element.y, + ), + isDragging: true, + }, + ], + ]), + ); + } + } + } + }); +}; + export const convertToExcalidrawElements = ( elementsSkeleton: ExcalidrawElementSkeleton[] | null, opts?: { regenerateIds: boolean; useElbow?: boolean }, @@ -561,20 +611,32 @@ export const convertToExcalidrawElements = ( 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", - elbowed: opts?.useElbow, - }); - Object.assign( - excalidrawElement, - getSizeFromPoints(excalidrawElement.points), - ); + if (!opts?.useElbow) { + excalidrawElement = newArrowElement({ + width, + height, + endArrowhead: "arrow", + points: [pointFrom(0, 0), pointFrom(width, height)], + ...element, + type: "arrow", + elbowed: opts?.useElbow, + }); + Object.assign( + excalidrawElement, + getSizeFromPoints(excalidrawElement.points), + ); + } else { + excalidrawElement = newArrowElement({ + width, + height, + endArrowhead: "arrow", + ...element, + type: "arrow", + elbowed: opts?.useElbow, + roundness: null, + }); + } break; } case "text": { @@ -806,5 +868,12 @@ export const convertToExcalidrawElements = ( } } - return elementStore.getElements(); + const finalElements = elementStore.getElements(); + + // Adjust elbow arrow points now that all elements are in the scene + if (opts?.useElbow) { + adjustElbowArrowPoints(finalElements); + } + + return finalElements; };