Tldraw/packages/editor/src/lib/editor/shapeutils/ArrowShapeUtil/ArrowShapeUtil.tsx

1143 wiersze
31 KiB
TypeScript
Czysty Zwykły widok Historia

2023-04-25 11:01:25 +00:00
import {
Box2d,
getPointOnCircle,
linesIntersect,
longAngleDist,
Matrix2d,
pointInPolygon,
2023-04-25 11:01:25 +00:00
shortAngleDist,
toDomPrecision,
Vec2d,
VecLike,
} from '@tldraw/primitives'
import { ComputedCache } from '@tldraw/store'
2023-04-25 11:01:25 +00:00
import {
TLArrowheadType,
TLArrowShape,
TLColorType,
TLFillType,
TLHandle,
TLShapeId,
TLShapePartial,
Vec2dModel,
} from '@tldraw/tlschema'
import { deepCopy, last, minBy } from '@tldraw/utils'
import * as React from 'react'
import { computed, EMPTY_ARRAY } from 'signia'
import { SVGContainer } from '../../../components/SVGContainer'
import { ARROW_LABEL_FONT_SIZES, FONT_FAMILIES, TEXT_PROPS } from '../../../constants'
import {
ShapeUtil,
TLOnEditEndHandler,
TLOnHandleChangeHandler,
TLOnResizeHandler,
TLOnTranslateStartHandler,
TLShapeUtilFlag,
} from '../ShapeUtil'
Measure individual words instead of just line breaks for text exports (#1397) This diff fixes a number of issues with text export by completely overhauling how we approach laying out text in exports. Currently, we try to carefully replicate in-browser behaviour around line breaks and whitespace collapsing. We do this using an iterative algorithm that forces the browser to perform a layout for each word, and attempting to re-implement how the browser does things like whitespace collapsing & finding line break opportunities. Lots of export issues come from the fact that this is almost impossible to do well (short of sending a complete text layout algorithm & full unicode lookup tables). Luckily, the browser already has a complete text layout algorithm and full unicode lookup tables! In the new approach, we ask the browser to lay the text out once. Then, we use the [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to loop over every character in the rendered text and measure its position. These character positions are then grouped into "spans". A span is a contiguous range of either whitespace or non-whitespace characters, uninterrupted by any browser-inserting line breaks. When we come to render the SVG, each span gets its own `<tspan>` element, absolutely positioned according to where it ended up in the user's browser. This fixes a bunch of issues: **Misaligned text due to whitespace collapsing at line breaks** ![Kapture 2023-05-17 at 12 07 30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a) **Hyphenated text (or text with non-trivial/whitespace-based breaking rules like Thai) not splitting correctly** ![Kapture 2023-05-17 at 12 21 40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e) **Weird alignment issues in note shapes** ![Kapture 2023-05-17 at 12 24 59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e) **Frame labels not respecting multiple spaces & not truncating correctly** ![Kapture 2023-05-17 at 12 27 27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78) #### Quick note on browser compatibility This approach works well across all browsers, but in some cases actually _increases_ x-browser variance. Consider these screenshots of the same element (original above, export below): ![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de) Notice how on chrome, the whitespace at the end of each line of right-aligned text is preserved. On safari, it's collapsed. The safari option looks better - so our manual line-breaking/white-space-collapsing algorithm preferred safari's approach. That meant that in-app, this shape looks very slightly different from browser to browser. But out of the app, the exports would have been the same (although also note that hyphenation is broken). Now, because these shapes look different across browsers, the exports now look different across browsers too. We're relying on the host-browsers text layout algorithm, which means we'll faithfully reproduce any quirks/inconsistencies of that algorithm. I think this is an acceptable tradeoff. ### Change Type - [x] `patch` — Bug Fix ### Test Plan * Comprehensive testing of text in exports, paying close attention to details around white-space, line-breaking and alignment * Consider setting `tldrawDebugSvg = true` * Check text shapes, geo shapes with labels, arrow shapes with labels, note shapes, frame labels * Check different alignments and fonts (including vertical alignment) ### Release Notes - Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans'
2023-04-25 11:01:25 +00:00
import { getPerfectDashProps } from '../shared/getPerfectDashProps'
import { getShapeFillSvg, ShapeFill } from '../shared/ShapeFill'
import { TLExportColors } from '../shared/TLExportColors'
import { ArrowInfo } from './arrow/arrow-types'
import { getArrowheadPathForType } from './arrow/arrowheads'
import {
getCurvedArrowHandlePath,
getCurvedArrowInfo,
getSolidCurvedArrowPath,
} from './arrow/curved-arrow'
import { getArrowTerminalsInArrowSpace, getIsArrowStraight } from './arrow/shared'
import {
getSolidStraightArrowPath,
getStraightArrowHandlePath,
getStraightArrowInfo,
} from './arrow/straight-arrow'
import { ArrowTextLabel } from './components/ArrowTextLabel'
let globalRenderIndex = 0
/** @public */
export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
static override type = 'arrow'
2023-04-25 11:01:25 +00:00
override canEdit = () => true
override canBind = () => false
override isClosed = () => false
override hideResizeHandles: TLShapeUtilFlag<TLArrowShape> = () => true
override hideRotateHandle: TLShapeUtilFlag<TLArrowShape> = () => true
override hideSelectionBoundsFg: TLShapeUtilFlag<TLArrowShape> = () => true
override hideSelectionBoundsBg: TLShapeUtilFlag<TLArrowShape> = () => true
override defaultProps(): TLArrowShape['props'] {
return {
dash: 'draw',
size: 'm',
fill: 'none',
color: 'black',
labelColor: 'black',
bend: 0,
start: { type: 'point', x: 0, y: 0 },
end: { type: 'point', x: 0, y: 0 },
arrowheadStart: 'none',
arrowheadEnd: 'arrow',
text: '',
font: 'draw',
}
}
getCenter(shape: TLArrowShape): Vec2d {
return this.bounds(shape).center
}
getBounds(shape: TLArrowShape) {
return Box2d.FromPoints(this.getOutlineWithoutLabel(shape))
}
getOutlineWithoutLabel(shape: TLArrowShape) {
const info = this.getArrowInfo(shape)
if (!info) {
return []
}
if (info.isStraight) {
if (info.isValid) {
return [info.start.point, info.end.point]
} else {
return [new Vec2d(0, 0), new Vec2d(1, 1)]
}
}
if (!info.isValid) {
return [new Vec2d(0, 0), new Vec2d(1, 1)]
}
const pointsToPush = Math.max(5, Math.ceil(Math.abs(info.bodyArc.length) / 16))
if (pointsToPush <= 0 && !isFinite(pointsToPush)) {
return [new Vec2d(0, 0), new Vec2d(1, 1)]
}
const results: Vec2d[] = Array(pointsToPush)
const startAngle = Vec2d.Angle(info.bodyArc.center, info.start.point)
const endAngle = Vec2d.Angle(info.bodyArc.center, info.end.point)
const a = info.bodyArc.sweepFlag ? endAngle : startAngle
const b = info.bodyArc.sweepFlag ? startAngle : endAngle
const l = info.bodyArc.largeArcFlag ? -longAngleDist(a, b) : shortAngleDist(a, b)
const r = Math.max(1, info.bodyArc.radius)
for (let i = 0; i < pointsToPush; i++) {
const t = i / (pointsToPush - 1)
const angle = a + l * t
const point = getPointOnCircle(info.bodyArc.center.x, info.bodyArc.center.y, r, angle)
results[i] = point
}
return results
}
getOutline(shape: TLArrowShape): Vec2dModel[] {
const outlineWithoutLabel = this.getOutlineWithoutLabel(shape)
const labelBounds = this.getLabelBounds(shape)
if (!labelBounds) {
return outlineWithoutLabel
}
const sides = labelBounds.sides
const sideIndexes = [0, 1, 2, 3]
// start with the first point...
let prevPoint = outlineWithoutLabel[0]
let didAddLabel = false
const result = [prevPoint]
for (let i = 1; i < outlineWithoutLabel.length; i++) {
// ...and use the next point to form a line segment for the outline.
const nextPoint = outlineWithoutLabel[i]
if (!didAddLabel) {
// find the index of the side of the label bounds that intersects the line segment
const nearestIntersectingSideIndex = minBy(
sideIndexes.filter((sideIndex) =>
linesIntersect(sides[sideIndex][0], sides[sideIndex][1], prevPoint, nextPoint)
),
(sideIndex) =>
Vec2d.DistanceToLineSegment(sides[sideIndex][0], sides[sideIndex][1], prevPoint)
)
// if we've found one, start at that index and trace around all four corners of the label bounds
if (nearestIntersectingSideIndex !== undefined) {
const intersectingPoint = Vec2d.NearestPointOnLineSegment(
sides[nearestIntersectingSideIndex][0],
sides[nearestIntersectingSideIndex][1],
prevPoint
)
result.push(intersectingPoint)
for (let j = 0; j < 4; j++) {
const sideIndex = (nearestIntersectingSideIndex + j) % 4
result.push(sides[sideIndex][1])
}
result.push(intersectingPoint)
// we've added the label, so we can just continue with the rest of the outline as normal
didAddLabel = true
}
}
result.push(nextPoint)
prevPoint = nextPoint
}
return result
}
snapPoints(_shape: TLArrowShape): Vec2d[] {
return EMPTY_ARRAY
}
@computed
private get infoCache() {
return this.editor.store.createComputedCache<ArrowInfo, TLArrowShape>(
2023-04-25 11:01:25 +00:00
'arrow infoCache',
(shape) => {
return getIsArrowStraight(shape)
? getStraightArrowInfo(this.editor, shape)
: getCurvedArrowInfo(this.editor, shape)
2023-04-25 11:01:25 +00:00
}
)
}
getArrowInfo(shape: TLArrowShape) {
return this.infoCache.get(shape.id)
}
getHandles(shape: TLArrowShape): TLHandle[] {
const info = this.infoCache.get(shape.id)!
return [
{
id: 'start',
type: 'vertex',
index: 'a0',
x: info.start.handle.x,
y: info.start.handle.y,
canBind: true,
},
{
id: 'middle',
type: 'vertex',
index: 'a2',
x: info.middle.x,
y: info.middle.y,
canBind: false,
},
{
id: 'end',
type: 'vertex',
index: 'a3',
x: info.end.handle.x,
y: info.end.handle.y,
canBind: true,
},
]
}
onHandleChange: TLOnHandleChangeHandler<TLArrowShape> = (shape, { handle, isPrecise }) => {
2023-04-25 11:01:25 +00:00
const next = deepCopy(shape)
switch (handle.id) {
case 'start':
case 'end': {
const pageTransform = this.editor.getPageTransformById(next.id)!
2023-04-25 11:01:25 +00:00
const pointInPageSpace = Matrix2d.applyToPoint(pageTransform, handle)
if (this.editor.inputs.ctrlKey) {
2023-04-25 11:01:25 +00:00
next.props[handle.id] = {
type: 'point',
x: handle.x,
y: handle.y,
}
} else {
const target = last(
this.editor.sortedShapesArray.filter((hitShape) => {
if (hitShape.id === shape.id) {
// We're testing against the arrow
return
}
const util = this.editor.getShapeUtil(hitShape)
if (!util.canBind(hitShape)) {
// The shape can't be bound to
return
}
// Check the page mask
const pageMask = this.editor.getPageMaskById(hitShape.id)
if (pageMask) {
if (!pointInPolygon(pointInPageSpace, pageMask)) return
}
const pointInTargetSpace = this.editor.getPointInShapeSpace(
hitShape,
pointInPageSpace
)
if (util.isClosed(hitShape)) {
// Test the polygon
return pointInPolygon(pointInTargetSpace, util.outline(hitShape))
}
// Test the point using the shape's idea of what a hit is
return util.hitTestPoint(hitShape, pointInTargetSpace)
})
)
2023-04-25 11:01:25 +00:00
if (target) {
const targetBounds = this.editor.getBounds(target)
const pointInTargetSpace = this.editor.getPointInShapeSpace(target, pointInPageSpace)
const prevHandle = next.props[handle.id]
const startBindingId =
shape.props.start.type === 'binding' && shape.props.start.boundShapeId
const endBindingId = shape.props.end.type === 'binding' && shape.props.end.boundShapeId
let precise =
// If externally precise, then always precise
isPrecise ||
// If the other handle is bound to the same shape, then precise
((startBindingId || endBindingId) && startBindingId === endBindingId) ||
// If the other shape is not closed, then precise
!this.editor.getShapeUtil(target).isClosed(next)
if (
// If we're switching to a new bound shape, then precise only if moving slowly
prevHandle.type === 'point' ||
(prevHandle.type === 'binding' && target.id !== prevHandle.boundShapeId)
) {
precise = this.editor.inputs.pointerVelocity.len() < 0.5
}
if (precise) {
// Funky math but we want the snap distance to be 4 at the minimum and either
// 16 or 15% of the smaller dimension of the target shape, whichever is smaller
precise =
Vec2d.Dist(pointInTargetSpace, targetBounds.center) >
Math.max(
4,
Math.min(Math.min(targetBounds.width, targetBounds.height) * 0.15, 16)
) /
this.editor.zoomLevel
}
next.props[handle.id] = {
type: 'binding',
boundShapeId: target.id,
normalizedAnchor: precise
? {
x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width,
y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height,
}
: { x: 0.5, y: 0.5 },
isExact: this.editor.inputs.altKey,
}
} else {
next.props[handle.id] = {
type: 'point',
x: handle.x,
y: handle.y,
}
}
}
2023-04-25 11:01:25 +00:00
break
}
case 'middle': {
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, next)
2023-04-25 11:01:25 +00:00
const delta = Vec2d.Sub(end, start)
const v = Vec2d.Per(delta)
const med = Vec2d.Med(end, start)
const A = Vec2d.Sub(med, v)
const B = Vec2d.Add(med, v)
const point = Vec2d.NearestPointOnLineSegment(A, B, handle, false)
let bend = Vec2d.Dist(point, med)
if (Vec2d.Clockwise(point, end, med)) bend *= -1
next.props.bend = bend
break
}
}
return next
}
onTranslateStart: TLOnTranslateStartHandler<TLArrowShape> = (shape) => {
2023-04-25 11:01:25 +00:00
let startBinding: TLShapeId | null =
shape.props.start.type === 'binding' ? shape.props.start.boundShapeId : null
let endBinding: TLShapeId | null =
shape.props.end.type === 'binding' ? shape.props.end.boundShapeId : null
// If at least one bound shape is in the selection, do nothing;
// If no bound shapes are in the selection, unbind any bound shapes
if (
(startBinding && this.editor.isWithinSelection(startBinding)) ||
(endBinding && this.editor.isWithinSelection(endBinding))
2023-04-25 11:01:25 +00:00
) {
return
}
startBinding = null
endBinding = null
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape)
2023-04-25 11:01:25 +00:00
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
start: {
type: 'point',
x: start.x,
y: start.y,
},
end: {
type: 'point',
x: end.x,
y: end.y,
},
},
}
}
onResize: TLOnResizeHandler<TLArrowShape> = (shape, info) => {
2023-04-25 11:01:25 +00:00
const { scaleX, scaleY } = info
const terminals = getArrowTerminalsInArrowSpace(this.editor, shape)
2023-04-25 11:01:25 +00:00
const { start, end } = deepCopy<TLArrowShape['props']>(shape.props)
let { bend } = shape.props
// Rescale start handle if it's not bound to a shape
if (start.type === 'point') {
start.x = terminals.start.x * scaleX
start.y = terminals.start.y * scaleY
}
// Rescale end handle if it's not bound to a shape
if (end.type === 'point') {
end.x = terminals.end.x * scaleX
end.y = terminals.end.y * scaleY
}
// todo: we should only change the normalized anchor positions
// of the shape's handles if the bound shape is also being resized
const mx = Math.abs(scaleX)
const my = Math.abs(scaleY)
if (scaleX < 0 && scaleY >= 0) {
if (bend !== 0) {
bend *= -1
bend *= Math.max(mx, my)
}
if (start.type === 'binding') {
start.normalizedAnchor.x = 1 - start.normalizedAnchor.x
}
if (end.type === 'binding') {
end.normalizedAnchor.x = 1 - end.normalizedAnchor.x
}
} else if (scaleX >= 0 && scaleY < 0) {
if (bend !== 0) {
bend *= -1
bend *= Math.max(mx, my)
}
if (start.type === 'binding') {
start.normalizedAnchor.y = 1 - start.normalizedAnchor.y
}
if (end.type === 'binding') {
end.normalizedAnchor.y = 1 - end.normalizedAnchor.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 (start.type === 'binding') {
start.normalizedAnchor.x = 1 - start.normalizedAnchor.x
start.normalizedAnchor.y = 1 - start.normalizedAnchor.y
}
if (end.type === 'binding') {
end.normalizedAnchor.x = 1 - end.normalizedAnchor.x
end.normalizedAnchor.y = 1 - end.normalizedAnchor.y
}
}
const next = {
props: {
start,
end,
bend,
},
}
return next
}
onDoubleClickHandle = (
shape: TLArrowShape,
handle: TLHandle
): TLShapePartial<TLArrowShape> | void => {
switch (handle.id) {
case 'start': {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
arrowheadStart: shape.props.arrowheadStart === 'none' ? 'arrow' : 'none',
},
}
}
case 'end': {
return {
id: shape.id,
type: shape.type,
props: {
...shape.props,
arrowheadEnd: shape.props.arrowheadEnd === 'none' ? 'arrow' : 'none',
},
}
}
}
}
hitTestPoint(shape: TLArrowShape, point: VecLike): boolean {
const outline = this.outline(shape)
const zoomLevel = this.editor.zoomLevel
const offsetDist = this.editor.getStrokeWidth(shape.props.size) / zoomLevel
2023-04-25 11:01:25 +00:00
for (let i = 0; i < outline.length - 1; i++) {
const C = outline[i]
const D = outline[i + 1]
if (Vec2d.DistanceToLineSegment(C, D, point) < offsetDist) return true
2023-04-25 11:01:25 +00:00
}
return false
}
hitTestLineSegment(shape: TLArrowShape, A: VecLike, B: VecLike): boolean {
const outline = this.outline(shape)
for (let i = 0; i < outline.length - 1; i++) {
const C = outline[i]
const D = outline[i + 1]
if (linesIntersect(A, B, C, D)) return true
}
return false
}
render(shape: TLArrowShape) {
// Not a class component, but eslint can't tell that :(
const onlySelectedShape = this.editor.onlySelectedShape
2023-04-25 11:01:25 +00:00
const shouldDisplayHandles =
this.editor.isInAny(
2023-04-25 11:01:25 +00:00
'select.idle',
'select.pointing_handle',
'select.dragging_handle',
'arrow.dragging'
) && !this.editor.isReadOnly
2023-04-25 11:01:25 +00:00
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const labelSize = this.getLabelBounds(shape)
// eslint-disable-next-line react-hooks/rules-of-hooks
const changeIndex = React.useMemo<number>(() => {
return this.editor.isSafari ? (globalRenderIndex += 1) : 0
2023-04-25 11:01:25 +00:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shape])
if (!info?.isValid) return null
const strokeWidth = this.editor.getStrokeWidth(shape.props.size)
2023-04-25 11:01:25 +00:00
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info)
let handlePath: null | JSX.Element = null
if (onlySelectedShape === shape && shouldDisplayHandles) {
const sw = 2
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight
? Vec2d.Dist(info.start.handle, info.end.handle)
: Math.abs(info.handleArc.length),
sw,
{
end: 'skip',
start: 'skip',
lengthRatio: 2.5,
}
)
handlePath =
shape.props.start.type === 'binding' || shape.props.end.type === 'binding' ? (
<path
className="tl-arrow-hint"
2023-04-25 11:01:25 +00:00
d={info.isStraight ? getStraightArrowHandlePath(info) : getCurvedArrowHandlePath(info)}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
strokeWidth={sw}
markerStart={
shape.props.start.type === 'binding'
? shape.props.start.isExact
? ''
: isPrecise(shape.props.start.normalizedAnchor)
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
markerEnd={
shape.props.end.type === 'binding'
? shape.props.end.isExact
? ''
: isPrecise(shape.props.end.normalizedAnchor)
? 'url(#arrowhead-cross)'
: 'url(#arrowhead-dot)'
: ''
}
opacity={0.16}
/>
) : null
}
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight ? info.length : Math.abs(info.bodyArc.length),
strokeWidth,
{
style: shape.props.dash,
}
)
const maskStartArrowhead = !(
info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow'
)
const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow')
const includeMask = maskStartArrowhead || maskEndArrowhead || labelSize
// NOTE: I know right setting `changeIndex` hacky-as right! But we need this because otherwise safari loses
// the mask, see <https://linear.app/tldraw/issue/TLD-1500/changing-arrow-color-makes-line-pass-through-text>
const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_')
return (
<>
<SVGContainer id={shape.id} style={{ minWidth: 50, minHeight: 50 }}>
{includeMask && (
<defs>
<mask id={maskId}>
<rect
x={toDomPrecision(-100 + bounds.minX)}
y={toDomPrecision(-100 + bounds.minY)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
fill="white"
/>
{labelSize && (
<rect
x={toDomPrecision(labelSize.x)}
y={toDomPrecision(labelSize.y)}
width={toDomPrecision(labelSize.w)}
height={toDomPrecision(labelSize.h)}
fill="black"
rx={4}
ry={4}
/>
)}
{as && maskStartArrowhead && (
<path
d={as}
fill={info.start.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
{ae && maskEndArrowhead && (
<path
d={ae}
fill={info.end.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
</mask>
</defs>
)}
<g
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
pointerEvents="none"
>
{handlePath}
{/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */}
<g {...(includeMask ? { mask: `url(#${maskId})` } : undefined)}>
{/* This rect needs to be here if we're creating a mask due to an svg quirk on Chrome */}
{includeMask && (
<rect
x={toDomPrecision(bounds.minX - 100)}
y={toDomPrecision(bounds.minY - 100)}
width={toDomPrecision(bounds.width + 200)}
height={toDomPrecision(bounds.height + 200)}
opacity={0}
/>
)}
<path
d={path}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
</g>
{as && maskStartArrowhead && shape.props.fill !== 'none' && (
<ShapeFill d={as} color={shape.props.color} fill={shape.props.fill} />
)}
{ae && maskEndArrowhead && shape.props.fill !== 'none' && (
<ShapeFill d={ae} color={shape.props.color} fill={shape.props.fill} />
)}
{as && <path d={as} />}
{ae && <path d={ae} />}
</g>
<path d={path} className="tl-hitarea-stroke" />
2023-04-25 11:01:25 +00:00
</SVGContainer>
<ArrowTextLabel
id={shape.id}
text={shape.props.text}
font={shape.props.font}
size={shape.props.size}
position={info.middle}
width={labelSize?.w ?? 0}
labelColor={this.editor.getCssColor(shape.props.labelColor)}
2023-04-25 11:01:25 +00:00
/>
</>
)
}
indicator(shape: TLArrowShape) {
const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape)
2023-04-25 11:01:25 +00:00
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const labelSize = this.getLabelBounds(shape)
if (!info) return null
if (Vec2d.Equals(start, end)) return null
const strokeWidth = this.editor.getStrokeWidth(shape.props.size)
2023-04-25 11:01:25 +00:00
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const path = info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info)
const includeMask =
(as && info.start.arrowhead !== 'arrow') ||
(ae && info.end.arrowhead !== 'arrow') ||
labelSize !== null
const maskId = (shape.id + '_clip').replace(':', '_')
return (
<g>
{includeMask && (
<defs>
<mask id={maskId}>
<rect
x={bounds.minX - 100}
y={bounds.minY - 100}
width={bounds.w + 200}
height={bounds.h + 200}
fill="white"
/>
{labelSize && (
<rect
x={labelSize.x}
y={labelSize.y}
width={labelSize.w}
height={labelSize.h}
fill="black"
rx={4}
ry={4}
/>
)}
{as && (
<path
d={as}
fill={info.start.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
{ae && (
<path
d={ae}
fill={info.end.arrowhead === 'arrow' ? 'none' : 'black'}
stroke="none"
/>
)}
</mask>
</defs>
)}
{/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */}
<g {...(includeMask ? { mask: `url(#${maskId})` } : undefined)}>
{/* This rect needs to be here if we're creating a mask due to an svg quirk on Chrome */}
{includeMask && (
<rect
x={bounds.minX - 100}
y={bounds.minY - 100}
width={bounds.width + 200}
height={bounds.height + 200}
opacity={0}
/>
)}
<path d={path} />
</g>
{as && <path d={as} />}
{ae && <path d={ae} />}
{labelSize && (
<rect
x={labelSize.x}
y={labelSize.y}
width={labelSize.w}
height={labelSize.h}
rx={4}
ry={4}
/>
)}
</g>
)
}
@computed get labelBoundsCache(): ComputedCache<Box2d | null, TLArrowShape> {
return this.editor.store.createComputedCache('labelBoundsCache', (shape) => {
2023-04-25 11:01:25 +00:00
const info = this.getArrowInfo(shape)
const bounds = this.bounds(shape)
const { text, font, size } = shape.props
if (!info) return null
if (!text.trim()) return null
const { w, h } = this.editor.textMeasure.measureText(text, {
2023-04-25 11:01:25 +00:00
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size],
width: 'fit-content',
})
let width = w
let height = h
if (bounds.width > bounds.height) {
width = Math.max(Math.min(w, 64), Math.min(bounds.width - 64, w))
const { w: squishedWidth, h: squishedHeight } = this.editor.textMeasure.measureText(text, {
2023-04-25 11:01:25 +00:00
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size],
width: width + 'px',
})
width = squishedWidth
height = squishedHeight
}
if (width > 16 * ARROW_LABEL_FONT_SIZES[size]) {
width = 16 * ARROW_LABEL_FONT_SIZES[size]
const { w: squishedWidth, h: squishedHeight } = this.editor.textMeasure.measureText(text, {
2023-04-25 11:01:25 +00:00
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[font],
fontSize: ARROW_LABEL_FONT_SIZES[size],
width: width + 'px',
})
width = squishedWidth
height = squishedHeight
}
return new Box2d(
info.middle.x - (width + 8) / 2,
info.middle.y - (height + 8) / 2,
width + 8,
height + 8
)
})
}
getLabelBounds(shape: TLArrowShape): Box2d | null {
return this.labelBoundsCache.get(shape.id) || null
}
getEditingBounds = (shape: TLArrowShape): Box2d => {
return this.getLabelBounds(shape) ?? new Box2d()
}
onEditEnd: TLOnEditEndHandler<TLArrowShape> = (shape) => {
2023-04-25 11:01:25 +00:00
const {
id,
type,
props: { text },
} = shape
if (text.trimEnd() !== shape.props.text) {
this.editor.updateShapes([
2023-04-25 11:01:25 +00:00
{
id,
type,
props: {
text: text.trimEnd(),
2023-04-25 11:01:25 +00:00
},
},
])
}
}
toSvg(shape: TLArrowShape, font: string, colors: TLExportColors) {
const color = colors.fill[shape.props.color]
const info = this.getArrowInfo(shape)
const strokeWidth = this.editor.getStrokeWidth(shape.props.size)
2023-04-25 11:01:25 +00:00
// Group for arrow
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
if (!info) return g
// Arrowhead start path
const as = info.start.arrowhead && getArrowheadPathForType(info, 'start', strokeWidth)
// Arrowhead end path
const ae = info.end.arrowhead && getArrowheadPathForType(info, 'end', strokeWidth)
const bounds = this.bounds(shape)
const labelSize = this.getLabelBounds(shape)
const maskId = (shape.id + '_clip').replace(':', '_')
// If we have any arrowheads, then mask the arrowheads
if (as || ae || labelSize) {
2023-04-25 11:01:25 +00:00
// Create mask for arrowheads
// Create defs
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs')
// Create mask
const mask = document.createElementNS('http://www.w3.org/2000/svg', 'mask')
mask.id = maskId
// Create large white shape for mask
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect.setAttribute('x', bounds.minX - 100 + '')
rect.setAttribute('y', bounds.minY - 100 + '')
rect.setAttribute('width', bounds.width + 200 + '')
rect.setAttribute('height', bounds.height + 200 + '')
rect.setAttribute('fill', 'white')
mask.appendChild(rect)
// add arrowhead start mask
if (as) mask.appendChild(getArrowheadSvgMask(as, info.start.arrowhead))
// add arrowhead end mask
if (ae) mask.appendChild(getArrowheadSvgMask(ae, info.end.arrowhead))
// Mask out text label if text is present
if (labelSize) {
const labelMask = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
labelMask.setAttribute('x', labelSize.x + '')
labelMask.setAttribute('y', labelSize.y + '')
labelMask.setAttribute('width', labelSize.w + '')
labelMask.setAttribute('height', labelSize.h + '')
labelMask.setAttribute('fill', 'black')
mask.appendChild(labelMask)
}
defs.appendChild(mask)
g.appendChild(defs)
}
const g2 = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g2.setAttribute('mask', `url(#${maskId})`)
g.appendChild(g2)
// Dumb mask fix thing
const rect2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect2.setAttribute('x', '-100')
rect2.setAttribute('y', '-100')
rect2.setAttribute('width', bounds.width + 200 + '')
rect2.setAttribute('height', bounds.height + 200 + '')
rect2.setAttribute('fill', 'transparent')
rect2.setAttribute('stroke', 'none')
g2.appendChild(rect2)
// Arrowhead body path
const path = getArrowSvgPath(
info.isStraight ? getSolidStraightArrowPath(info) : getSolidCurvedArrowPath(info),
color,
strokeWidth
)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
info.isStraight ? info.length : Math.abs(info.bodyArc.length),
strokeWidth,
{
style: shape.props.dash,
}
)
path.setAttribute('stroke-dasharray', strokeDasharray)
path.setAttribute('stroke-dashoffset', strokeDashoffset)
g2.appendChild(path)
// Arrowhead start path
if (as) {
g.appendChild(
getArrowheadSvgPath(
as,
shape.props.color,
strokeWidth,
shape.props.arrowheadStart === 'arrow' ? 'none' : shape.props.fill,
colors
)
)
}
// Arrowhead end path
if (ae) {
g.appendChild(
getArrowheadSvgPath(
ae,
shape.props.color,
strokeWidth,
shape.props.arrowheadEnd === 'arrow' ? 'none' : shape.props.fill,
colors
)
)
}
// Text Label
if (labelSize) {
const opts = {
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
lineHeight: TEXT_PROPS.lineHeight,
fontFamily: font,
padding: 0,
textAlign: 'middle' as const,
Measure individual words instead of just line breaks for text exports (#1397) This diff fixes a number of issues with text export by completely overhauling how we approach laying out text in exports. Currently, we try to carefully replicate in-browser behaviour around line breaks and whitespace collapsing. We do this using an iterative algorithm that forces the browser to perform a layout for each word, and attempting to re-implement how the browser does things like whitespace collapsing & finding line break opportunities. Lots of export issues come from the fact that this is almost impossible to do well (short of sending a complete text layout algorithm & full unicode lookup tables). Luckily, the browser already has a complete text layout algorithm and full unicode lookup tables! In the new approach, we ask the browser to lay the text out once. Then, we use the [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to loop over every character in the rendered text and measure its position. These character positions are then grouped into "spans". A span is a contiguous range of either whitespace or non-whitespace characters, uninterrupted by any browser-inserting line breaks. When we come to render the SVG, each span gets its own `<tspan>` element, absolutely positioned according to where it ended up in the user's browser. This fixes a bunch of issues: **Misaligned text due to whitespace collapsing at line breaks** ![Kapture 2023-05-17 at 12 07 30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a) **Hyphenated text (or text with non-trivial/whitespace-based breaking rules like Thai) not splitting correctly** ![Kapture 2023-05-17 at 12 21 40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e) **Weird alignment issues in note shapes** ![Kapture 2023-05-17 at 12 24 59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e) **Frame labels not respecting multiple spaces & not truncating correctly** ![Kapture 2023-05-17 at 12 27 27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78) #### Quick note on browser compatibility This approach works well across all browsers, but in some cases actually _increases_ x-browser variance. Consider these screenshots of the same element (original above, export below): ![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de) Notice how on chrome, the whitespace at the end of each line of right-aligned text is preserved. On safari, it's collapsed. The safari option looks better - so our manual line-breaking/white-space-collapsing algorithm preferred safari's approach. That meant that in-app, this shape looks very slightly different from browser to browser. But out of the app, the exports would have been the same (although also note that hyphenation is broken). Now, because these shapes look different across browsers, the exports now look different across browsers too. We're relying on the host-browsers text layout algorithm, which means we'll faithfully reproduce any quirks/inconsistencies of that algorithm. I think this is an acceptable tradeoff. ### Change Type - [x] `patch` — Bug Fix ### Test Plan * Comprehensive testing of text in exports, paying close attention to details around white-space, line-breaking and alignment * Consider setting `tldrawDebugSvg = true` * Check text shapes, geo shapes with labels, arrow shapes with labels, note shapes, frame labels * Check different alignments and fonts (including vertical alignment) ### Release Notes - Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
width: labelSize.w - 8,
verticalTextAlign: 'middle' as const,
2023-04-25 11:01:25 +00:00
height: labelSize.h,
fontStyle: 'normal',
fontWeight: 'normal',
Measure individual words instead of just line breaks for text exports (#1397) This diff fixes a number of issues with text export by completely overhauling how we approach laying out text in exports. Currently, we try to carefully replicate in-browser behaviour around line breaks and whitespace collapsing. We do this using an iterative algorithm that forces the browser to perform a layout for each word, and attempting to re-implement how the browser does things like whitespace collapsing & finding line break opportunities. Lots of export issues come from the fact that this is almost impossible to do well (short of sending a complete text layout algorithm & full unicode lookup tables). Luckily, the browser already has a complete text layout algorithm and full unicode lookup tables! In the new approach, we ask the browser to lay the text out once. Then, we use the [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to loop over every character in the rendered text and measure its position. These character positions are then grouped into "spans". A span is a contiguous range of either whitespace or non-whitespace characters, uninterrupted by any browser-inserting line breaks. When we come to render the SVG, each span gets its own `<tspan>` element, absolutely positioned according to where it ended up in the user's browser. This fixes a bunch of issues: **Misaligned text due to whitespace collapsing at line breaks** ![Kapture 2023-05-17 at 12 07 30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a) **Hyphenated text (or text with non-trivial/whitespace-based breaking rules like Thai) not splitting correctly** ![Kapture 2023-05-17 at 12 21 40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e) **Weird alignment issues in note shapes** ![Kapture 2023-05-17 at 12 24 59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e) **Frame labels not respecting multiple spaces & not truncating correctly** ![Kapture 2023-05-17 at 12 27 27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78) #### Quick note on browser compatibility This approach works well across all browsers, but in some cases actually _increases_ x-browser variance. Consider these screenshots of the same element (original above, export below): ![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de) Notice how on chrome, the whitespace at the end of each line of right-aligned text is preserved. On safari, it's collapsed. The safari option looks better - so our manual line-breaking/white-space-collapsing algorithm preferred safari's approach. That meant that in-app, this shape looks very slightly different from browser to browser. But out of the app, the exports would have been the same (although also note that hyphenation is broken). Now, because these shapes look different across browsers, the exports now look different across browsers too. We're relying on the host-browsers text layout algorithm, which means we'll faithfully reproduce any quirks/inconsistencies of that algorithm. I think this is an acceptable tradeoff. ### Change Type - [x] `patch` — Bug Fix ### Test Plan * Comprehensive testing of text in exports, paying close attention to details around white-space, line-breaking and alignment * Consider setting `tldrawDebugSvg = true` * Check text shapes, geo shapes with labels, arrow shapes with labels, note shapes, frame labels * Check different alignments and fonts (including vertical alignment) ### Release Notes - Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
overflow: 'wrap' as const,
2023-04-25 11:01:25 +00:00
}
Measure individual words instead of just line breaks for text exports (#1397) This diff fixes a number of issues with text export by completely overhauling how we approach laying out text in exports. Currently, we try to carefully replicate in-browser behaviour around line breaks and whitespace collapsing. We do this using an iterative algorithm that forces the browser to perform a layout for each word, and attempting to re-implement how the browser does things like whitespace collapsing & finding line break opportunities. Lots of export issues come from the fact that this is almost impossible to do well (short of sending a complete text layout algorithm & full unicode lookup tables). Luckily, the browser already has a complete text layout algorithm and full unicode lookup tables! In the new approach, we ask the browser to lay the text out once. Then, we use the [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to loop over every character in the rendered text and measure its position. These character positions are then grouped into "spans". A span is a contiguous range of either whitespace or non-whitespace characters, uninterrupted by any browser-inserting line breaks. When we come to render the SVG, each span gets its own `<tspan>` element, absolutely positioned according to where it ended up in the user's browser. This fixes a bunch of issues: **Misaligned text due to whitespace collapsing at line breaks** ![Kapture 2023-05-17 at 12 07 30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a) **Hyphenated text (or text with non-trivial/whitespace-based breaking rules like Thai) not splitting correctly** ![Kapture 2023-05-17 at 12 21 40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e) **Weird alignment issues in note shapes** ![Kapture 2023-05-17 at 12 24 59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e) **Frame labels not respecting multiple spaces & not truncating correctly** ![Kapture 2023-05-17 at 12 27 27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78) #### Quick note on browser compatibility This approach works well across all browsers, but in some cases actually _increases_ x-browser variance. Consider these screenshots of the same element (original above, export below): ![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de) Notice how on chrome, the whitespace at the end of each line of right-aligned text is preserved. On safari, it's collapsed. The safari option looks better - so our manual line-breaking/white-space-collapsing algorithm preferred safari's approach. That meant that in-app, this shape looks very slightly different from browser to browser. But out of the app, the exports would have been the same (although also note that hyphenation is broken). Now, because these shapes look different across browsers, the exports now look different across browsers too. We're relying on the host-browsers text layout algorithm, which means we'll faithfully reproduce any quirks/inconsistencies of that algorithm. I think this is an acceptable tradeoff. ### Change Type - [x] `patch` — Bug Fix ### Test Plan * Comprehensive testing of text in exports, paying close attention to details around white-space, line-breaking and alignment * Consider setting `tldrawDebugSvg = true` * Check text shapes, geo shapes with labels, arrow shapes with labels, note shapes, frame labels * Check different alignments and fonts (including vertical alignment) ### Release Notes - Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
const textElm = createTextSvgElementFromSpans(
this.editor,
this.editor.textMeasure.measureTextSpans(shape.props.text, opts),
Measure individual words instead of just line breaks for text exports (#1397) This diff fixes a number of issues with text export by completely overhauling how we approach laying out text in exports. Currently, we try to carefully replicate in-browser behaviour around line breaks and whitespace collapsing. We do this using an iterative algorithm that forces the browser to perform a layout for each word, and attempting to re-implement how the browser does things like whitespace collapsing & finding line break opportunities. Lots of export issues come from the fact that this is almost impossible to do well (short of sending a complete text layout algorithm & full unicode lookup tables). Luckily, the browser already has a complete text layout algorithm and full unicode lookup tables! In the new approach, we ask the browser to lay the text out once. Then, we use the [`Range`](https://developer.mozilla.org/en-US/docs/Web/API/Range) API to loop over every character in the rendered text and measure its position. These character positions are then grouped into "spans". A span is a contiguous range of either whitespace or non-whitespace characters, uninterrupted by any browser-inserting line breaks. When we come to render the SVG, each span gets its own `<tspan>` element, absolutely positioned according to where it ended up in the user's browser. This fixes a bunch of issues: **Misaligned text due to whitespace collapsing at line breaks** ![Kapture 2023-05-17 at 12 07 30](https://github.com/tldraw/tldraw/assets/1489520/5ab66fe0-6ceb-45bb-8787-90ccb124664a) **Hyphenated text (or text with non-trivial/whitespace-based breaking rules like Thai) not splitting correctly** ![Kapture 2023-05-17 at 12 21 40](https://github.com/tldraw/tldraw/assets/1489520/d2d5fd13-3e79-48c4-8e76-ae2c70a6471e) **Weird alignment issues in note shapes** ![Kapture 2023-05-17 at 12 24 59](https://github.com/tldraw/tldraw/assets/1489520/a0e51d57-7c1c-490e-9952-b92417ffdf9e) **Frame labels not respecting multiple spaces & not truncating correctly** ![Kapture 2023-05-17 at 12 27 27](https://github.com/tldraw/tldraw/assets/1489520/39b2f53c-0180-460e-b10a-9fd955a6fa78) #### Quick note on browser compatibility This approach works well across all browsers, but in some cases actually _increases_ x-browser variance. Consider these screenshots of the same element (original above, export below): ![image](https://github.com/tldraw/tldraw/assets/1489520/5633b041-8cb3-4c92-bef6-4f3c202305de) Notice how on chrome, the whitespace at the end of each line of right-aligned text is preserved. On safari, it's collapsed. The safari option looks better - so our manual line-breaking/white-space-collapsing algorithm preferred safari's approach. That meant that in-app, this shape looks very slightly different from browser to browser. But out of the app, the exports would have been the same (although also note that hyphenation is broken). Now, because these shapes look different across browsers, the exports now look different across browsers too. We're relying on the host-browsers text layout algorithm, which means we'll faithfully reproduce any quirks/inconsistencies of that algorithm. I think this is an acceptable tradeoff. ### Change Type - [x] `patch` — Bug Fix ### Test Plan * Comprehensive testing of text in exports, paying close attention to details around white-space, line-breaking and alignment * Consider setting `tldrawDebugSvg = true` * Check text shapes, geo shapes with labels, arrow shapes with labels, note shapes, frame labels * Check different alignments and fonts (including vertical alignment) ### Release Notes - Add a brief release note for your PR here.
2023-05-22 15:10:03 +00:00
opts
)
2023-04-25 11:01:25 +00:00
textElm.setAttribute('fill', colors.fill[shape.props.labelColor])
const children = Array.from(textElm.children) as unknown as SVGTSpanElement[]
children.forEach((child) => {
const x = parseFloat(child.getAttribute('x') || '0')
const y = parseFloat(child.getAttribute('y') || '0')
child.setAttribute('x', x + 4 + labelSize!.x + 'px')
child.setAttribute('y', y + labelSize!.y + 'px')
})
const textBgEl = textElm.cloneNode(true) as SVGTextElement
textBgEl.setAttribute('stroke-width', '2')
textBgEl.setAttribute('fill', colors.background)
textBgEl.setAttribute('stroke', colors.background)
g.appendChild(textBgEl)
2023-04-25 11:01:25 +00:00
g.appendChild(textElm)
}
return g
}
}
function getArrowheadSvgMask(d: string, arrowhead: TLArrowheadType) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', arrowhead === 'arrow' ? 'none' : 'black')
path.setAttribute('stroke', 'none')
return path
}
function getArrowSvgPath(d: string, color: string, strokeWidth: number) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
path.setAttribute('stroke', color)
path.setAttribute('stroke-width', strokeWidth + '')
return path
}
function getArrowheadSvgPath(
d: string,
color: TLColorType,
strokeWidth: number,
fill: TLFillType,
colors: TLExportColors
) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', d)
path.setAttribute('fill', 'none')
path.setAttribute('stroke', colors.fill[color])
path.setAttribute('stroke-width', strokeWidth + '')
// Get the fill element, if any
const shapeFill = getShapeFillSvg({
d,
fill,
color,
colors,
})
if (shapeFill) {
// If there is a fill element, return a group containing the fill and the path
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(shapeFill)
g.appendChild(path)
return g
} else {
// Otherwise, just return the path
return path
}
}
function isPrecise(normalizedAnchor: Vec2dModel) {
return normalizedAnchor.x !== 0.5 || normalizedAnchor.y !== 0.5
}