feat: bind to mid point

This commit is contained in:
Ryan Di
2025-05-26 10:04:22 +10:00
parent 6eb93b281a
commit 5516e7c819
6 changed files with 778 additions and 256 deletions

View File

@@ -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);
};

View File

@@ -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 {

View File

@@ -22,8 +22,6 @@ import {
isDevEnv,
} from "@excalidraw/common";
import { debugDrawBounds } from "@excalidraw/utils/visualdebug";
import type { AppState } from "@excalidraw/excalidraw/types";
import {