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

353 wiersze
9.0 KiB
TypeScript

/* eslint-disable react-hooks/rules-of-hooks */
import { getIndexBetween, sortByIndex } from '@tldraw/indices'
import {
CubicSpline2d,
Polyline2d,
Vec2d,
VecLike,
getDrawLinePathData,
intersectLineSegmentPolyline,
pointNearToPolyline,
} from '@tldraw/primitives'
import { TLHandle, TLLineShape } from '@tldraw/tlschema'
import { deepCopy } from '@tldraw/utils'
import { SVGContainer } from '../../../components/SVGContainer'
import { WeakMapCache } from '../../../utils/WeakMapCache'
import { ShapeUtil, TLOnHandleChangeHandler, TLOnResizeHandler } from '../ShapeUtil'
import { ShapeFill } from '../shared/ShapeFill'
import { TLExportColors } from '../shared/TLExportColors'
import { getPerfectDashProps } from '../shared/getPerfectDashProps'
import { useForceSolid } from '../shared/useForceSolid'
import { getLineDrawPath, getLineIndicatorPath, getLinePoints } from './components/getLinePath'
import { getLineSvg } from './components/getLineSvg'
const splinesCache = new WeakMapCache<TLLineShape['props'], CubicSpline2d | Polyline2d>()
const handlesCache = new WeakMapCache<TLLineShape['props'], TLHandle[]>()
/** @public */
export class LineShapeUtil extends ShapeUtil<TLLineShape> {
static override type = 'line'
override hideResizeHandles = () => true
override hideRotateHandle = () => true
override hideSelectionBoundsBg = () => true
override hideSelectionBoundsFg = () => true
override isClosed = () => false
override defaultProps(): TLLineShape['props'] {
return {
dash: 'draw',
size: 'm',
color: 'black',
spline: 'line',
handles: {
start: {
id: 'start',
type: 'vertex',
canBind: false,
index: 'a1',
x: 0,
y: 0,
},
end: {
id: 'end',
type: 'vertex',
canBind: false,
index: 'a2',
x: 0,
y: 0,
},
},
}
}
getBounds(shape: TLLineShape) {
// todo: should we have min size?
const spline = getSplineForLineShape(shape)
return spline.bounds
}
getCenter(shape: TLLineShape) {
return this.bounds(shape).center
}
getHandles(shape: TLLineShape) {
return handlesCache.get(shape.props, () => {
const handles = shape.props.handles
const spline = getSplineForLineShape(shape)
const sortedHandles = Object.values(handles).sort(sortByIndex)
const results = sortedHandles.slice()
// Add "hint" handles between each vertex handle
for (let i = 0; i < spline.segments.length; i++) {
const segment = spline.segments[i]
const point = segment.getPoint(0.5)
const index = getIndexBetween(sortedHandles[i].index, sortedHandles[i + 1].index)
results.push({
id: `mid-${i}`,
type: 'create',
index,
x: point.x,
y: point.y,
})
}
return results.sort(sortByIndex)
})
}
getOutline(shape: TLLineShape) {
return getLinePoints(getSplineForLineShape(shape))
}
// Events
onResize: TLOnResizeHandler<TLLineShape> = (shape, info) => {
const { scaleX, scaleY } = info
const handles = deepCopy(shape.props.handles)
Object.values(shape.props.handles).forEach(({ id, x, y }) => {
handles[id].x = x * scaleX
handles[id].y = y * scaleY
})
return {
props: {
handles,
},
}
}
onHandleChange: TLOnHandleChangeHandler<TLLineShape> = (shape, { handle }) => {
const next = deepCopy(shape)
switch (handle.id) {
case 'start':
case 'end': {
next.props.handles[handle.id] = {
...next.props.handles[handle.id],
x: handle.x,
y: handle.y,
}
break
}
default: {
const id = 'handle:' + handle.index
const existing = shape.props.handles[id]
if (existing) {
next.props.handles[id] = {
...existing,
x: handle.x,
y: handle.y,
}
} else {
next.props.handles[id] = {
id,
type: 'vertex',
canBind: false,
index: handle.index,
x: handle.x,
y: handle.y,
}
}
break
}
}
return next
}
hitTestPoint(shape: TLLineShape, point: Vec2d): boolean {
const zoomLevel = this.editor.zoomLevel
const offsetDist = this.editor.getStrokeWidth(shape.props.size) / zoomLevel
return pointNearToPolyline(point, this.outline(shape), offsetDist)
}
hitTestLineSegment(shape: TLLineShape, A: VecLike, B: VecLike): boolean {
return intersectLineSegmentPolyline(A, B, this.outline(shape)) !== null
}
render(shape: TLLineShape) {
const forceSolid = useForceSolid()
const spline = getSplineForLineShape(shape)
const strokeWidth = this.editor.getStrokeWidth(shape.props.size)
const { dash, color } = shape.props
// Line style lines
if (shape.props.spline === 'line') {
if (dash === 'solid') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={pathData} fill={'none'} color={color} />
<path d={pathData} stroke="currentColor" strokeWidth={strokeWidth} fill="none" />
</SVGContainer>
)
}
if (dash === 'dashed' || dash === 'dotted') {
const outline = spline.points
const pathData = 'M' + outline[0] + 'L' + outline.slice(1)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={pathData} fill={'none'} color={color} />
<g stroke="currentColor" strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={segment.path}
fill="none"
/>
)
})}
</g>
</SVGContainer>
)
}
if (dash === 'draw') {
const outline = spline.points
const [innerPathData, outerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth)
return (
<SVGContainer id={shape.id}>
<ShapeFill d={innerPathData} fill={'none'} color={color} />
<path d={outerPathData} stroke="currentColor" strokeWidth={strokeWidth} fill="none" />
</SVGContainer>
)
}
}
// Cubic style spline
if (shape.props.spline === 'cubic') {
const splinePath = spline.path
if (dash === 'solid' || (dash === 'draw' && forceSolid)) {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} />
<path strokeWidth={strokeWidth} stroke="currentColor" fill="none" d={splinePath} />
</SVGContainer>
)
}
if (dash === 'dashed' || dash === 'dotted') {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} />
<g stroke="currentColor" strokeWidth={strokeWidth}>
{spline.segments.map((segment, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
segment.length,
strokeWidth,
{
style: dash,
start: i > 0 ? 'outset' : 'none',
end: i < spline.segments.length - 1 ? 'outset' : 'none',
}
)
return (
<path
key={i}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
d={segment.path}
fill="none"
/>
)
})}
</g>
</SVGContainer>
)
}
if (dash === 'draw') {
return (
<SVGContainer id={shape.id}>
<ShapeFill d={splinePath} fill={'none'} color={color} />
<path
d={getLineDrawPath(shape, spline, strokeWidth)}
strokeWidth={1}
stroke="currentColor"
fill="currentColor"
/>
</SVGContainer>
)
}
}
}
indicator(shape: TLLineShape) {
const strokeWidth = this.editor.getStrokeWidth(shape.props.size)
const spline = getSplineForLineShape(shape)
const { dash } = shape.props
let path: string
if (shape.props.spline === 'line') {
const outline = spline.points
if (dash === 'solid' || dash === 'dotted' || dash === 'dashed') {
path = 'M' + outline[0] + 'L' + outline.slice(1)
} else {
const [innerPathData] = getDrawLinePathData(shape.id, outline, strokeWidth)
path = innerPathData
}
} else {
path = getLineIndicatorPath(shape, spline, strokeWidth)
}
return <path d={path} />
}
toSvg(shape: TLLineShape, _font: string, colors: TLExportColors) {
const { color: _color, size } = shape.props
const color = colors.fill[_color]
const spline = getSplineForLineShape(shape)
return getLineSvg(shape, spline, color, this.editor.getStrokeWidth(size))
}
}
/** @public */
export function getSplineForLineShape(shape: TLLineShape) {
return splinesCache.get(shape.props, () => {
const { spline, handles } = shape.props
const handlePoints = Object.values(handles).sort(sortByIndex).map(Vec2d.From)
switch (spline) {
case 'cubic': {
return new CubicSpline2d(handlePoints, handlePoints.length === 2 ? 2 : 1.2, 20)
}
case 'line': {
return new Polyline2d(handlePoints)
}
}
})
}