2024-01-24 10:19:20 +00:00
|
|
|
import {
|
|
|
|
Arc2d,
|
|
|
|
Box,
|
|
|
|
Circle2d,
|
|
|
|
Edge2d,
|
|
|
|
Editor,
|
|
|
|
Geometry2d,
|
|
|
|
Polygon2d,
|
|
|
|
TLArrowInfo,
|
|
|
|
TLArrowShape,
|
|
|
|
Vec,
|
|
|
|
VecLike,
|
|
|
|
angleDistance,
|
|
|
|
clamp,
|
|
|
|
getPointOnCircle,
|
|
|
|
intersectCirclePolygon,
|
|
|
|
intersectLineSegmentPolygon,
|
|
|
|
} from '@tldraw/editor'
|
|
|
|
import {
|
|
|
|
ARROW_LABEL_FONT_SIZES,
|
|
|
|
ARROW_LABEL_PADDING,
|
|
|
|
FONT_FAMILIES,
|
|
|
|
LABEL_TO_ARROW_PADDING,
|
|
|
|
STROKE_SIZES,
|
|
|
|
TEXT_PROPS,
|
|
|
|
} from '../shared/default-shape-constants'
|
|
|
|
|
|
|
|
const labelSizeCache = new WeakMap<TLArrowShape, Vec>()
|
|
|
|
|
React-powered SVG exports (#3117)
## Migration path
1. If any of your shapes implement `toSvg` for exports, you'll need to
replace your implementation with a new version that returns JSX (it's a
react component) instead of manually constructing SVG DOM nodes
2. `editor.getSvg` is deprecated. It still works, but will be going away
in a future release. If you still need SVGs as DOM elements rather than
strings, use `new DOMParser().parseFromString(svgString,
'image/svg+xml').firstElementChild`
## The change in detail
At the moment, our SVG exports very carefully try to recreate the
visuals of our shapes by manually constructing SVG DOM nodes. On its own
this is really painful, but it also results in a lot of duplicated logic
between the `component` and `getSvg` methods of shape utils.
In #3020, we looked at using string concatenation & DOMParser to make
this a bit less painful. This works, but requires specifying namespaces
everywhere, is still pretty painful (no syntax highlighting or
formatting), and still results in all that duplicated logic.
I briefly experimented with creating my own version of the javascript
language that let you embed XML like syntax directly. I was going to
call it EXTREME JAVASCRIPT or XJS for short, but then I noticed that we
already wrote the whole of tldraw in this thing called react and a (imo
much worse named) version of the javascript xml thing already existed.
Given the entire library already depends on react, what would it look
like if we just used react directly for these exports? Turns out things
get a lot simpler! Take a look at lmk what you think
This diff was intended as a proof of concept, but is actually pretty
close to being landable. The main thing is that here, I've deliberately
leant into this being a big breaking change to see just how much code we
could delete (turns out: lots). We could if we wanted to make this
without making it a breaking change at all, but it would add back a lot
of complexity on our side and run a fair bit slower
---------
Co-authored-by: huppy-bot[bot] <128400622+huppy-bot[bot]@users.noreply.github.com>
2024-03-25 14:16:55 +00:00
|
|
|
function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
|
2024-01-24 10:19:20 +00:00
|
|
|
const cachedSize = labelSizeCache.get(shape)
|
|
|
|
if (cachedSize) return cachedSize
|
|
|
|
|
|
|
|
const info = editor.getArrowInfo(shape)!
|
|
|
|
let width = 0
|
|
|
|
let height = 0
|
|
|
|
|
|
|
|
const bodyGeom = info.isStraight
|
|
|
|
? new Edge2d({
|
|
|
|
start: Vec.From(info.start.point),
|
|
|
|
end: Vec.From(info.end.point),
|
2024-02-07 16:02:22 +00:00
|
|
|
})
|
2024-01-24 10:19:20 +00:00
|
|
|
: new Arc2d({
|
|
|
|
center: Vec.Cast(info.handleArc.center),
|
|
|
|
radius: info.handleArc.radius,
|
|
|
|
start: Vec.Cast(info.start.point),
|
|
|
|
end: Vec.Cast(info.end.point),
|
|
|
|
sweepFlag: info.bodyArc.sweepFlag,
|
|
|
|
largeArcFlag: info.bodyArc.largeArcFlag,
|
2024-02-07 16:02:22 +00:00
|
|
|
})
|
2024-01-24 10:19:20 +00:00
|
|
|
|
|
|
|
if (shape.props.text.trim()) {
|
|
|
|
const bodyBounds = bodyGeom.bounds
|
|
|
|
|
|
|
|
const { w, h } = editor.textMeasure.measureText(shape.props.text, {
|
|
|
|
...TEXT_PROPS,
|
|
|
|
fontFamily: FONT_FAMILIES[shape.props.font],
|
|
|
|
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
|
|
|
|
maxWidth: null,
|
|
|
|
})
|
|
|
|
|
|
|
|
width = w
|
|
|
|
height = h
|
|
|
|
|
|
|
|
if (bodyBounds.width > bodyBounds.height) {
|
|
|
|
width = Math.max(Math.min(w, 64), Math.min(bodyBounds.width - 64, w))
|
|
|
|
|
|
|
|
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
|
|
|
|
shape.props.text,
|
|
|
|
{
|
|
|
|
...TEXT_PROPS,
|
|
|
|
fontFamily: FONT_FAMILIES[shape.props.font],
|
|
|
|
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
|
|
|
|
maxWidth: width,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
width = squishedWidth
|
|
|
|
height = squishedHeight
|
|
|
|
}
|
|
|
|
|
|
|
|
if (width > 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]) {
|
|
|
|
width = 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]
|
|
|
|
|
|
|
|
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
|
|
|
|
shape.props.text,
|
|
|
|
{
|
|
|
|
...TEXT_PROPS,
|
|
|
|
fontFamily: FONT_FAMILIES[shape.props.font],
|
|
|
|
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
|
|
|
|
maxWidth: width,
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
width = squishedWidth
|
|
|
|
height = squishedHeight
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const size = new Vec(width, height).addScalar(ARROW_LABEL_PADDING * 2)
|
|
|
|
labelSizeCache.set(shape, size)
|
|
|
|
return size
|
|
|
|
}
|
|
|
|
|
|
|
|
function getLabelToArrowPadding(editor: Editor, shape: TLArrowShape) {
|
|
|
|
const strokeWidth = STROKE_SIZES[shape.props.size]
|
|
|
|
const labelToArrowPadding =
|
|
|
|
LABEL_TO_ARROW_PADDING +
|
|
|
|
(strokeWidth - STROKE_SIZES.s) * 2 +
|
|
|
|
(strokeWidth === STROKE_SIZES.xl ? 20 : 0)
|
|
|
|
|
|
|
|
return labelToArrowPadding
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return the range of possible label positions for a straight arrow. The full possible range is 0
|
|
|
|
* to 1, but as the label itself takes up space the usable range is smaller.
|
|
|
|
*/
|
|
|
|
function getStraightArrowLabelRange(
|
|
|
|
editor: Editor,
|
|
|
|
shape: TLArrowShape,
|
|
|
|
info: Extract<TLArrowInfo, { isStraight: true }>
|
|
|
|
): { start: number; end: number } {
|
|
|
|
const labelSize = getArrowLabelSize(editor, shape)
|
|
|
|
const labelToArrowPadding = getLabelToArrowPadding(editor, shape)
|
|
|
|
|
|
|
|
// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
|
|
|
|
const startOffset = Vec.Nudge(info.start.point, info.end.point, labelToArrowPadding)
|
|
|
|
const endOffset = Vec.Nudge(info.end.point, info.start.point, labelToArrowPadding)
|
|
|
|
|
|
|
|
// assuming we just stick the label in the middle of the shape, where does the arrow intersect the label?
|
|
|
|
const intersectionPoints = intersectLineSegmentPolygon(
|
|
|
|
startOffset,
|
|
|
|
endOffset,
|
|
|
|
Box.FromCenter(info.middle, labelSize).corners
|
|
|
|
)
|
|
|
|
if (!intersectionPoints || intersectionPoints.length !== 2) {
|
|
|
|
return { start: 0.5, end: 0.5 }
|
|
|
|
}
|
|
|
|
|
|
|
|
// there should be two intersection points - one near the start, and one near the end
|
|
|
|
let [startIntersect, endIntersect] = intersectionPoints
|
|
|
|
if (Vec.Dist2(startIntersect, startOffset) > Vec.Dist2(endIntersect, startOffset)) {
|
|
|
|
;[endIntersect, startIntersect] = intersectionPoints
|
|
|
|
}
|
|
|
|
|
|
|
|
// take our nudged start and end points and scooch them in even further to give us the possible
|
|
|
|
// range for the position of the _center_ of the label
|
|
|
|
const startConstrained = startOffset.add(Vec.Sub(info.middle, startIntersect))
|
|
|
|
const endConstrained = endOffset.add(Vec.Sub(info.middle, endIntersect))
|
|
|
|
|
|
|
|
// now we can work out the range of possible label positions
|
|
|
|
const start = Vec.Dist(info.start.point, startConstrained) / info.length
|
|
|
|
const end = Vec.Dist(info.start.point, endConstrained) / info.length
|
|
|
|
return { start, end }
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return the range of possible label positions for a curved arrow. The full possible range is 0
|
|
|
|
* to 1, but as the label itself takes up space the usable range is smaller.
|
|
|
|
*/
|
|
|
|
function getCurvedArrowLabelRange(
|
|
|
|
editor: Editor,
|
|
|
|
shape: TLArrowShape,
|
|
|
|
info: Extract<TLArrowInfo, { isStraight: false }>
|
|
|
|
): { start: number; end: number; dbg?: Geometry2d[] } {
|
|
|
|
const labelSize = getArrowLabelSize(editor, shape)
|
|
|
|
const labelToArrowPadding = getLabelToArrowPadding(editor, shape)
|
|
|
|
const direction = Math.sign(shape.props.bend)
|
|
|
|
|
|
|
|
// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
|
|
|
|
const labelToArrowPaddingRad = (labelToArrowPadding / info.handleArc.radius) * direction
|
|
|
|
const startOffsetAngle = Vec.Angle(info.bodyArc.center, info.start.point) - labelToArrowPaddingRad
|
|
|
|
const endOffsetAngle = Vec.Angle(info.bodyArc.center, info.end.point) + labelToArrowPaddingRad
|
|
|
|
const startOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, startOffsetAngle)
|
|
|
|
const endOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, endOffsetAngle)
|
|
|
|
|
|
|
|
const dbg: Geometry2d[] = []
|
|
|
|
|
|
|
|
// unlike the straight arrow, we can't just stick the label in the middle of the shape when
|
|
|
|
// we're working out the range. this is because as the label moves along the curve, the place
|
|
|
|
// where the arrow intersects with label changes. instead, we have to stick the label center on
|
|
|
|
// the `startOffset` (the start-most place where it can go), then find where it intersects with
|
|
|
|
// the arc. because of the symmetry of the label rectangle, we can move the label to that new
|
|
|
|
// center and take that as the start-most possible point.
|
|
|
|
const startIntersections = intersectArcPolygon(
|
|
|
|
info.bodyArc.center,
|
|
|
|
info.bodyArc.radius,
|
|
|
|
startOffsetAngle,
|
|
|
|
endOffsetAngle,
|
|
|
|
direction,
|
|
|
|
Box.FromCenter(startOffset, labelSize).corners
|
|
|
|
)
|
|
|
|
|
|
|
|
dbg.push(
|
|
|
|
new Polygon2d({
|
|
|
|
points: Box.FromCenter(startOffset, labelSize).corners,
|
|
|
|
debugColor: 'lime',
|
|
|
|
isFilled: false,
|
|
|
|
ignore: true,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
|
|
|
|
const endIntersections = intersectArcPolygon(
|
|
|
|
info.bodyArc.center,
|
|
|
|
info.bodyArc.radius,
|
|
|
|
startOffsetAngle,
|
|
|
|
endOffsetAngle,
|
|
|
|
direction,
|
|
|
|
Box.FromCenter(endOffset, labelSize).corners
|
|
|
|
)
|
|
|
|
|
|
|
|
dbg.push(
|
|
|
|
new Polygon2d({
|
|
|
|
points: Box.FromCenter(endOffset, labelSize).corners,
|
|
|
|
debugColor: 'lime',
|
|
|
|
isFilled: false,
|
|
|
|
ignore: true,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
for (const pt of [
|
|
|
|
...(startIntersections ?? []),
|
|
|
|
...(endIntersections ?? []),
|
|
|
|
startOffset,
|
|
|
|
endOffset,
|
|
|
|
]) {
|
|
|
|
dbg.push(
|
|
|
|
new Circle2d({
|
|
|
|
x: pt.x - 3,
|
|
|
|
y: pt.y - 3,
|
|
|
|
radius: 3,
|
|
|
|
isFilled: false,
|
|
|
|
debugColor: 'magenta',
|
|
|
|
ignore: true,
|
|
|
|
})
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
// if we have one or more intersections (we shouldn't have more than two) then the one we need
|
|
|
|
// is the one furthest from the arrow terminal
|
|
|
|
const startConstrained =
|
|
|
|
(startIntersections && furthest(info.start.point, startIntersections)) ?? info.middle
|
|
|
|
const endConstrained =
|
|
|
|
(endIntersections && furthest(info.end.point, endIntersections)) ?? info.middle
|
|
|
|
|
|
|
|
const startAngle = Vec.Angle(info.bodyArc.center, info.start.point)
|
|
|
|
const endAngle = Vec.Angle(info.bodyArc.center, info.end.point)
|
|
|
|
const constrainedStartAngle = Vec.Angle(info.bodyArc.center, startConstrained)
|
|
|
|
const constrainedEndAngle = Vec.Angle(info.bodyArc.center, endConstrained)
|
|
|
|
|
|
|
|
// if the arc is small enough that there's no room for the label to move, we constrain it to the middle.
|
|
|
|
if (
|
|
|
|
angleDistance(startAngle, constrainedStartAngle, direction) >
|
|
|
|
angleDistance(startAngle, constrainedEndAngle, direction)
|
|
|
|
) {
|
|
|
|
return { start: 0.5, end: 0.5, dbg }
|
|
|
|
}
|
|
|
|
|
|
|
|
// now we can work out the range of possible label positions
|
|
|
|
const fullDistance = angleDistance(startAngle, endAngle, direction)
|
|
|
|
const start = angleDistance(startAngle, constrainedStartAngle, direction) / fullDistance
|
|
|
|
const end = angleDistance(startAngle, constrainedEndAngle, direction) / fullDistance
|
|
|
|
return { start, end, dbg }
|
|
|
|
}
|
|
|
|
|
|
|
|
export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
|
|
|
|
let labelCenter
|
|
|
|
const debugGeom: Geometry2d[] = []
|
|
|
|
const info = editor.getArrowInfo(shape)!
|
|
|
|
|
2024-04-17 14:35:25 +00:00
|
|
|
const hasStartBinding = shape.props.start.type === 'binding'
|
|
|
|
const hasEndBinding = shape.props.end.type === 'binding'
|
2024-01-31 11:17:03 +00:00
|
|
|
const hasStartArrowhead = info.start.arrowhead !== 'none'
|
|
|
|
const hasEndArrowhead = info.end.arrowhead !== 'none'
|
2024-01-24 10:19:20 +00:00
|
|
|
if (info.isStraight) {
|
|
|
|
const range = getStraightArrowLabelRange(editor, shape, info)
|
2024-01-31 11:17:03 +00:00
|
|
|
let clampedPosition = clamp(
|
|
|
|
shape.props.labelPosition,
|
2024-04-17 14:35:25 +00:00
|
|
|
hasStartArrowhead || hasStartBinding ? range.start : 0,
|
|
|
|
hasEndArrowhead || hasEndBinding ? range.end : 1
|
2024-01-31 11:17:03 +00:00
|
|
|
)
|
|
|
|
// This makes the position snap in the middle.
|
|
|
|
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
|
2024-01-24 10:19:20 +00:00
|
|
|
labelCenter = Vec.Lrp(info.start.point, info.end.point, clampedPosition)
|
|
|
|
} else {
|
|
|
|
const range = getCurvedArrowLabelRange(editor, shape, info)
|
|
|
|
if (range.dbg) debugGeom.push(...range.dbg)
|
2024-01-31 11:17:03 +00:00
|
|
|
let clampedPosition = clamp(
|
|
|
|
shape.props.labelPosition,
|
2024-04-17 14:35:25 +00:00
|
|
|
hasStartArrowhead || hasStartBinding ? range.start : 0,
|
|
|
|
hasEndArrowhead || hasEndBinding ? range.end : 1
|
2024-01-31 11:17:03 +00:00
|
|
|
)
|
|
|
|
// This makes the position snap in the middle.
|
|
|
|
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
|
2024-01-24 10:19:20 +00:00
|
|
|
const labelAngle = interpolateArcAngles(
|
|
|
|
Vec.Angle(info.bodyArc.center, info.start.point),
|
|
|
|
Vec.Angle(info.bodyArc.center, info.end.point),
|
|
|
|
Math.sign(shape.props.bend),
|
|
|
|
clampedPosition
|
|
|
|
)
|
|
|
|
labelCenter = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, labelAngle)
|
|
|
|
}
|
|
|
|
|
|
|
|
const labelSize = getArrowLabelSize(editor, shape)
|
|
|
|
|
|
|
|
return { box: Box.FromCenter(labelCenter, labelSize), debugGeom }
|
|
|
|
}
|
|
|
|
|
|
|
|
function intersectArcPolygon(
|
|
|
|
center: VecLike,
|
|
|
|
radius: number,
|
|
|
|
angleStart: number,
|
|
|
|
angleEnd: number,
|
|
|
|
direction: number,
|
|
|
|
polygon: VecLike[]
|
|
|
|
) {
|
|
|
|
const intersections = intersectCirclePolygon(center, radius, polygon)
|
|
|
|
|
|
|
|
// filter the circle intersections to just the ones from the arc
|
|
|
|
const fullArcDistance = angleDistance(angleStart, angleEnd, direction)
|
|
|
|
return intersections?.filter((pt) => {
|
|
|
|
const pDistance = angleDistance(angleStart, Vec.Angle(center, pt), direction)
|
|
|
|
return pDistance >= 0 && pDistance <= fullArcDistance
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param angleStart - The angle of the start of the arc
|
|
|
|
* @param angleEnd - The angle of the end of the arc
|
|
|
|
* @param direction - The direction of the arc (1 = counter-clockwise, -1 = clockwise)
|
|
|
|
* @param t - A number between 0 and 1 representing the position along the arc
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
function interpolateArcAngles(angleStart: number, angleEnd: number, direction: number, t: number) {
|
|
|
|
const dist = angleDistance(angleStart, angleEnd, direction)
|
|
|
|
return angleStart + dist * t * direction * -1
|
|
|
|
}
|