tldraw
Version:
A tiny little drawing editor.
245 lines (244 loc) • 8.57 kB
JavaScript
import {
Arc2d,
Box,
Circle2d,
Edge2d,
Group2d,
Polygon2d,
Polyline2d,
Vec,
clamp,
createComputedCache,
exhaustiveSwitchError,
getChangedKeys,
pointInPolygon,
toRichText
} from "@tldraw/editor";
import { isEmptyRichText, renderHtmlFromRichTextForMeasurement } from "../../utils/text/richText.mjs";
import {
ARROW_LABEL_FONT_SIZES,
ARROW_LABEL_PADDING,
FONT_FAMILIES,
LABEL_TO_ARROW_PADDING,
STROKE_SIZES,
TEXT_PROPS
} from "../shared/default-shape-constants.mjs";
import { getArrowInfo } from "./getArrowInfo.mjs";
function getArrowBodyGeometry(editor, shape) {
const info = getArrowInfo(editor, shape);
switch (info.type) {
case "straight":
return new Edge2d({
start: Vec.From(info.start.point),
end: Vec.From(info.end.point)
});
case "arc":
return 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
});
case "elbow":
return new Polyline2d({ points: info.route.points });
default:
exhaustiveSwitchError(info, "type");
}
}
const labelSizeCache = createComputedCache(
"arrow label size",
(editor, shape) => {
editor.fonts.trackFontsForShape(shape);
let width = 0;
let height = 0;
const bodyGeom = getArrowBodyGeometry(editor, shape);
const isEmpty = isEmptyRichText(shape.props.richText);
const html = renderHtmlFromRichTextForMeasurement(
editor,
isEmpty ? toRichText("i") : shape.props.richText
);
const bodyBounds = bodyGeom.bounds;
const fontSize = getArrowLabelFontSize(shape);
const { w, h } = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize,
maxWidth: null
});
width = w;
height = h;
let shouldSquish = false;
const info = getArrowInfo(editor, shape);
const labelToArrowPadding = getLabelToArrowPadding(shape);
const margin = info.type === "elbow" ? Math.max(info.elbow.A.arrowheadOffset + labelToArrowPadding, 32) + Math.max(info.elbow.B.arrowheadOffset + labelToArrowPadding, 32) : 64;
if (bodyBounds.width > bodyBounds.height) {
width = Math.max(Math.min(w, margin), Math.min(bodyBounds.width - margin, w));
shouldSquish = true;
} else if (width > 16 * fontSize) {
width = 16 * fontSize;
shouldSquish = true;
}
if (shouldSquish) {
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize,
maxWidth: width
});
width = squishedWidth;
height = squishedHeight;
}
return new Vec(width, height).addScalar(ARROW_LABEL_PADDING * 2 * shape.props.scale);
},
{
areRecordsEqual: (a, b) => {
if (a.props === b.props) return true;
const changedKeys = getChangedKeys(a.props, b.props);
return changedKeys.length === 1 && changedKeys[0] === "labelPosition";
}
}
);
function getArrowLabelSize(editor, shape) {
return labelSizeCache.get(editor, shape.id) ?? new Vec(0, 0);
}
function getLabelToArrowPadding(shape) {
const strokeWidth = STROKE_SIZES[shape.props.size];
const labelToArrowPadding = (LABEL_TO_ARROW_PADDING + (strokeWidth - STROKE_SIZES.s) * 2 + (strokeWidth === STROKE_SIZES.xl ? 20 : 0)) * shape.props.scale;
return labelToArrowPadding;
}
function getArrowLabelRange(editor, shape, info) {
const bodyGeom = getArrowBodyGeometry(editor, shape);
const dbgPoints = [];
const dbg = [new Group2d({ children: [bodyGeom], debugColor: "lime" })];
const labelSize = getArrowLabelSize(editor, shape);
const labelToArrowPadding = getLabelToArrowPadding(shape);
const paddingRelative = labelToArrowPadding / bodyGeom.length;
let startBox, endBox;
if (info.type === "elbow") {
dbgPoints.push(info.start.point, info.end.point);
startBox = Box.FromCenter(info.start.point, labelSize).expandBy(labelToArrowPadding);
endBox = Box.FromCenter(info.end.point, labelSize).expandBy(labelToArrowPadding);
} else {
const startPoint = bodyGeom.interpolateAlongEdge(paddingRelative);
const endPoint = bodyGeom.interpolateAlongEdge(1 - paddingRelative);
dbgPoints.push(startPoint, endPoint);
startBox = Box.FromCenter(startPoint, labelSize);
endBox = Box.FromCenter(endPoint, labelSize);
}
const startIntersections = bodyGeom.intersectPolygon(startBox.corners);
const endIntersections = bodyGeom.intersectPolygon(endBox.corners);
const startConstrained = furthest(info.start.point, startIntersections);
const endConstrained = furthest(info.end.point, endIntersections);
let startRelative = startConstrained ? bodyGeom.uninterpolateAlongEdge(startConstrained) : 0.5;
let endRelative = endConstrained ? bodyGeom.uninterpolateAlongEdge(endConstrained) : 0.5;
if (startRelative > endRelative) {
startRelative = 0.5;
endRelative = 0.5;
}
for (const pt of [...startIntersections, ...endIntersections, ...dbgPoints]) {
dbg.push(
new Circle2d({
x: pt.x - 3,
y: pt.y - 3,
radius: 3,
isFilled: false,
debugColor: "magenta",
ignore: true
})
);
}
dbg.push(
new Polygon2d({
points: startBox.corners,
debugColor: "lime",
isFilled: false,
ignore: true
}),
new Polygon2d({
points: endBox.corners,
debugColor: "lime",
isFilled: false,
ignore: true
})
);
return { start: startRelative, end: endRelative, dbg };
}
function getArrowLabelPosition(editor, shape, isEditing) {
if (!isEditing && isEmptyRichText(shape.props.richText)) {
const bodyGeom2 = getArrowBodyGeometry(editor, shape);
const labelCenter2 = bodyGeom2.interpolateAlongEdge(0.5);
return { box: Box.FromCenter(labelCenter2, new Vec(0, 0)), debugGeom: [] };
}
const debugGeom = [];
const info = getArrowInfo(editor, shape);
const arrowheadInfo = {
hasStartBinding: !!info.bindings.start,
hasEndBinding: !!info.bindings.end,
hasStartArrowhead: info.start.arrowhead !== "none",
hasEndArrowhead: info.end.arrowhead !== "none"
};
const range = getArrowLabelRange(editor, shape, info);
if (range.dbg) debugGeom.push(...range.dbg);
const clampedPosition = getClampedPosition(shape, range, arrowheadInfo);
const bodyGeom = getArrowBodyGeometry(editor, shape);
const labelCenter = bodyGeom.interpolateAlongEdge(clampedPosition);
const labelSize = getArrowLabelSize(editor, shape);
return { box: Box.FromCenter(labelCenter, labelSize), debugGeom };
}
function getClampedPosition(shape, range, arrowheadInfo) {
const { hasEndArrowhead, hasEndBinding, hasStartBinding, hasStartArrowhead } = arrowheadInfo;
const clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
);
return clampedPosition;
}
function furthest(from, candidates) {
let furthest2 = null;
let furthestDist = -Infinity;
for (const candidate of candidates) {
const dist = Vec.Dist2(from, candidate);
if (dist > furthestDist) {
furthest2 = candidate;
furthestDist = dist;
}
}
return furthest2;
}
function getArrowLabelFontSize(shape) {
return ARROW_LABEL_FONT_SIZES[shape.props.size] * shape.props.scale;
}
function getArrowLabelDefaultPosition(editor, shape) {
const info = getArrowInfo(editor, shape);
switch (info.type) {
case "straight":
case "arc":
return 0.5;
case "elbow": {
const midpointHandle = info.route.midpointHandle;
const bodyGeom = getArrowBodyGeometry(editor, shape);
if (midpointHandle && bodyGeom) {
return bodyGeom.uninterpolateAlongEdge(midpointHandle.point);
}
return 0.5;
}
default:
exhaustiveSwitchError(info, "type");
}
}
function isOverArrowLabel(editor, shape) {
if (!editor.isShapeOfType(shape, "arrow")) return false;
const pointInShapeSpace = editor.getPointInShapeSpace(shape, editor.inputs.getCurrentPagePoint());
const labelGeometry = editor.getShapeGeometry(shape).children[1];
return labelGeometry && pointInPolygon(pointInShapeSpace, labelGeometry.vertices);
}
export {
getArrowBodyGeometry,
getArrowLabelDefaultPosition,
getArrowLabelFontSize,
getArrowLabelPosition,
isOverArrowLabel
};
//# sourceMappingURL=arrowLabel.mjs.map