tldraw
Version:
A tiny little drawing editor.
1,065 lines (1,064 loc) • 38.9 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import {
Arc2d,
Box,
EMPTY_ARRAY,
Edge2d,
Group2d,
PI2,
Polyline2d,
Rectangle2d,
SVGContainer,
ShapeUtil,
Vec,
WeakCache,
arrowShapeMigrations,
arrowShapeProps,
clamp,
debugFlags,
exhaustiveSwitchError,
getColorValue,
getDefaultColorTheme,
getFontsFromRichText,
invLerp,
lerp,
mapObjectMapValues,
maybeSnapToGrid,
structuredClone,
toDomPrecision,
toRichText,
track,
useEditor,
useIsEditing,
useSharedSafeId,
useValue
} from "@tldraw/editor";
import { useMemo } from "react";
import { updateArrowTerminal } from "../../bindings/arrow/ArrowBindingUtil.mjs";
import { isEmptyRichText, renderPlaintextFromRichText } from "../../utils/text/richText.mjs";
import { PathBuilder } from "../shared/PathBuilder.mjs";
import { RichTextLabel, RichTextSVG } from "../shared/RichTextLabel.mjs";
import { ShapeFill } from "../shared/ShapeFill.mjs";
import { ARROW_LABEL_PADDING, STROKE_SIZES, TEXT_PROPS } from "../shared/default-shape-constants.mjs";
import { getFillDefForCanvas, getFillDefForExport } from "../shared/defaultStyleDefs.mjs";
import { useDefaultColorTheme } from "../shared/useDefaultColorTheme.mjs";
import { useEfficientZoomThreshold } from "../shared/useEfficientZoomThreshold.mjs";
import { getArrowBodyPath, getArrowBodyPathBuilder, getArrowHandlePath } from "./ArrowPath.mjs";
import {
getArrowLabelDefaultPosition,
getArrowLabelFontSize,
getArrowLabelPosition
} from "./arrowLabel.mjs";
import { updateArrowTargetState } from "./arrowTargetState.mjs";
import { getArrowheadPathForType } from "./arrowheads.mjs";
import { ElbowArrowDebug } from "./elbow/ElbowArrowDebug.mjs";
import { ElbowArrowAxes } from "./elbow/definitions.mjs";
import { getElbowArrowSnapLines, perpDistanceToLineAngle } from "./elbow/elbowArrowSnapLines.mjs";
import { getArrowInfo } from "./getArrowInfo.mjs";
import {
createOrUpdateArrowBinding,
getArrowBindings,
getArrowTerminalsInArrowSpace,
removeArrowBinding
} from "./shared.mjs";
const ArrowHandles = {
Start: "start",
Middle: "middle",
End: "end"
};
class ArrowShapeUtil extends ShapeUtil {
static type = "arrow";
static props = arrowShapeProps;
static migrations = arrowShapeMigrations;
options = {
expandElbowLegLength: {
s: 28,
m: 36,
l: 44,
xl: 66
},
minElbowLegLength: {
s: STROKE_SIZES.s * 3,
m: STROKE_SIZES.m * 3,
l: STROKE_SIZES.l * 3,
xl: STROKE_SIZES.xl * 3
},
minElbowHandleDistance: 16,
arcArrowCenterSnapDistance: 16,
elbowArrowCenterSnapDistance: 24,
elbowArrowEdgeSnapDistance: 20,
elbowArrowPointSnapDistance: 24,
elbowArrowAxisSnapDistance: 16,
labelCenterSnapDistance: 10,
elbowMidpointSnapDistance: 10,
elbowMinSegmentLengthToShowMidpointHandle: 20,
hoverPreciseTimeout: 600,
pointingPreciseTimeout: 320,
shouldBeExact(editor) {
return editor.inputs.getAltKey();
},
shouldIgnoreTargets(editor) {
return editor.inputs.getCtrlKey();
},
showTextOutline: true
};
canEdit() {
return true;
}
canBind({ toShape }) {
return toShape.type !== "arrow";
}
canSnap() {
return false;
}
hideResizeHandles() {
return true;
}
hideRotateHandle() {
return true;
}
hideSelectionBoundsBg() {
return true;
}
hideSelectionBoundsFg() {
return true;
}
hideInMinimap() {
return true;
}
canBeLaidOut(shape, info) {
if (info.type === "flip") {
const bindings = getArrowBindings(this.editor, shape);
const { start, end } = bindings;
const { shapes = [] } = info;
if (start && !shapes.find((s) => s.id === start.toId)) return false;
if (end && !shapes.find((s) => s.id === end.toId)) return false;
}
return true;
}
getFontFaces(shape) {
if (isEmptyRichText(shape.props.richText)) return EMPTY_ARRAY;
return getFontsFromRichText(this.editor, shape.props.richText, {
family: `tldraw_${shape.props.font}`,
weight: "normal",
style: "normal"
});
}
getDefaultProps() {
return {
kind: "arc",
elbowMidPoint: 0.5,
dash: "draw",
size: "m",
fill: "none",
color: "black",
labelColor: "black",
bend: 0,
start: { x: 0, y: 0 },
end: { x: 2, y: 0 },
arrowheadStart: "none",
arrowheadEnd: "arrow",
richText: toRichText(""),
labelPosition: 0.5,
font: "draw",
scale: 1
};
}
getGeometry(shape) {
const isEditing = this.editor.getEditingShapeId() === shape.id;
const info = getArrowInfo(this.editor, shape);
const debugGeom = [];
const bodyGeom = info.type === "straight" ? new Edge2d({
start: Vec.From(info.start.point),
end: Vec.From(info.end.point)
}) : info.type === "arc" ? new Arc2d({
center: Vec.Cast(info.handleArc.center),
start: Vec.Cast(info.start.point),
end: Vec.Cast(info.end.point),
sweepFlag: info.bodyArc.sweepFlag,
largeArcFlag: info.bodyArc.largeArcFlag
}) : new Polyline2d({ points: info.route.points });
let labelGeom;
if (info.isValid && (isEditing || !isEmptyRichText(shape.props.richText))) {
const labelPosition = getArrowLabelPosition(this.editor, shape, isEditing);
if (debugFlags.debugGeometry.get()) {
debugGeom.push(...labelPosition.debugGeom);
}
labelGeom = new Rectangle2d({
x: labelPosition.box.x,
y: labelPosition.box.y,
width: labelPosition.box.w,
height: labelPosition.box.h,
isFilled: true,
isLabel: true
});
}
return new Group2d({
children: [...(labelGeom ? [bodyGeom, labelGeom] : [bodyGeom]), ...debugGeom]
});
}
getHandles(shape) {
const info = getArrowInfo(this.editor, shape);
const handles = [
{
id: ArrowHandles.Start,
type: "vertex",
index: "a1",
x: info.start.handle.x,
y: info.start.handle.y
},
{
id: ArrowHandles.End,
type: "vertex",
index: "a3",
x: info.end.handle.x,
y: info.end.handle.y
}
];
if (shape.props.kind === "arc" && (info.type === "straight" || info.type === "arc")) {
handles.push({
id: ArrowHandles.Middle,
type: "virtual",
index: "a2",
x: info.middle.x,
y: info.middle.y
});
}
if (shape.props.kind === "elbow" && info.type === "elbow" && info.route.midpointHandle) {
const shapePageTransform = this.editor.getShapePageTransform(shape.id);
const segmentStart = shapePageTransform.applyToPoint(info.route.midpointHandle.segmentStart);
const segmentEnd = shapePageTransform.applyToPoint(info.route.midpointHandle.segmentEnd);
const segmentLength = Vec.Dist(segmentStart, segmentEnd) * this.editor.getEfficientZoomLevel();
if (segmentLength > this.options.elbowMinSegmentLengthToShowMidpointHandle) {
handles.push({
id: ArrowHandles.Middle,
type: "vertex",
index: "a2",
x: info.route.midpointHandle.point.x,
y: info.route.midpointHandle.point.y
});
}
}
return handles;
}
getText(shape) {
return renderPlaintextFromRichText(this.editor, shape.props.richText);
}
onHandleDrag(shape, info) {
const handleId = info.handle.id;
switch (handleId) {
case ArrowHandles.Middle:
switch (shape.props.kind) {
case "arc":
return this.onArcMidpointHandleDrag(shape, info);
case "elbow":
return this.onElbowMidpointHandleDrag(shape, info);
default:
exhaustiveSwitchError(shape.props.kind);
}
case ArrowHandles.Start:
case ArrowHandles.End:
return this.onTerminalHandleDrag(shape, info, handleId);
default:
exhaustiveSwitchError(handleId);
}
}
onArcMidpointHandleDrag(shape, { handle }) {
const bindings = getArrowBindings(this.editor, shape);
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape, bindings);
const delta = Vec.Sub(end, start);
const v = Vec.Per(delta);
const med = Vec.Med(end, start);
const A = Vec.Sub(med, v);
const B = Vec.Add(med, v);
const point = Vec.NearestPointOnLineSegment(A, B, handle, false);
let bend = Vec.Dist(point, med);
if (Vec.Clockwise(point, end, med)) bend *= -1;
return { id: shape.id, type: shape.type, props: { bend } };
}
onElbowMidpointHandleDrag(shape, { handle }) {
const info = getArrowInfo(this.editor, shape);
if (info?.type !== "elbow") return;
const shapeToPageTransform = this.editor.getShapePageTransform(shape.id);
const handlePagePoint = shapeToPageTransform.applyToPoint(handle);
const axisName = info.route.midpointHandle?.axis;
if (!axisName) return;
const axis = ElbowArrowAxes[axisName];
const midRange = info.elbow[axis.midRange];
if (!midRange) return;
let angle = Vec.Angle(
shapeToPageTransform.applyToPoint(axis.v(0, 0)),
shapeToPageTransform.applyToPoint(axis.v(0, 1))
);
if (angle < 0) angle += Math.PI;
const handlePoint = perpDistanceToLineAngle(handlePagePoint, angle);
const loPoint = perpDistanceToLineAngle(
shapeToPageTransform.applyToPoint(axis.v(midRange.lo, 0)),
angle
);
const hiPoint = perpDistanceToLineAngle(
shapeToPageTransform.applyToPoint(axis.v(midRange.hi, 0)),
angle
);
const maxSnapDistance = this.options.elbowMidpointSnapDistance / this.editor.getEfficientZoomLevel();
const midPoint = perpDistanceToLineAngle(
shapeToPageTransform.applyToPoint(axis.v(lerp(midRange.lo, midRange.hi, 0.5), 0)),
angle
);
let snapPoint = midPoint;
let snapDistance = Math.abs(midPoint - handlePoint);
for (const [snapAngle, snapLines] of getElbowArrowSnapLines(this.editor)) {
const { isParallel, isFlippedParallel } = anglesAreApproximatelyParallel(angle, snapAngle);
if (isParallel || isFlippedParallel) {
for (const snapLine of snapLines) {
const doesShareStartIntersection = snapLine.startBoundShapeId && (snapLine.startBoundShapeId === info.bindings.start?.toId || snapLine.startBoundShapeId === info.bindings.end?.toId);
const doesShareEndIntersection = snapLine.endBoundShapeId && (snapLine.endBoundShapeId === info.bindings.start?.toId || snapLine.endBoundShapeId === info.bindings.end?.toId);
if (!doesShareStartIntersection && !doesShareEndIntersection) continue;
const point = isFlippedParallel ? -snapLine.perpDistance : snapLine.perpDistance;
const distance = Math.abs(point - handlePoint);
if (distance < snapDistance) {
snapPoint = point;
snapDistance = distance;
}
}
}
}
if (snapDistance > maxSnapDistance) {
snapPoint = handlePoint;
}
const newMid = clamp(invLerp(loPoint, hiPoint, snapPoint), 0, 1);
return {
id: shape.id,
type: shape.type,
props: {
elbowMidPoint: newMid
}
};
}
onTerminalHandleDrag(shape, { handle, isPrecise }, handleId) {
const bindings = getArrowBindings(this.editor, shape);
const update = { id: shape.id, type: "arrow", props: {} };
const currentBinding = bindings[handleId];
const oppositeHandleId = handleId === ArrowHandles.Start ? ArrowHandles.End : ArrowHandles.Start;
const oppositeBinding = bindings[oppositeHandleId];
const targetInfo = updateArrowTargetState({
editor: this.editor,
pointInPageSpace: this.editor.getShapePageTransform(shape.id).applyToPoint(handle),
arrow: shape,
isPrecise,
currentBinding,
oppositeBinding
});
if (!targetInfo) {
removeArrowBinding(this.editor, shape, handleId);
const newPoint = maybeSnapToGrid(new Vec(handle.x, handle.y), this.editor);
update.props[handleId] = {
x: newPoint.x,
y: newPoint.y
};
return update;
}
const bindingProps = {
terminal: handleId,
normalizedAnchor: targetInfo.normalizedAnchor,
isPrecise: targetInfo.isPrecise,
isExact: targetInfo.isExact,
snap: targetInfo.snap
};
createOrUpdateArrowBinding(this.editor, shape, targetInfo.target.id, bindingProps);
const newBindings = getArrowBindings(this.editor, shape);
if (newBindings.start && newBindings.end && newBindings.start.toId === newBindings.end.toId) {
if (Vec.Equals(newBindings.start.props.normalizedAnchor, newBindings.end.props.normalizedAnchor)) {
createOrUpdateArrowBinding(this.editor, shape, newBindings.end.toId, {
...newBindings.end.props,
normalizedAnchor: {
x: newBindings.end.props.normalizedAnchor.x + 0.05,
y: newBindings.end.props.normalizedAnchor.y
}
});
}
}
return update;
}
onTranslateStart(shape) {
const bindings = getArrowBindings(this.editor, shape);
if (shape.props.kind === "elbow" && this.editor.getOnlySelectedShapeId() === shape.id) {
const info = getArrowInfo(this.editor, shape);
if (!info) return;
const update = { id: shape.id, type: "arrow", props: {} };
if (bindings.start) {
update.props.start = { x: info.start.point.x, y: info.start.point.y };
removeArrowBinding(this.editor, shape, "start");
}
if (bindings.end) {
update.props.end = { x: info.end.point.x, y: info.end.point.y };
removeArrowBinding(this.editor, shape, "end");
}
return update;
}
const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(this.editor, shape, bindings);
const shapePageTransform = this.editor.getShapePageTransform(shape.id);
const selectedShapeIds = this.editor.getSelectedShapeIds();
if (bindings.start && (selectedShapeIds.includes(bindings.start.toId) || this.editor.isAncestorSelected(bindings.start.toId)) || bindings.end && (selectedShapeIds.includes(bindings.end.toId) || this.editor.isAncestorSelected(bindings.end.toId))) {
return;
}
shapeAtTranslationStart.set(shape, {
pagePosition: shapePageTransform.applyToPoint(shape),
terminalBindings: mapObjectMapValues(terminalsInArrowSpace, (terminalName, point) => {
const binding = bindings[terminalName];
if (!binding) return null;
return {
binding,
shapePosition: point,
pagePosition: shapePageTransform.applyToPoint(point)
};
})
});
if (bindings.start) {
updateArrowTerminal({
editor: this.editor,
arrow: shape,
terminal: "start",
useHandle: true
});
shape = this.editor.getShape(shape.id);
}
if (bindings.end) {
updateArrowTerminal({
editor: this.editor,
arrow: shape,
terminal: "end",
useHandle: true
});
}
for (const handleName of [ArrowHandles.Start, ArrowHandles.End]) {
const binding = bindings[handleName];
if (!binding) continue;
this.editor.updateBinding({
...binding,
props: { ...binding.props, isPrecise: true }
});
}
return;
}
onTranslate(initialShape, shape) {
const atTranslationStart = shapeAtTranslationStart.get(initialShape);
if (!atTranslationStart) return;
const shapePageTransform = this.editor.getShapePageTransform(shape.id);
const pageDelta = Vec.Sub(
shapePageTransform.applyToPoint(shape),
atTranslationStart.pagePosition
);
for (const terminalBinding of Object.values(atTranslationStart.terminalBindings)) {
if (!terminalBinding) continue;
const newPagePoint = Vec.Add(terminalBinding.pagePosition, Vec.Mul(pageDelta, 0.5));
const newTarget = this.editor.getShapeAtPoint(newPagePoint, {
hitInside: true,
hitFrameInside: true,
margin: 0,
filter: (targetShape) => {
return !targetShape.isLocked && this.editor.canBindShapes({ fromShape: shape, toShape: targetShape, binding: "arrow" });
}
});
if (newTarget?.id === terminalBinding.binding.toId) {
const targetBounds = Box.ZeroFix(this.editor.getShapeGeometry(newTarget).bounds);
const pointInTargetSpace = this.editor.getPointInShapeSpace(newTarget, newPagePoint);
const normalizedAnchor = {
x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width,
y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height
};
createOrUpdateArrowBinding(this.editor, shape, newTarget.id, {
...terminalBinding.binding.props,
normalizedAnchor,
isPrecise: true
});
} else {
removeArrowBinding(this.editor, shape, terminalBinding.binding.props.terminal);
}
}
}
_resizeInitialBindings = new WeakCache();
onResize(shape, info) {
const { scaleX, scaleY } = info;
const bindings = this._resizeInitialBindings.get(
shape,
() => getArrowBindings(this.editor, shape)
);
const terminals = getArrowTerminalsInArrowSpace(this.editor, shape, bindings);
const { start, end } = structuredClone(shape.props);
let { bend } = shape.props;
if (!bindings.start) {
start.x = terminals.start.x * scaleX;
start.y = terminals.start.y * scaleY;
}
if (!bindings.end) {
end.x = terminals.end.x * scaleX;
end.y = terminals.end.y * scaleY;
}
const mx = Math.abs(scaleX);
const my = Math.abs(scaleY);
const startNormalizedAnchor = bindings?.start ? Vec.From(bindings.start.props.normalizedAnchor) : null;
const endNormalizedAnchor = bindings?.end ? Vec.From(bindings.end.props.normalizedAnchor) : null;
if (scaleX < 0 && scaleY >= 0) {
if (bend !== 0) {
bend *= -1;
bend *= Math.max(mx, my);
}
if (startNormalizedAnchor) {
startNormalizedAnchor.x = 1 - startNormalizedAnchor.x;
}
if (endNormalizedAnchor) {
endNormalizedAnchor.x = 1 - endNormalizedAnchor.x;
}
} else if (scaleX >= 0 && scaleY < 0) {
if (bend !== 0) {
bend *= -1;
bend *= Math.max(mx, my);
}
if (startNormalizedAnchor) {
startNormalizedAnchor.y = 1 - startNormalizedAnchor.y;
}
if (endNormalizedAnchor) {
endNormalizedAnchor.y = 1 - endNormalizedAnchor.y;
}
} else if (scaleX >= 0 && scaleY >= 0) {
if (bend !== 0) {
bend *= Math.max(mx, my);
}
} else if (scaleX < 0 && scaleY < 0) {
if (bend !== 0) {
bend *= Math.max(mx, my);
}
if (startNormalizedAnchor) {
startNormalizedAnchor.x = 1 - startNormalizedAnchor.x;
startNormalizedAnchor.y = 1 - startNormalizedAnchor.y;
}
if (endNormalizedAnchor) {
endNormalizedAnchor.x = 1 - endNormalizedAnchor.x;
endNormalizedAnchor.y = 1 - endNormalizedAnchor.y;
}
}
if (bindings.start && startNormalizedAnchor) {
createOrUpdateArrowBinding(this.editor, shape, bindings.start.toId, {
...bindings.start.props,
normalizedAnchor: startNormalizedAnchor.toJson()
});
}
if (bindings.end && endNormalizedAnchor) {
createOrUpdateArrowBinding(this.editor, shape, bindings.end.toId, {
...bindings.end.props,
normalizedAnchor: endNormalizedAnchor.toJson()
});
}
const next = {
props: {
start,
end,
bend
}
};
return next;
}
onDoubleClickHandle(shape, handle) {
switch (handle.id) {
case ArrowHandles.Start: {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
arrowheadStart: shape.props.arrowheadStart === "none" ? "arrow" : "none"
}
};
}
case ArrowHandles.End: {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
arrowheadEnd: shape.props.arrowheadEnd === "none" ? "arrow" : "none"
}
};
}
}
}
component(shape) {
const { editor } = this;
const theme = useDefaultColorTheme();
const shouldDisplayHandles = useValue(
"should display handles",
() => {
const { editor: editor2 } = this;
return !editor2.getIsReadonly() && editor2.getOnlySelectedShapeId() === shape.id && editor2.isInAny(
"select.idle",
"select.pointing_handle",
"select.dragging_handle",
"select.translating",
"arrow.dragging"
);
},
[editor, shape.id]
);
const isSelected = useValue(
"is selected",
() => editor.getOnlySelectedShape()?.id === shape.id,
[editor, shape.id]
);
const isEditing = useValue("is editing", () => editor.getEditingShapeId() === shape.id, [
editor,
shape.id
]);
const info = getArrowInfo(editor, shape);
if (!info?.isValid) return null;
const labelPosition = getArrowLabelPosition(editor, shape, isEditing);
const showArrowLabel = isEditing || !isEmptyRichText(shape.props.richText);
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsxs(SVGContainer, { style: { minWidth: 50, minHeight: 50 }, children: [
/* @__PURE__ */ jsx(ArrowSvg, { shape, shouldDisplayHandles }),
shape.props.kind === "elbow" && debugFlags.debugElbowArrows.get() && /* @__PURE__ */ jsx(ElbowArrowDebug, { arrow: shape })
] }),
showArrowLabel && /* @__PURE__ */ jsx(
RichTextLabel,
{
shapeId: shape.id,
type: "arrow",
font: shape.props.font,
fontSize: getArrowLabelFontSize(shape),
lineHeight: TEXT_PROPS.lineHeight,
align: "middle",
verticalAlign: "middle",
labelColor: getColorValue(theme, shape.props.labelColor, "solid"),
richText: shape.props.richText,
textWidth: labelPosition.box.w - ARROW_LABEL_PADDING * 2 * shape.props.scale,
isSelected,
padding: 0,
showTextOutline: this.options.showTextOutline,
style: {
transform: `translate(${labelPosition.box.center.x}px, ${labelPosition.box.center.y}px)`
}
}
)
] });
}
indicator(shape) {
const isEditing = useIsEditing(shape.id);
const clipPathId = useSharedSafeId(shape.id + "_clip");
const info = getArrowInfo(this.editor, shape);
if (!info) return null;
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape, info?.bindings);
const geometry = this.editor.getShapeGeometry(shape);
const bounds = geometry.bounds;
const isEmpty = isEmptyRichText(shape.props.richText);
const labelGeometry = isEditing || !isEmpty ? geometry.children[1] : null;
if (Vec.Equals(start, end)) return null;
const strokeWidth = STROKE_SIZES[shape.props.size] * shape.props.scale;
const as = info.start.arrowhead && getArrowheadPathForType(info, "start", strokeWidth);
const ae = info.end.arrowhead && getArrowheadPathForType(info, "end", strokeWidth);
const includeClipPath = as && info.start.arrowhead !== "arrow" || ae && info.end.arrowhead !== "arrow" || !!labelGeometry;
const labelBounds = labelGeometry ? labelGeometry.getBounds() : new Box(0, 0, 0, 0);
if (isEditing && labelGeometry) {
return /* @__PURE__ */ jsx(
"rect",
{
x: toDomPrecision(labelBounds.x),
y: toDomPrecision(labelBounds.y),
width: labelBounds.w,
height: labelBounds.h,
rx: 3.5 * shape.props.scale,
ry: 3.5 * shape.props.scale
}
);
}
const clipStartArrowhead = !(info.start.arrowhead === "none" || info.start.arrowhead === "arrow");
const clipEndArrowhead = !(info.end.arrowhead === "none" || info.end.arrowhead === "arrow");
return /* @__PURE__ */ jsxs("g", { children: [
includeClipPath && /* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx(
ArrowClipPath,
{
radius: 3.5 * shape.props.scale,
hasText: !isEmpty,
bounds,
labelBounds,
as: clipStartArrowhead && as ? as : "",
ae: clipEndArrowhead && ae ? ae : ""
}
) }),
/* @__PURE__ */ jsxs(
"g",
{
style: {
clipPath: includeClipPath ? `url(#${clipPathId})` : void 0,
WebkitClipPath: includeClipPath ? `url(#${clipPathId})` : void 0
},
children: [
includeClipPath && /* @__PURE__ */ jsx(
"rect",
{
x: bounds.minX - 100,
y: bounds.minY - 100,
width: bounds.width + 200,
height: bounds.height + 200,
opacity: 0
}
),
getArrowBodyPath(
shape,
info,
shape.props.dash === "draw" ? {
style: "draw",
randomSeed: shape.id,
strokeWidth: 1,
passes: 1,
offset: 0,
roundness: strokeWidth * 2,
props: { strokeWidth: void 0 }
} : { style: "solid", strokeWidth: 1, props: { strokeWidth: void 0 } }
)
]
}
),
as && /* @__PURE__ */ jsx("path", { d: as }),
ae && /* @__PURE__ */ jsx("path", { d: ae }),
labelGeometry && /* @__PURE__ */ jsx(
"rect",
{
x: toDomPrecision(labelBounds.x),
y: toDomPrecision(labelBounds.y),
width: labelBounds.w,
height: labelBounds.h,
rx: 3.5,
ry: 3.5
}
)
] });
}
useLegacyIndicator() {
return false;
}
getIndicatorPath(shape) {
const info = getArrowInfo(this.editor, shape);
if (!info) return void 0;
const isEditing = this.editor.getEditingShapeId() === shape.id;
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape, info?.bindings);
const geometry = this.editor.getShapeGeometry(shape);
const isEmpty = isEmptyRichText(shape.props.richText);
const labelGeometry = isEditing || !isEmpty ? geometry.children[1] : null;
if (Vec.Equals(start, end)) return void 0;
const strokeWidth = STROKE_SIZES[shape.props.size] * shape.props.scale;
if (isEditing && labelGeometry) {
const labelBounds = labelGeometry.getBounds();
const path = new Path2D();
path.roundRect(
labelBounds.x,
labelBounds.y,
labelBounds.w,
labelBounds.h,
3.5 * shape.props.scale
);
return path;
}
const isForceSolid = this.editor.getEfficientZoomLevel() < 0.25 / shape.props.scale;
const bodyPathBuilder = getArrowBodyPathBuilder(info);
const bodyPath2D = bodyPathBuilder.toPath2D(
shape.props.dash === "draw" && !isForceSolid ? {
style: "draw",
randomSeed: shape.id,
strokeWidth: 1,
passes: 1,
offset: 0,
roundness: strokeWidth * 2
} : { style: "solid", strokeWidth: 1 }
);
const as = info.start.arrowhead && getArrowheadPathForType(info, "start", strokeWidth);
const ae = info.end.arrowhead && getArrowheadPathForType(info, "end", strokeWidth);
const clipStartArrowhead = !!(as && info.start.arrowhead !== "arrow");
const clipEndArrowhead = !!(ae && info.end.arrowhead !== "arrow");
const needsClipping = labelGeometry || clipStartArrowhead || clipEndArrowhead;
if (needsClipping) {
const bounds = geometry.bounds;
const clipPath = new Path2D();
clipPath.rect(bounds.minX - 100, bounds.minY - 100, bounds.width + 200, bounds.height + 200);
if (labelGeometry) {
const labelBounds = labelGeometry.getBounds();
const radius = 3.5 * shape.props.scale;
const lb = labelBounds;
clipPath.moveTo(lb.x, lb.y + radius);
clipPath.lineTo(lb.x, lb.maxY - radius);
clipPath.arcTo(lb.x, lb.maxY, lb.x + radius, lb.maxY, radius);
clipPath.lineTo(lb.maxX - radius, lb.maxY);
clipPath.arcTo(lb.maxX, lb.maxY, lb.maxX, lb.maxY - radius, radius);
clipPath.lineTo(lb.maxX, lb.y + radius);
clipPath.arcTo(lb.maxX, lb.y, lb.maxX - radius, lb.y, radius);
clipPath.lineTo(lb.x + radius, lb.y);
clipPath.arcTo(lb.x, lb.y, lb.x, lb.y + radius, radius);
clipPath.closePath();
}
if (clipStartArrowhead && as) {
clipPath.addPath(new Path2D(as));
}
if (clipEndArrowhead && ae) {
clipPath.addPath(new Path2D(ae));
}
const additionalPaths = [];
if (as) additionalPaths.push(new Path2D(as));
if (ae) additionalPaths.push(new Path2D(ae));
if (labelGeometry) {
const labelBounds = labelGeometry.getBounds();
const labelPath = new Path2D();
labelPath.roundRect(labelBounds.x, labelBounds.y, labelBounds.w, labelBounds.h, 3.5);
additionalPaths.push(labelPath);
}
return {
path: bodyPath2D,
clipPath,
additionalPaths
};
}
const combinedPath = new Path2D();
combinedPath.addPath(bodyPath2D);
if (as) {
combinedPath.addPath(new Path2D(as));
}
if (ae) {
combinedPath.addPath(new Path2D(ae));
}
return combinedPath;
}
onEditStart(shape) {
if (isEmptyRichText(shape.props.richText)) {
const labelPosition = getArrowLabelDefaultPosition(this.editor, shape);
this.editor.updateShape({
id: shape.id,
type: shape.type,
props: { labelPosition }
});
}
}
toSvg(shape, ctx) {
ctx.addExportDef(getFillDefForExport(shape.props.fill));
const theme = getDefaultColorTheme(ctx);
const scaleFactor = 1 / shape.props.scale;
const showArrowLabel = !isEmptyRichText(shape.props.richText);
return /* @__PURE__ */ jsxs("g", { transform: `scale(${scaleFactor})`, children: [
/* @__PURE__ */ jsx(ArrowSvg, { shape, shouldDisplayHandles: false }),
showArrowLabel && /* @__PURE__ */ jsx(
RichTextSVG,
{
fontSize: getArrowLabelFontSize(shape),
font: shape.props.font,
align: "middle",
verticalAlign: "middle",
labelColor: getColorValue(theme, shape.props.labelColor, "solid"),
richText: shape.props.richText,
bounds: getArrowLabelPosition(this.editor, shape, false).box.clone().expandBy(-ARROW_LABEL_PADDING * shape.props.scale),
padding: 0,
showTextOutline: this.options.showTextOutline
}
)
] });
}
getCanvasSvgDefs() {
return [
getFillDefForCanvas(),
{
key: `arrow:dot`,
component: ArrowheadDotDef
},
{
key: `arrow:cross`,
component: ArrowheadCrossDef
}
];
}
getInterpolatedProps(startShape, endShape, progress) {
return {
...(progress > 0.5 ? endShape.props : startShape.props),
scale: lerp(startShape.props.scale, endShape.props.scale, progress),
start: {
x: lerp(startShape.props.start.x, endShape.props.start.x, progress),
y: lerp(startShape.props.start.y, endShape.props.start.y, progress)
},
end: {
x: lerp(startShape.props.end.x, endShape.props.end.x, progress),
y: lerp(startShape.props.end.y, endShape.props.end.y, progress)
},
bend: lerp(startShape.props.bend, endShape.props.bend, progress),
labelPosition: lerp(startShape.props.labelPosition, endShape.props.labelPosition, progress)
};
}
}
function getArrowLength(editor, shape) {
const info = getArrowInfo(editor, shape);
return info.type === "straight" ? Vec.Dist(info.start.handle, info.end.handle) : info.type === "arc" ? Math.abs(info.handleArc.length) : info.route.distance;
}
const ArrowSvg = track(function ArrowSvg2({
shape,
shouldDisplayHandles
}) {
const editor = useEditor();
const theme = useDefaultColorTheme();
const info = getArrowInfo(editor, shape);
const isForceSolid = useEfficientZoomThreshold(0.25 / shape.props.scale);
const clipPathId = useSharedSafeId(shape.id + "_clip");
const arrowheadDotId = useSharedSafeId("arrowhead-dot");
const arrowheadCrossId = useSharedSafeId("arrowhead-cross");
const isEditing = useIsEditing(shape.id);
const geometry = editor.getShapeGeometry(shape);
if (!geometry) return null;
const bounds = Box.ZeroFix(geometry.bounds);
const bindings = getArrowBindings(editor, shape);
const isEmpty = isEmptyRichText(shape.props.richText);
if (!info?.isValid) return null;
const strokeWidth = STROKE_SIZES[shape.props.size] * shape.props.scale;
const as = info.start.arrowhead && getArrowheadPathForType(info, "start", strokeWidth);
const ae = info.end.arrowhead && getArrowheadPathForType(info, "end", strokeWidth);
let handlePath = null;
if (shouldDisplayHandles && (bindings.start || bindings.end)) {
handlePath = getArrowHandlePath(info, {
style: "dashed",
start: "skip",
end: "skip",
lengthRatio: 2.5,
strokeWidth: 2 / editor.getEfficientZoomLevel(),
props: {
className: "tl-arrow-hint",
markerStart: bindings.start ? bindings.start.props.isExact ? "" : bindings.start.props.isPrecise ? `url(#${arrowheadCrossId})` : `url(#${arrowheadDotId})` : "",
markerEnd: bindings.end ? bindings.end.props.isExact ? "" : bindings.end.props.isPrecise ? `url(#${arrowheadCrossId})` : `url(#${arrowheadDotId})` : "",
opacity: 0.16
}
});
}
const labelPosition = getArrowLabelPosition(editor, shape, isEditing);
const clipStartArrowhead = !(info.start.arrowhead === "none" || info.start.arrowhead === "arrow");
const clipEndArrowhead = !(info.end.arrowhead === "none" || info.end.arrowhead === "arrow");
return /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsx("clipPath", { id: clipPathId, children: /* @__PURE__ */ jsx(
ArrowClipPath,
{
radius: 3.5 * shape.props.scale,
hasText: isEditing || !isEmpty,
bounds,
labelBounds: labelPosition.box,
as: clipStartArrowhead && as ? as : "",
ae: clipEndArrowhead && ae ? ae : ""
}
) }) }),
/* @__PURE__ */ jsxs(
"g",
{
fill: "none",
stroke: getColorValue(theme, shape.props.color, "solid"),
strokeWidth,
strokeLinejoin: "round",
strokeLinecap: "round",
pointerEvents: "none",
children: [
handlePath,
/* @__PURE__ */ jsxs(
"g",
{
style: {
clipPath: `url(#${clipPathId})`,
WebkitClipPath: `url(#${clipPathId})`
},
children: [
/* @__PURE__ */ jsx(
"rect",
{
x: toDomPrecision(bounds.minX - 100),
y: toDomPrecision(bounds.minY - 100),
width: toDomPrecision(bounds.width + 200),
height: toDomPrecision(bounds.height + 200),
opacity: 0
}
),
getArrowBodyPath(shape, info, {
style: shape.props.dash,
strokeWidth,
forceSolid: isForceSolid,
randomSeed: shape.id
})
]
}
),
as && clipStartArrowhead && shape.props.fill !== "none" && /* @__PURE__ */ jsx(
ShapeFill,
{
theme,
d: as,
color: shape.props.color,
fill: shape.props.fill,
scale: shape.props.scale
}
),
ae && clipEndArrowhead && shape.props.fill !== "none" && /* @__PURE__ */ jsx(
ShapeFill,
{
theme,
d: ae,
color: shape.props.color,
fill: shape.props.fill,
scale: shape.props.scale
}
),
as && /* @__PURE__ */ jsx("path", { d: as }),
ae && /* @__PURE__ */ jsx("path", { d: ae })
]
}
)
] });
});
function ArrowClipPath({
radius,
hasText,
bounds,
labelBounds,
as,
ae
}) {
const path = useMemo(() => {
const path2 = new PathBuilder();
path2.moveTo(bounds.left - 100, bounds.top - 100).lineTo(bounds.right + 100, bounds.top - 100).lineTo(bounds.right + 100, bounds.bottom + 100).lineTo(bounds.left - 100, bounds.bottom + 100).close();
if (hasText) {
path2.moveTo(labelBounds.left, labelBounds.top + radius).lineTo(labelBounds.left, labelBounds.bottom - radius).circularArcTo(radius, false, false, labelBounds.left + radius, labelBounds.bottom).lineTo(labelBounds.right - radius, labelBounds.bottom).circularArcTo(radius, false, false, labelBounds.right, labelBounds.bottom - radius).lineTo(labelBounds.right, labelBounds.top + radius).circularArcTo(radius, false, false, labelBounds.right - radius, labelBounds.top).lineTo(labelBounds.left + radius, labelBounds.top).circularArcTo(radius, false, false, labelBounds.left, labelBounds.top + radius).close();
}
return path2.toD();
}, [
radius,
hasText,
bounds.bottom,
bounds.left,
bounds.right,
bounds.top,
labelBounds.bottom,
labelBounds.left,
labelBounds.right,
labelBounds.top
]);
return /* @__PURE__ */ jsx("path", { d: `${path}${as}${ae}` });
}
const shapeAtTranslationStart = /* @__PURE__ */ new WeakMap();
function ArrowheadDotDef() {
const id = useSharedSafeId("arrowhead-dot");
return /* @__PURE__ */ jsx("marker", { id, className: "tl-arrow-hint", refX: "3.0", refY: "3.0", orient: "0", children: /* @__PURE__ */ jsx("circle", { cx: "3", cy: "3", r: "2", strokeDasharray: "100%" }) });
}
function ArrowheadCrossDef() {
const id = useSharedSafeId("arrowhead-cross");
return /* @__PURE__ */ jsxs("marker", { id, className: "tl-arrow-hint", refX: "3.0", refY: "3.0", orient: "auto", children: [
/* @__PURE__ */ jsx("line", { x1: "1.5", y1: "1.5", x2: "4.5", y2: "4.5", strokeDasharray: "100%" }),
/* @__PURE__ */ jsx("line", { x1: "1.5", y1: "4.5", x2: "4.5", y2: "1.5", strokeDasharray: "100%" })
] });
}
function anglesAreApproximatelyParallel(a, b, tolerance = 1e-4) {
const diff = Math.abs(a - b);
const isParallel = diff < tolerance;
const isFlippedParallel = Math.abs(diff - Math.PI) < tolerance;
const is360Parallel = Math.abs(diff - PI2) < tolerance;
return { isParallel: isParallel || is360Parallel, isFlippedParallel };
}
export {
ArrowShapeUtil,
getArrowLength
};
//# sourceMappingURL=ArrowShapeUtil.mjs.map