mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-18 11:44:23 +01:00
feat: bind to mid point
This commit is contained in:
@@ -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 = <T extends FixedPoint | null>(
|
||||
}
|
||||
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<ShapeType, SectorConfig[]> = {
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
isDevEnv,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||
...linearElement,
|
||||
points: newPoints,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
@@ -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<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,
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user