// oxlint-disable typescript/no-empty-object-type import { Group2d, HandleSnapGeometry, SVGContainer, ShapeUtil, SvgExportContext, TLHandle, TLHandleDragInfo, TLLineShape, TLLineShapePoint, TLResizeInfo, Vec, WeakCache, ZERO_INDEX_KEY, assert, getColorValue, getIndexAbove, getIndexBetween, getIndices, lerp, lineShapeMigrations, lineShapeProps, mapObjectMapValues, maybeSnapToGrid, sortByIndex, useColorMode, } from '@tldraw/editor' import { STROKE_SIZES } from '../shared/default-shape-constants' import { ShapeOptionsWithDisplayValues, getDisplayValues } from '../shared/getDisplayValues' import { PathBuilder, PathBuilderGeometry2d } from '../shared/PathBuilder' const handlesCache = new WeakCache() /** @public */ export interface LineShapeUtilDisplayValues { strokeColor: string strokeWidth: number } /** @public */ export interface LineShapeOptions extends ShapeOptionsWithDisplayValues< TLLineShape, LineShapeUtilDisplayValues > {} /** @public */ export class LineShapeUtil extends ShapeUtil { static override type = 'line' as const static override props = lineShapeProps static override migrations = lineShapeMigrations override options: LineShapeOptions = { getDefaultDisplayValues(_editor, shape, theme, colorMode): LineShapeUtilDisplayValues { const { color, size } = shape.props return { strokeColor: getColorValue(theme.colors[colorMode], color, 'solid'), strokeWidth: theme.strokeWidth * STROKE_SIZES[size], } }, getCustomDisplayValues(): Partial { return {} }, } override hideResizeHandles(shape: TLLineShape) { return true } override hideRotateHandle(shape: TLLineShape) { return true } override hideSelectionBoundsFg(shape: TLLineShape) { return true } override hideSelectionBoundsBg(shape: TLLineShape) { return true } override hideInMinimap() { return true } override getDefaultProps(): TLLineShape['props'] { const [start, end] = getIndices(2) return { dash: 'draw', size: 'm', color: 'black', spline: 'line', points: { [start]: { id: start, index: start, x: 0, y: 0 }, [end]: { id: end, index: end, x: 0.1, y: 0.1 }, }, scale: 1, } } getGeometry(shape: TLLineShape) { // todo: should we have min size? const geometry = getPathForLineShape(shape).toGeometry() assert(geometry instanceof PathBuilderGeometry2d) return geometry } override getHandles(shape: TLLineShape) { return handlesCache.get(shape.props, () => { const spline = this.getGeometry(shape) const points = linePointsToArray(shape) const results: TLHandle[] = points.map((point) => ({ ...point, id: point.index, type: 'vertex', canSnap: true, })) for (let i = 0; i < points.length - 1; i++) { const index = getIndexBetween(points[i].index, points[i + 1].index) const segment = spline.getSegments()[i] const point = segment.interpolateAlongEdge(0.5) results.push({ id: index, type: 'create', index, x: point.x, y: point.y, canSnap: true, }) } return results.sort(sortByIndex) }) } // Events override onResize(shape: TLLineShape, info: TLResizeInfo) { const { scaleX, scaleY } = info return { props: { points: mapObjectMapValues(shape.props.points, (_, { id, index, x, y }) => ({ id, index, x: x * scaleX, y: y * scaleY, })), }, } } override onBeforeCreate(next: TLLineShape): void | TLLineShape { const { props: { points }, } = next const pointKeys = Object.keys(points) if (pointKeys.length < 2) { return } const firstPoint = points[pointKeys[0]] const allSame = pointKeys.every((key) => { const point = points[key] return point.x === firstPoint.x && point.y === firstPoint.y }) if (allSame) { const lastKey = pointKeys[pointKeys.length - 1] points[lastKey] = { ...points[lastKey], x: points[lastKey].x + 0.1, y: points[lastKey].y + 0.1, } return next } return } override onHandleDrag(shape: TLLineShape, { handle }: TLHandleDragInfo) { const newPoint = maybeSnapToGrid(new Vec(handle.x, handle.y), this.editor) return { ...shape, props: { ...shape.props, points: { ...shape.props.points, [handle.id]: { id: handle.id, index: handle.index, x: newPoint.x, y: newPoint.y }, }, }, } } override onHandleDragStart(shape: TLLineShape, { handle }: TLHandleDragInfo) { // For line shapes, if we're dragging a "create" handle, then // create a new vertex handle at that point; and make this handle // the handle that we're dragging. if (handle.type === 'create') { return { ...shape, props: { ...shape.props, points: { ...shape.props.points, [handle.index]: { id: handle.index, index: handle.index, x: handle.x, y: handle.y }, }, }, } } return } component(shape: TLLineShape) { // eslint-disable-next-line react-hooks/rules-of-hooks const colorMode = useColorMode() const dv = getDisplayValues(this, shape, colorMode) return ( ) } override getIndicatorPath(shape: TLLineShape): Path2D { const strokeWidth = getDisplayValues(this, shape).strokeWidth * shape.props.scale const path = getPathForLineShape(shape) const { dash } = shape.props return path.toPath2D({ style: dash === 'draw' ? 'draw' : 'solid', strokeWidth: 1, passes: 1, randomSeed: shape.id, offset: 0, roundness: strokeWidth * 2, }) } override toSvg(shape: TLLineShape, ctx: SvgExportContext) { const dv = getDisplayValues(this, shape, ctx.colorMode) return ( ) } override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry { const points = linePointsToArray(shape) return { points, getSelfSnapPoints: (handle) => { const index = this.getHandles(shape) .filter((h) => h.type === 'vertex') .findIndex((h) => h.id === handle.id)! // We want to skip the current and adjacent handles return points.filter((_, i) => Math.abs(i - index) > 1).map(Vec.From) }, getSelfSnapOutline: (handle) => { // We want to skip the segments that include the handle, so // find the index of the handle that shares the same index property // as the initial dragging handle; this catches a quirk of create handles const index = this.getHandles(shape) .filter((h) => h.type === 'vertex') .findIndex((h) => h.id === handle.id)! // Get all the outline segments from the shape that don't include the handle const segments = this.getGeometry(shape) .getSegments() .filter((_, i) => i !== index - 1 && i !== index) if (!segments.length) return null return new Group2d({ children: segments }) }, } } override getInterpolatedProps( startShape: TLLineShape, endShape: TLLineShape, t: number ): TLLineShape['props'] { const startPoints = linePointsToArray(startShape) const endPoints = linePointsToArray(endShape) const pointsToUseStart: TLLineShapePoint[] = [] const pointsToUseEnd: TLLineShapePoint[] = [] let index = ZERO_INDEX_KEY if (startPoints.length > endPoints.length) { // we'll need to expand points for (let i = 0; i < startPoints.length; i++) { pointsToUseStart[i] = { ...startPoints[i] } if (endPoints[i] === undefined) { pointsToUseEnd[i] = { ...endPoints[endPoints.length - 1], id: index } } else { pointsToUseEnd[i] = { ...endPoints[i], id: index } } index = getIndexAbove(index) } } else if (endPoints.length > startPoints.length) { // we'll need to converge points for (let i = 0; i < endPoints.length; i++) { pointsToUseEnd[i] = { ...endPoints[i] } if (startPoints[i] === undefined) { pointsToUseStart[i] = { ...startPoints[startPoints.length - 1], id: index, } } else { pointsToUseStart[i] = { ...startPoints[i], id: index } } index = getIndexAbove(index) } } else { // noop, easy for (let i = 0; i < endPoints.length; i++) { pointsToUseStart[i] = startPoints[i] pointsToUseEnd[i] = endPoints[i] } } return { ...(t > 0.5 ? endShape.props : startShape.props), points: Object.fromEntries( pointsToUseStart.map((point, i) => { const endPoint = pointsToUseEnd[i] return [ point.id, { ...point, x: lerp(point.x, endPoint.x, t), y: lerp(point.y, endPoint.y, t), }, ] }) ), scale: lerp(startShape.props.scale, endShape.props.scale, t), } } } function linePointsToArray(shape: TLLineShape) { return Object.values(shape.props.points).sort(sortByIndex) } const pathCache = new WeakCache() function getPathForLineShape(shape: TLLineShape): PathBuilder { return pathCache.get(shape, () => { const points = linePointsToArray(shape).map(Vec.From) switch (shape.props.spline) { case 'cubic': { return PathBuilder.cubicSplineThroughPoints(points, { endOffsets: 0 }) } case 'line': { return PathBuilder.lineThroughPoints(points, { endOffsets: 0 }) } } }) } function LineShapeSvg({ shape, shouldScale = false, forceSolid = false, strokeColor, strokeWidth: baseStrokeWidth, }: { shape: TLLineShape shouldScale?: boolean forceSolid?: boolean strokeColor: string strokeWidth: number }) { const path = getPathForLineShape(shape) const { dash } = shape.props const scaleFactor = 1 / shape.props.scale const scale = shouldScale ? scaleFactor : 1 const strokeWidth = baseStrokeWidth * shape.props.scale return path.toSvg({ style: dash, strokeWidth, forceSolid, randomSeed: shape.id, props: { transform: `scale(${scale})`, stroke: strokeColor, fill: 'none', }, }) }