snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
324
packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts
Normal file
324
packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
Arc2d,
|
||||
Box,
|
||||
Circle2d,
|
||||
Edge2d,
|
||||
Editor,
|
||||
Geometry2d,
|
||||
Group2d,
|
||||
Polygon2d,
|
||||
Polyline2d,
|
||||
TLArrowShape,
|
||||
TLShape,
|
||||
TLTheme,
|
||||
Vec,
|
||||
VecLike,
|
||||
clamp,
|
||||
createComputedCache,
|
||||
exhaustiveSwitchError,
|
||||
getChangedKeys,
|
||||
pointInPolygon,
|
||||
toRichText,
|
||||
} from '@tldraw/editor'
|
||||
import { isEmptyRichText, renderHtmlFromRichTextForMeasurement } from '../../utils/text/richText'
|
||||
import { LABEL_TO_ARROW_PADDING, STROKE_SIZES, TEXT_PROPS } from '../shared/default-shape-constants'
|
||||
import { getDisplayValues } from '../shared/getDisplayValues'
|
||||
import type { ArrowShapeUtilDisplayValues } from './arrow-types'
|
||||
import { TLArrowInfo } from './arrow-types'
|
||||
import { getArrowInfo } from './getArrowInfo'
|
||||
|
||||
function getArrowDisplayValues(editor: Editor, shape: TLArrowShape): ArrowShapeUtilDisplayValues {
|
||||
// We cast here because editor.getShapeUtil('arrow') returns ShapeUtil<TLArrowShape>
|
||||
// with generic options, but the actual instance has ArrowShapeOptions with display values.
|
||||
return getDisplayValues(editor.getShapeUtil('arrow') as any, shape)
|
||||
}
|
||||
|
||||
export function getArrowBodyGeometry(editor: Editor, shape: TLArrowShape) {
|
||||
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: Editor, shape: TLArrowShape) => {
|
||||
editor.fonts.trackFontsForShape(shape)
|
||||
let width = 0
|
||||
let height = 0
|
||||
|
||||
const bodyGeom = getArrowBodyGeometry(editor, shape)
|
||||
// We use 'i' as a default label to measure against as a minimum width.
|
||||
const isEmpty = isEmptyRichText(shape.props.richText)
|
||||
const html = renderHtmlFromRichTextForMeasurement(
|
||||
editor,
|
||||
isEmpty ? toRichText('i') : shape.props.richText
|
||||
)
|
||||
|
||||
const bodyBounds = bodyGeom.bounds
|
||||
|
||||
const dv = getArrowDisplayValues(editor, shape)
|
||||
const fontSize = dv.labelFontSize * shape.props.scale
|
||||
|
||||
// First we measure the text with no constraints
|
||||
const { w, h } = editor.textMeasure.measureHtml(html, {
|
||||
...TEXT_PROPS,
|
||||
lineHeight: dv.labelLineHeight,
|
||||
fontFamily: dv.labelFontFamily,
|
||||
fontSize,
|
||||
maxWidth: null,
|
||||
})
|
||||
|
||||
width = w
|
||||
height = h
|
||||
|
||||
let shouldSquish = false
|
||||
|
||||
// If the text is wider than the body, we need to squish it
|
||||
const info = getArrowInfo(editor, shape)!
|
||||
const labelToArrowPadding = getLabelToArrowPadding(shape, editor.getCurrentTheme())
|
||||
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,
|
||||
lineHeight: dv.labelLineHeight,
|
||||
fontFamily: dv.labelFontFamily,
|
||||
fontSize,
|
||||
maxWidth: width,
|
||||
})
|
||||
|
||||
width = squishedWidth
|
||||
height = squishedHeight
|
||||
}
|
||||
|
||||
return new Vec(width, height).addScalar(dv.labelPadding * 2 * shape.props.scale)
|
||||
},
|
||||
{
|
||||
areRecordsEqual: (a, b) => {
|
||||
if (a.props === b.props) return true
|
||||
|
||||
// If the only thing that has changed is the label position, we can skip recalculating the size
|
||||
const changedKeys = getChangedKeys(a.props, b.props)
|
||||
return changedKeys.length === 1 && changedKeys[0] === 'labelPosition'
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
|
||||
return labelSizeCache.get(editor, shape.id) ?? new Vec(0, 0)
|
||||
}
|
||||
|
||||
function getLabelToArrowPadding(shape: TLArrowShape, theme: TLTheme) {
|
||||
const strokeWidth = theme.strokeWidth * STROKE_SIZES[shape.props.size]
|
||||
const smallStrokeWidth = theme.strokeWidth * STROKE_SIZES.s
|
||||
const xlStrokeWidth = theme.strokeWidth * STROKE_SIZES.xl
|
||||
const labelToArrowPadding =
|
||||
(LABEL_TO_ARROW_PADDING +
|
||||
(strokeWidth - smallStrokeWidth) * 2 +
|
||||
(strokeWidth >= xlStrokeWidth ? 20 : 0)) *
|
||||
shape.props.scale
|
||||
|
||||
return labelToArrowPadding
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the range of possible label positions for an arrow. The full possible range is 0 to 1, but
|
||||
* as the label itself takes up space the usable range is smaller.
|
||||
*/
|
||||
function getArrowLabelRange(editor: Editor, shape: TLArrowShape, info: TLArrowInfo) {
|
||||
const bodyGeom = getArrowBodyGeometry(editor, shape)
|
||||
const dbgPoints: VecLike[] = []
|
||||
const dbg: Geometry2d[] = [new Group2d({ children: [bodyGeom], debugColor: 'lime' })]
|
||||
|
||||
const labelSize = getArrowLabelSize(editor, shape)
|
||||
const labelToArrowPadding = getLabelToArrowPadding(shape, editor.getCurrentTheme())
|
||||
const paddingRelative = labelToArrowPadding / bodyGeom.length
|
||||
|
||||
// we can calculate the range by sticking the center of the label at the very start/end of the
|
||||
// arrow, and seeing where the label intersects with the arrow. Then, if we move the label's
|
||||
// center to that point, that'll be the start/end of the range.
|
||||
|
||||
let startBox, endBox
|
||||
if (info.type === 'elbow') {
|
||||
// for elbow arrows, because they have multiple segments but are always axis-aligned, we can use
|
||||
// an expanded box. This helps keep the box from partially covering the first segment when it's
|
||||
// very small.
|
||||
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 {
|
||||
// for other arrows, we move along the arrow by the padding amount to find the start/end points
|
||||
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 }
|
||||
}
|
||||
|
||||
interface ArrowheadInfo {
|
||||
hasStartBinding: boolean
|
||||
hasEndBinding: boolean
|
||||
hasStartArrowhead: boolean
|
||||
hasEndArrowhead: boolean
|
||||
}
|
||||
export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape, isEditing: boolean) {
|
||||
if (!isEditing && isEmptyRichText(shape.props.richText)) {
|
||||
// Short-circuit for empty labels.
|
||||
const bodyGeom = getArrowBodyGeometry(editor, shape)
|
||||
const labelCenter = bodyGeom.interpolateAlongEdge(0.5)
|
||||
return { box: Box.FromCenter(labelCenter, new Vec(0, 0)), debugGeom: [] }
|
||||
}
|
||||
|
||||
const debugGeom: Geometry2d[] = []
|
||||
const info = getArrowInfo(editor, shape)!
|
||||
|
||||
const arrowheadInfo: 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: TLArrowShape,
|
||||
range: { start: number; end: number },
|
||||
arrowheadInfo: 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: VecLike, candidates: VecLike[]): VecLike | null {
|
||||
let furthest: VecLike | null = null
|
||||
let furthestDist = -Infinity
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const dist = Vec.Dist2(from, candidate)
|
||||
if (dist > furthestDist) {
|
||||
furthest = candidate
|
||||
furthestDist = dist
|
||||
}
|
||||
}
|
||||
|
||||
return furthest
|
||||
}
|
||||
|
||||
export function getArrowLabelDefaultPosition(editor: Editor, shape: TLArrowShape) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function isOverArrowLabel(editor: Editor, shape: TLShape) {
|
||||
if (!editor.isShapeOfType(shape, 'arrow')) return false
|
||||
|
||||
const pointInShapeSpace = editor.getPointInShapeSpace(shape, editor.inputs.getCurrentPagePoint())
|
||||
// How should we handle multiple labels? Do shapes ever have multiple labels?
|
||||
const labelGeometry = editor.getShapeGeometry<Group2d>(shape).children[1]
|
||||
// Knowing what we know about arrows... if the shape has no text in its label,
|
||||
// then the label geometry should not be there.
|
||||
return labelGeometry && pointInPolygon(pointInShapeSpace, labelGeometry.vertices)
|
||||
}
|
||||
Reference in New Issue
Block a user