tldraw
Version:
A tiny little drawing editor.
315 lines (314 loc) • 12.1 kB
JavaScript
import {
Mat,
PI,
PI2,
Vec,
centerOfCircleFromThreePoints,
clockwiseAngleDist,
counterClockwiseAngleDist,
isSafeFloat
} from "@tldraw/editor";
import {
BOUND_ARROW_OFFSET,
MIN_ARROW_LENGTH,
STROKE_SIZES,
WAY_TOO_BIG_ARROW_BEND_FACTOR,
getArrowTerminalsInArrowSpace,
getBoundShapeInfoForTerminal,
getBoundShapeRelationships
} from "./shared.mjs";
import { getStraightArrowInfo } from "./straight-arrow.mjs";
function getCurvedArrowInfo(editor, shape, bindings) {
const { arrowheadEnd, arrowheadStart } = shape.props;
const bend = shape.props.bend;
if (Math.abs(bend) > Math.abs(shape.props.bend * (WAY_TOO_BIG_ARROW_BEND_FACTOR * shape.props.scale))) {
return getStraightArrowInfo(editor, shape, bindings);
}
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(editor, shape, bindings);
const med = Vec.Med(terminalsInArrowSpace.start, terminalsInArrowSpace.end);
const distance = Vec.Sub(terminalsInArrowSpace.end, terminalsInArrowSpace.start);
const u = Vec.Len(distance) ? distance.uni() : Vec.From(distance);
const middle = Vec.Add(med, u.per().mul(-bend));
const startShapeInfo = getBoundShapeInfoForTerminal(editor, shape, "start");
const endShapeInfo = getBoundShapeInfoForTerminal(editor, shape, "end");
const a = terminalsInArrowSpace.start.clone();
const b = terminalsInArrowSpace.end.clone();
const c = middle.clone();
if (Vec.Equals(a, b)) {
return {
bindings,
type: "straight",
start: {
handle: a,
point: a,
arrowhead: shape.props.arrowheadStart
},
end: {
handle: b,
point: b,
arrowhead: shape.props.arrowheadEnd
},
middle: c,
isValid: false,
length: 0
};
}
const isClockwise = shape.props.bend < 0;
const distFn = isClockwise ? clockwiseAngleDist : counterClockwiseAngleDist;
const handleArc = getArcInfo(a, b, c);
const handle_aCA = Vec.Angle(handleArc.center, a);
const handle_aCB = Vec.Angle(handleArc.center, b);
const handle_dAB = distFn(handle_aCA, handle_aCB);
if (handleArc.length === 0 || handleArc.size === 0 || !isSafeFloat(handleArc.length) || !isSafeFloat(handleArc.size)) {
return getStraightArrowInfo(editor, shape, bindings);
}
const tempA = a.clone();
const tempB = b.clone();
const tempC = c.clone();
const arrowPageTransform = editor.getShapePageTransform(shape);
let offsetA = 0;
let offsetB = 0;
let minLength = MIN_ARROW_LENGTH * shape.props.scale;
if (startShapeInfo && !startShapeInfo.isExact) {
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA);
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center);
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB);
const inverseTransform = Mat.Inverse(startShapeInfo.transform);
const startInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace);
const centerInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace);
const endInStartShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace);
const { isClosed } = startShapeInfo;
let point;
let intersections = Array.from(
startShapeInfo.geometry.intersectCircle(centerInStartShapeLocalSpace, handleArc.radius, {
includeLabels: false,
includeInternal: false
})
);
if (intersections.length) {
const angleToStart = centerInStartShapeLocalSpace.angle(startInStartShapeLocalSpace);
const angleToEnd = centerInStartShapeLocalSpace.angle(endInStartShapeLocalSpace);
const dAB2 = distFn(angleToStart, angleToEnd);
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInStartShapeLocalSpace.angle(pt)) <= dAB2
);
const targetDist = dAB2 * 0.25;
intersections.sort(
isClosed ? (p0, p1) => Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) - targetDist) < Math.abs(distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) - targetDist) ? -1 : 1 : (p0, p1) => distFn(angleToStart, centerInStartShapeLocalSpace.angle(p0)) < distFn(angleToStart, centerInStartShapeLocalSpace.angle(p1)) ? -1 : 1
);
point = intersections[0];
}
if (!point) {
if (isClosed) {
const nearestPoint = startShapeInfo.geometry.nearestPoint(startInStartShapeLocalSpace);
if (Vec.DistMin(nearestPoint, startInStartShapeLocalSpace, 1)) {
point = nearestPoint;
}
} else {
point = startInStartShapeLocalSpace;
}
}
if (point) {
tempA.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(startShapeInfo.transform, point))
);
startShapeInfo.didIntersect = true;
if (arrowheadStart !== "none") {
const strokeOffset = STROKE_SIZES[shape.props.size] / 2 + ("size" in startShapeInfo.shape.props ? STROKE_SIZES[startShapeInfo.shape.props.size] / 2 : 0);
offsetA = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale;
minLength += strokeOffset * shape.props.scale;
}
}
}
if (endShapeInfo && !endShapeInfo.isExact) {
const startInPageSpace = Mat.applyToPoint(arrowPageTransform, tempA);
const endInPageSpace = Mat.applyToPoint(arrowPageTransform, tempB);
const centerInPageSpace = Mat.applyToPoint(arrowPageTransform, handleArc.center);
const inverseTransform = Mat.Inverse(endShapeInfo.transform);
const startInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, startInPageSpace);
const centerInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, centerInPageSpace);
const endInEndShapeLocalSpace = Mat.applyToPoint(inverseTransform, endInPageSpace);
const isClosed = endShapeInfo.isClosed;
let point;
let intersections = Array.from(
endShapeInfo.geometry.intersectCircle(centerInEndShapeLocalSpace, handleArc.radius, {
includeLabels: false,
includeInternal: false
})
);
if (intersections.length) {
const angleToStart = centerInEndShapeLocalSpace.angle(startInEndShapeLocalSpace);
const angleToEnd = centerInEndShapeLocalSpace.angle(endInEndShapeLocalSpace);
const dAB2 = distFn(angleToStart, angleToEnd);
const targetDist = dAB2 * 0.75;
intersections = intersections.filter(
(pt) => distFn(angleToStart, centerInEndShapeLocalSpace.angle(pt)) <= dAB2
);
intersections.sort(
isClosed ? (p0, p1) => Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) - targetDist) < Math.abs(distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) - targetDist) ? -1 : 1 : (p0, p1) => distFn(angleToStart, centerInEndShapeLocalSpace.angle(p0)) < distFn(angleToStart, centerInEndShapeLocalSpace.angle(p1)) ? -1 : 1
);
point = intersections[0];
}
if (!point) {
if (isClosed) {
const nearestPoint = endShapeInfo.geometry.nearestPoint(endInEndShapeLocalSpace);
if (Vec.DistMin(nearestPoint, endInEndShapeLocalSpace, 1)) {
point = nearestPoint;
}
} else {
point = endInEndShapeLocalSpace;
}
}
if (point) {
tempB.setTo(
editor.getPointInShapeSpace(shape, Mat.applyToPoint(endShapeInfo.transform, point))
);
endShapeInfo.didIntersect = true;
if (arrowheadEnd !== "none") {
const strokeOffset = STROKE_SIZES[shape.props.size] / 2 + ("size" in endShapeInfo.shape.props ? STROKE_SIZES[endShapeInfo.shape.props.size] / 2 : 0);
offsetB = (BOUND_ARROW_OFFSET + strokeOffset) * shape.props.scale;
minLength += strokeOffset * shape.props.scale;
}
}
}
let aCA = Vec.Angle(handleArc.center, tempA);
let aCB = Vec.Angle(handleArc.center, tempB);
let dAB = distFn(aCA, aCB);
let lAB = dAB * handleArc.radius;
const tA = tempA.clone();
const tB = tempB.clone();
if (offsetA !== 0) {
tA.setTo(handleArc.center).add(
Vec.FromAngle(aCA + dAB * (offsetA / lAB * (isClockwise ? 1 : -1))).mul(handleArc.radius)
);
}
if (offsetB !== 0) {
tB.setTo(handleArc.center).add(
Vec.FromAngle(aCB + dAB * (offsetB / lAB * (isClockwise ? -1 : 1))).mul(handleArc.radius)
);
}
if (Vec.DistMin(tA, tB, minLength)) {
if (offsetA !== 0 && offsetB !== 0) {
offsetA *= -1.5;
offsetB *= -1.5;
} else if (offsetA !== 0) {
offsetA *= -2;
} else if (offsetB !== 0) {
offsetB *= -2;
} else {
}
const minOffsetA = 0.1 - distFn(handle_aCA, aCA) * handleArc.radius;
const minOffsetB = 0.1 - distFn(aCB, handle_aCB) * handleArc.radius;
offsetA = Math.max(offsetA, minOffsetA);
offsetB = Math.max(offsetB, minOffsetB);
}
if (offsetA !== 0) {
tempA.setTo(handleArc.center).add(
Vec.FromAngle(aCA + dAB * (offsetA / lAB * (isClockwise ? 1 : -1))).mul(handleArc.radius)
);
}
if (offsetB !== 0) {
tempB.setTo(handleArc.center).add(
Vec.FromAngle(aCB + dAB * (offsetB / lAB * (isClockwise ? -1 : 1))).mul(handleArc.radius)
);
}
if (startShapeInfo && endShapeInfo && !startShapeInfo.isExact && !endShapeInfo.isExact) {
aCA = Vec.Angle(handleArc.center, tempA);
aCB = Vec.Angle(handleArc.center, tempB);
dAB = distFn(aCA, aCB);
lAB = dAB * handleArc.radius;
const relationship = getBoundShapeRelationships(
editor,
startShapeInfo.shape.id,
endShapeInfo.shape.id
);
if (relationship === "double-bound" && lAB < 30) {
tempA.setTo(a);
tempB.setTo(b);
tempC.setTo(c);
} else if (relationship === "safe") {
if (startShapeInfo && !startShapeInfo.didIntersect) {
tempA.setTo(a);
}
if (endShapeInfo && !endShapeInfo.didIntersect || distFn(handle_aCA, aCA) > distFn(handle_aCA, aCB)) {
tempB.setTo(handleArc.center).add(
Vec.FromAngle(
aCA + dAB * (Math.min(0.9, MIN_ARROW_LENGTH * shape.props.scale / lAB) * (isClockwise ? 1 : -1))
).mul(handleArc.radius)
);
}
}
}
placeCenterHandle(
handleArc.center,
handleArc.radius,
tempA,
tempB,
tempC,
handle_dAB,
isClockwise
);
if (tempA.equals(tempB)) {
tempA.setTo(tempC.clone().addXY(1, 1));
tempB.setTo(tempC.clone().subXY(1, 1));
}
a.setTo(tempA);
b.setTo(tempB);
c.setTo(tempC);
const bodyArc = getArcInfo(a, b, c);
return {
bindings,
type: "arc",
start: {
point: a,
handle: terminalsInArrowSpace.start,
arrowhead: shape.props.arrowheadStart
},
end: {
point: b,
handle: terminalsInArrowSpace.end,
arrowhead: shape.props.arrowheadEnd
},
middle: c,
handleArc,
bodyArc,
isValid: bodyArc.length !== 0 && isFinite(bodyArc.center.x) && isFinite(bodyArc.center.y)
};
}
function getArcInfo(a, b, c) {
const center = centerOfCircleFromThreePoints(a, b, c) ?? Vec.Med(a, b);
const radius = Vec.Dist(center, a);
const sweepFlag = +Vec.Clockwise(a, c, b);
const ab = ((a.y - b.y) ** 2 + (a.x - b.x) ** 2) ** 0.5;
const bc = ((b.y - c.y) ** 2 + (b.x - c.x) ** 2) ** 0.5;
const ca = ((c.y - a.y) ** 2 + (c.x - a.x) ** 2) ** 0.5;
const theta = Math.acos((bc * bc + ca * ca - ab * ab) / (2 * bc * ca)) * 2;
const largeArcFlag = +(PI > theta);
const size = (PI2 - theta) * (sweepFlag ? 1 : -1);
const length = size * radius;
return {
center,
radius,
size,
length,
largeArcFlag,
sweepFlag
};
}
function placeCenterHandle(center, radius, tempA, tempB, tempC, originalArcLength, isClockwise) {
const aCA = Vec.Angle(center, tempA);
const aCB = Vec.Angle(center, tempB);
let dAB = clockwiseAngleDist(aCA, aCB);
if (!isClockwise) dAB = PI2 - dAB;
tempC.setTo(center).add(Vec.FromAngle(aCA + dAB * (0.5 * (isClockwise ? 1 : -1))).mul(radius));
if (dAB > originalArcLength) {
tempC.rotWith(center, PI);
const t = tempB.clone();
tempB.setTo(tempA);
tempA.setTo(t);
}
}
export {
getCurvedArrowInfo
};
//# sourceMappingURL=curved-arrow.mjs.map