UNPKG

tldraw

Version:

A tiny little drawing editor.

245 lines (244 loc) • 8.57 kB
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