import { Box2d, getPointOnCircle, linesIntersect, longAngleDist, Matrix2d, pointInPolygon, shortAngleDist, toDomPrecision, Vec2d, VecLike, } from '@tldraw/primitives' import { ComputedCache } from '@tldraw/store' 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' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' 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 { static override type = 'arrow' override canEdit = () => true override canBind = () => false override isClosed = () => false override hideResizeHandles: TLShapeUtilFlag = () => true override hideRotateHandle: TLShapeUtilFlag = () => true override hideSelectionBoundsFg: TLShapeUtilFlag = () => true override hideSelectionBoundsBg: TLShapeUtilFlag = () => 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( 'arrow infoCache', (shape) => { return getIsArrowStraight(shape) ? getStraightArrowInfo(this.editor, shape) : getCurvedArrowInfo(this.editor, shape) } ) } 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 = (shape, { handle, isPrecise }) => { const next = deepCopy(shape) switch (handle.id) { case 'start': case 'end': { const pageTransform = this.editor.getPageTransformById(next.id)! const pointInPageSpace = Matrix2d.applyToPoint(pageTransform, handle) if (this.editor.inputs.ctrlKey) { 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) }) ) 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, } } } break } case 'middle': { const { start, end } = getArrowTerminalsInArrowSpace(this.editor, next) 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 = (shape) => { 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)) ) { return } startBinding = null endBinding = null const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape) 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 = (shape, info) => { const { scaleX, scaleY } = info const terminals = getArrowTerminalsInArrowSpace(this.editor, shape) const { start, end } = deepCopy(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 | 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 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 } 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 const shouldDisplayHandles = this.editor.isInAny( 'select.idle', 'select.pointing_handle', 'select.dragging_handle', 'arrow.dragging' ) && !this.editor.isReadOnly 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(() => { return this.editor.isSafari ? (globalRenderIndex += 1) : 0 // eslint-disable-next-line react-hooks/exhaustive-deps }, [shape]) if (!info?.isValid) return null const strokeWidth = this.editor.getStrokeWidth(shape.props.size) 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' ? ( ) : 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 const maskId = (shape.id + '_clip_' + changeIndex).replace(':', '_') return ( <> {includeMask && ( {labelSize && ( )} {as && maskStartArrowhead && ( )} {ae && maskEndArrowhead && ( )} )} {handlePath} {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} {/* This rect needs to be here if we're creating a mask due to an svg quirk on Chrome */} {includeMask && ( )} {as && maskStartArrowhead && shape.props.fill !== 'none' && ( )} {ae && maskEndArrowhead && shape.props.fill !== 'none' && ( )} {as && } {ae && } ) } indicator(shape: TLArrowShape) { const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape) 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) 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 ( {includeMask && ( {labelSize && ( )} {as && ( )} {ae && ( )} )} {/* firefox will clip if you provide a maskURL even if there is no mask matching that URL in the DOM */} {/* This rect needs to be here if we're creating a mask due to an svg quirk on Chrome */} {includeMask && ( )} {as && } {ae && } {labelSize && ( )} ) } @computed get labelBoundsCache(): ComputedCache { return this.editor.store.createComputedCache('labelBoundsCache', (shape) => { 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, { ...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, { ...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, { ...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 = (shape) => { const { id, type, props: { text }, } = shape if (text.trimEnd() !== shape.props.text) { this.editor.updateShapes([ { id, type, props: { text: text.trimEnd(), }, }, ]) } } 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) // 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) { // 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, width: labelSize.w - 8, verticalTextAlign: 'middle' as const, height: labelSize.h, fontStyle: 'normal', fontWeight: 'normal', overflow: 'wrap' as const, } const textElm = createTextSvgElementFromSpans( this.editor, this.editor.textMeasure.measureTextSpans(shape.props.text, opts), opts ) 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) 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 }