import { Arc2d, Box, DefaultFontFamilies, Edge2d, Geometry2d, Group2d, Rectangle2d, SVGContainer, ShapeUtil, SvgExportContext, TLArrowShape, TLArrowShapeArrowheadStyle, TLArrowShapeProps, TLDefaultColorStyle, TLDefaultColorTheme, TLDefaultFillStyle, TLHandle, TLOnEditEndHandler, TLOnHandleDragHandler, TLOnResizeHandler, TLOnTranslateHandler, TLOnTranslateStartHandler, TLShapePartial, TLShapeUtilCanvasSvgDef, TLShapeUtilFlag, Vec, arrowShapeMigrations, arrowShapeProps, deepCopy, getArrowTerminalsInArrowSpace, getDefaultColorTheme, mapObjectMapValues, objectMapEntries, toDomPrecision, useIsEditing, } from '@tldraw/editor' import React from 'react' import { ShapeFill, getShapeFillSvg, useDefaultColorTheme } from '../shared/ShapeFill' import { createTextSvgElementFromSpans } from '../shared/createTextSvgElementFromSpans' import { ARROW_LABEL_FONT_SIZES, STROKE_SIZES, TEXT_PROPS } from '../shared/default-shape-constants' import { getFillDefForCanvas, getFillDefForExport, getFontDefForExport, } from '../shared/defaultStyleDefs' import { getPerfectDashProps } from '../shared/getPerfectDashProps' import { getArrowLabelPosition } from './arrowLabel' import { getArrowheadPathForType } from './arrowheads' import { getCurvedArrowHandlePath, getSolidCurvedArrowPath, getSolidStraightArrowPath, getStraightArrowHandlePath, } from './arrowpaths' import { ArrowTextLabel } from './components/ArrowTextLabel' let globalRenderIndex = 0 enum ARROW_HANDLES { START = 'start', MIDDLE = 'middle', END = 'end', } /** @public */ export class ArrowShapeUtil extends ShapeUtil { static override type = 'arrow' as const static override props = arrowShapeProps static override migrations = arrowShapeMigrations override canEdit = () => true override canBind = () => false override canSnap = () => false override hideResizeHandles: TLShapeUtilFlag = () => true override hideRotateHandle: TLShapeUtilFlag = () => true override hideSelectionBoundsBg: TLShapeUtilFlag = () => true override hideSelectionBoundsFg: TLShapeUtilFlag = () => true override getDefaultProps(): 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: 2, y: 0 }, arrowheadStart: 'none', arrowheadEnd: 'arrow', text: '', labelPosition: 0.5, font: 'draw', } } getGeometry(shape: TLArrowShape) { const info = this.editor.getArrowInfo(shape)! const debugGeom: Geometry2d[] = [] const bodyGeom = info.isStraight ? new Edge2d({ start: Vec.From(info.start.point), end: Vec.From(info.end.point), }) : 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, }) let labelGeom if (shape.props.text.trim()) { const labelPosition = getArrowLabelPosition(this.editor, shape) debugGeom.push(...labelPosition.debugGeom) labelGeom = new Rectangle2d({ x: labelPosition.box.x, y: labelPosition.box.y, width: labelPosition.box.w, height: labelPosition.box.h, isFilled: true, isLabel: true, }) } return new Group2d({ children: [...(labelGeom ? [bodyGeom, labelGeom] : [bodyGeom]), ...debugGeom], isSnappable: false, }) } private getLength(shape: TLArrowShape): number { const info = this.editor.getArrowInfo(shape)! return info.isStraight ? Vec.Dist(info.start.handle, info.end.handle) : Math.abs(info.handleArc.length) } override getHandles(shape: TLArrowShape): TLHandle[] { const info = this.editor.getArrowInfo(shape)! return [ { id: ARROW_HANDLES.START, type: 'vertex', index: 'a0', x: info.start.handle.x, y: info.start.handle.y, canBind: true, }, { id: ARROW_HANDLES.MIDDLE, type: 'virtual', index: 'a2', x: info.middle.x, y: info.middle.y, canBind: false, }, { id: ARROW_HANDLES.END, type: 'vertex', index: 'a3', x: info.end.handle.x, y: info.end.handle.y, canBind: true, }, ].filter(Boolean) as TLHandle[] } override onHandleDrag: TLOnHandleDragHandler = (shape, { handle, isPrecise }) => { const handleId = handle.id as ARROW_HANDLES if (handleId === ARROW_HANDLES.MIDDLE) { // Bending the arrow... const { start, end } = getArrowTerminalsInArrowSpace(this.editor, shape) const delta = Vec.Sub(end, start) const v = Vec.Per(delta) const med = Vec.Med(end, start) const A = Vec.Sub(med, v) const B = Vec.Add(med, v) const point = Vec.NearestPointOnLineSegment(A, B, handle, false) let bend = Vec.Dist(point, med) if (Vec.Clockwise(point, end, med)) bend *= -1 return { id: shape.id, type: shape.type, props: { bend } } } // Start or end, pointing the arrow... const next = deepCopy(shape) as TLArrowShape if (this.editor.inputs.ctrlKey) { // todo: maybe double check that this isn't equal to the other handle too? // Skip binding next.props[handleId] = { type: 'point', x: handle.x, y: handle.y, } return next } const point = this.editor.getShapePageTransform(shape.id)!.applyToPoint(handle) const target = this.editor.getShapeAtPoint(point, { hitInside: true, hitFrameInside: true, margin: 0, filter: (targetShape) => { return !targetShape.isLocked && this.editor.getShapeUtil(targetShape).canBind(targetShape) }, }) if (!target) { // todo: maybe double check that this isn't equal to the other handle too? next.props[handleId] = { type: 'point', x: handle.x, y: handle.y, } return next } // we've got a target! the handle is being dragged over a shape, bind to it const targetGeometry = this.editor.getShapeGeometry(target) const targetBounds = Box.ZeroFix(targetGeometry.bounds) const pageTransform = this.editor.getShapePageTransform(next.id)! const pointInPageSpace = pageTransform.applyToPoint(handle) const pointInTargetSpace = this.editor.getPointInShapeSpace(target, pointInPageSpace) let precise = isPrecise if (!precise) { // If we're switching to a new bound shape, then precise only if moving slowly const prevHandle = next.props[handleId] if ( prevHandle.type === 'point' || (prevHandle.type === 'binding' && target.id !== prevHandle.boundShapeId) ) { precise = this.editor.inputs.pointerVelocity.len() < 0.5 } } if (!isPrecise) { if (!targetGeometry.isClosed) { precise = true } // Double check that we're not going to be doing an imprecise snap on // the same shape twice, as this would result in a zero length line const otherHandle = next.props[handleId === ARROW_HANDLES.START ? ARROW_HANDLES.END : ARROW_HANDLES.START] if ( otherHandle.type === 'binding' && target.id === otherHandle.boundShapeId && otherHandle.isPrecise ) { precise = true } } const normalizedAnchor = { x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width, y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height, } if (precise) { // Turn off precision if we're within a certain distance to the center of the shape. // 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 if ( Vec.Dist(pointInTargetSpace, targetBounds.center) < Math.max(4, Math.min(Math.min(targetBounds.width, targetBounds.height) * 0.15, 16)) / this.editor.getZoomLevel() ) { normalizedAnchor.x = 0.5 normalizedAnchor.y = 0.5 } } next.props[handleId] = { type: 'binding', boundShapeId: target.id, normalizedAnchor: normalizedAnchor, isPrecise: precise, isExact: this.editor.inputs.altKey, } if (next.props.start.type === 'binding' && next.props.end.type === 'binding') { if (next.props.start.boundShapeId === next.props.end.boundShapeId) { if (Vec.Equals(next.props.start.normalizedAnchor, next.props.end.normalizedAnchor)) { next.props.end.normalizedAnchor.x += 0.05 } } } return next } override onTranslateStart: TLOnTranslateStartHandler = (shape) => { const startBindingId = shape.props.start.type === 'binding' ? shape.props.start.boundShapeId : null const endBindingId = shape.props.end.type === 'binding' ? shape.props.end.boundShapeId : null const terminalsInArrowSpace = getArrowTerminalsInArrowSpace(this.editor, shape) const shapePageTransform = this.editor.getShapePageTransform(shape.id)! // If at least one bound shape is in the selection, do nothing; // If no bound shapes are in the selection, unbind any bound shapes const selectedShapeIds = this.editor.getSelectedShapeIds() if ( (startBindingId && (selectedShapeIds.includes(startBindingId) || this.editor.isAncestorSelected(startBindingId))) || (endBindingId && (selectedShapeIds.includes(endBindingId) || this.editor.isAncestorSelected(endBindingId))) ) { return } let result = shape // When we start translating shapes, record where their bindings were in page space so we // can maintain them as we translate the arrow shapeAtTranslationStart.set(shape, { pagePosition: shapePageTransform.applyToPoint(shape), terminalBindings: mapObjectMapValues(terminalsInArrowSpace, (terminalName, point) => { const terminal = shape.props[terminalName] if (terminal.type !== 'binding') return null return { binding: terminal, shapePosition: point, pagePosition: shapePageTransform.applyToPoint(point), } }), }) for (const handleName of [ARROW_HANDLES.START, ARROW_HANDLES.END] as const) { const terminal = shape.props[handleName] if (terminal.type !== 'binding') continue result = { ...shape, props: { ...shape.props, [handleName]: { ...terminal, isPrecise: true } }, } } return result } override onTranslate?: TLOnTranslateHandler = (initialShape, shape) => { const atTranslationStart = shapeAtTranslationStart.get(initialShape) if (!atTranslationStart) return const shapePageTransform = this.editor.getShapePageTransform(shape.id)! const pageDelta = Vec.Sub( shapePageTransform.applyToPoint(shape), atTranslationStart.pagePosition ) let result = shape for (const [terminalName, terminalBinding] of objectMapEntries( atTranslationStart.terminalBindings )) { if (!terminalBinding) continue const newPagePoint = Vec.Add(terminalBinding.pagePosition, Vec.Mul(pageDelta, 0.5)) const newTarget = this.editor.getShapeAtPoint(newPagePoint, { hitInside: true, hitFrameInside: true, margin: 0, filter: (targetShape) => { return !targetShape.isLocked && this.editor.getShapeUtil(targetShape).canBind(targetShape) }, }) if (newTarget?.id === terminalBinding.binding.boundShapeId) { const targetBounds = Box.ZeroFix(this.editor.getShapeGeometry(newTarget).bounds) const pointInTargetSpace = this.editor.getPointInShapeSpace(newTarget, newPagePoint) const normalizedAnchor = { x: (pointInTargetSpace.x - targetBounds.minX) / targetBounds.width, y: (pointInTargetSpace.y - targetBounds.minY) / targetBounds.height, } result = { ...result, props: { ...result.props, [terminalName]: { ...terminalBinding.binding, isPrecise: true, normalizedAnchor }, }, } } else { result = { ...result, props: { ...result.props, [terminalName]: { type: 'point', x: terminalBinding.shapePosition.x, y: terminalBinding.shapePosition.y, }, }, } } } return result } override 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 } override onDoubleClickHandle = ( shape: TLArrowShape, handle: TLHandle ): TLShapePartial | void => { switch (handle.id) { case ARROW_HANDLES.START: { return { id: shape.id, type: shape.type, props: { ...shape.props, arrowheadStart: shape.props.arrowheadStart === 'none' ? 'arrow' : 'none', }, } } case ARROW_HANDLES.END: { return { id: shape.id, type: shape.type, props: { ...shape.props, arrowheadEnd: shape.props.arrowheadEnd === 'none' ? 'arrow' : 'none', }, } } } } component(shape: TLArrowShape) { // Not a class component, but eslint can't tell that :( // eslint-disable-next-line react-hooks/rules-of-hooks const theme = useDefaultColorTheme() const onlySelectedShape = this.editor.getOnlySelectedShape() const shouldDisplayHandles = this.editor.isInAny( 'select.idle', 'select.pointing_handle', 'select.dragging_handle', 'select.translating', 'arrow.dragging' ) && !this.editor.getInstanceState().isReadonly const info = this.editor.getArrowInfo(shape) const bounds = Box.ZeroFix(this.editor.getShapeGeometry(shape).bounds) // eslint-disable-next-line react-hooks/rules-of-hooks const changeIndex = React.useMemo(() => { return this.editor.environment.isSafari ? (globalRenderIndex += 1) : 0 // eslint-disable-next-line react-hooks/exhaustive-deps }, [shape]) if (!info?.isValid) return null const strokeWidth = STROKE_SIZES[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 | React.JSX.Element = null if (onlySelectedShape === shape && shouldDisplayHandles) { const sw = 2 const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(this.getLength(shape), 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 labelPosition = getArrowLabelPosition(this.editor, shape) const maskStartArrowhead = !( info.start.arrowhead === 'none' || info.start.arrowhead === 'arrow' ) const maskEndArrowhead = !(info.end.arrowhead === 'none' || info.end.arrowhead === 'arrow') // 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 ( <> {/* Yep */} {shape.props.text.trim() && ( )} {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 */} {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.editor.getArrowInfo(shape) const geometry = this.editor.getShapeGeometry(shape) const bounds = geometry.bounds const labelGeometry = shape.props.text.trim() ? (geometry.children[1] as Rectangle2d) : null // eslint-disable-next-line react-hooks/rules-of-hooks const isEditing = useIsEditing(shape.id) if (!info) return null if (Vec.Equals(start, end)) return null const strokeWidth = STROKE_SIZES[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') || !!labelGeometry const maskId = (shape.id + '_clip').replace(':', '_') if (isEditing && labelGeometry) { return ( ) } return ( {includeMask && ( {labelGeometry && ( )} {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 && } {labelGeometry && ( )} ) } override onEditEnd: TLOnEditEndHandler = (shape) => { const { id, type, props: { text }, } = shape if (text.trimEnd() !== shape.props.text) { this.editor.updateShapes([ { id, type, props: { text: text.trimEnd(), }, }, ]) } } override toSvg(shape: TLArrowShape, ctx: SvgExportContext) { const theme = getDefaultColorTheme({ isDarkMode: this.editor.user.getIsDarkMode() }) ctx.addExportDef(getFillDefForExport(shape.props.fill, theme)) const color = theme[shape.props.color].solid const info = this.editor.getArrowInfo(shape) const strokeWidth = STROKE_SIZES[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 geometry = this.editor.getShapeGeometry(shape) const bounds = geometry.bounds const labelGeometry = shape.props.text.trim() ? (geometry.children[1] as Rectangle2d) : null const maskId = (shape.id + '_clip').replace(':', '_') // If we have any arrowheads, then mask the arrowheads if (as || ae || !!labelGeometry) { // 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 (labelGeometry) { const labelMask = document.createElementNS('http://www.w3.org/2000/svg', 'rect') labelMask.setAttribute('x', labelGeometry.x + '') labelMask.setAttribute('y', labelGeometry.y + '') labelMask.setAttribute('width', labelGeometry.w + '') labelMask.setAttribute('height', labelGeometry.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, theme ) ) } // Arrowhead end path if (ae) { g.appendChild( getArrowheadSvgPath( ae, shape.props.color, strokeWidth, shape.props.arrowheadEnd === 'arrow' ? 'none' : shape.props.fill, theme ) ) } // Text Label if (labelGeometry) { ctx.addExportDef(getFontDefForExport(shape.props.font)) const opts = { fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size], lineHeight: TEXT_PROPS.lineHeight, fontFamily: DefaultFontFamilies[shape.props.font], padding: 0, textAlign: 'middle' as const, width: labelGeometry.w - 8, verticalTextAlign: 'middle' as const, height: labelGeometry.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', theme[shape.props.labelColor].solid) 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 + labelGeometry.x + 'px') child.setAttribute('y', y + labelGeometry.y + 'px') }) const textBgEl = textElm.cloneNode(true) as SVGTextElement textBgEl.setAttribute('stroke-width', '2') textBgEl.setAttribute('fill', theme.background) textBgEl.setAttribute('stroke', theme.background) g.appendChild(textBgEl) g.appendChild(textElm) } return g } override getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[] { return [getFillDefForCanvas()] } } function getArrowheadSvgMask(d: string, arrowhead: TLArrowShapeArrowheadStyle) { 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: TLDefaultColorStyle, strokeWidth: number, fill: TLDefaultFillStyle, theme: TLDefaultColorTheme ) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') path.setAttribute('d', d) path.setAttribute('fill', 'none') path.setAttribute('stroke', theme[color].solid) path.setAttribute('stroke-width', strokeWidth + '') // Get the fill element, if any const shapeFill = getShapeFillSvg({ d, fill, color, theme, }) 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 } } const shapeAtTranslationStart = new WeakMap< TLArrowShape, { pagePosition: Vec terminalBindings: Record< 'start' | 'end', { pagePosition: Vec shapePosition: Vec binding: Extract } | null > } >()