kopia lustrzana https://github.com/Tldraw/Tldraw
323 wiersze
9.7 KiB
TypeScript
323 wiersze
9.7 KiB
TypeScript
import * as React from 'react'
|
|
import { Utils, SVGContainer, TLBounds } from '@tldraw/core'
|
|
import { Vec } from '@tldraw/vec'
|
|
import { defaultStyle, getShapeStyle } from '../shared/shape-styles'
|
|
import { DrawShape, DashStyle, TDShapeType, TransformInfo, TDMeta } from '~types'
|
|
import { TDShapeUtil } from '../TDShapeUtil'
|
|
import {
|
|
intersectBoundsBounds,
|
|
intersectBoundsPolyline,
|
|
intersectLineSegmentBounds,
|
|
intersectLineSegmentLineSegment,
|
|
} from '@tldraw/intersect'
|
|
import {
|
|
getDrawStrokePathTDSnapshot,
|
|
getFillPath,
|
|
getSolidStrokePathTDSnapshot,
|
|
} from './drawHelpers'
|
|
import { GHOSTED_OPACITY } from '~constants'
|
|
|
|
type T = DrawShape
|
|
type E = SVGSVGElement
|
|
|
|
export class DrawUtil extends TDShapeUtil<T, E> {
|
|
type = TDShapeType.Draw as const
|
|
|
|
pointsBoundsCache = new WeakMap<T['points'], TLBounds>([])
|
|
|
|
shapeBoundsCache = new Map<string, TLBounds>()
|
|
|
|
rotatedCache = new WeakMap<T, number[][]>([])
|
|
|
|
pointCache: Record<string, number[]> = {}
|
|
|
|
canClone = true
|
|
|
|
getShape = (props: Partial<T>): T => {
|
|
return Utils.deepMerge<T>(
|
|
{
|
|
id: 'id',
|
|
type: TDShapeType.Draw,
|
|
name: 'Draw',
|
|
parentId: 'page',
|
|
childIndex: 1,
|
|
point: [0, 0],
|
|
rotation: 0,
|
|
style: defaultStyle,
|
|
points: [],
|
|
isComplete: false,
|
|
},
|
|
props
|
|
)
|
|
}
|
|
|
|
Component = TDShapeUtil.Component<T, E, TDMeta>(
|
|
({ shape, meta, isSelected, isGhost, events }, ref) => {
|
|
const { points, style, isComplete } = shape
|
|
|
|
const polygonPathTDSnapshot = React.useMemo(() => {
|
|
return getFillPath(shape)
|
|
}, [points, style.size])
|
|
|
|
const pathTDSnapshot = React.useMemo(() => {
|
|
return style.dash === DashStyle.Draw
|
|
? getDrawStrokePathTDSnapshot(shape)
|
|
: getSolidStrokePathTDSnapshot(shape)
|
|
}, [points, style.size, style.dash, isComplete])
|
|
|
|
const styles = getShapeStyle(style, meta.isDarkMode)
|
|
const { stroke, fill, strokeWidth } = styles
|
|
|
|
// For very short lines, draw a point instead of a line
|
|
const bounds = this.getBounds(shape)
|
|
|
|
const verySmall = bounds.width <= strokeWidth / 2 && bounds.height <= strokeWidth / 2
|
|
|
|
if (verySmall) {
|
|
const sw = 1 + strokeWidth
|
|
|
|
return (
|
|
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
|
<circle
|
|
r={sw}
|
|
fill={stroke}
|
|
stroke={stroke}
|
|
pointerEvents="all"
|
|
opacity={isGhost ? GHOSTED_OPACITY : 1}
|
|
/>
|
|
</SVGContainer>
|
|
)
|
|
}
|
|
|
|
const shouldFill =
|
|
style.isFilled &&
|
|
points.length > 3 &&
|
|
Vec.dist(points[0], points[points.length - 1]) < strokeWidth * 2
|
|
|
|
if (shape.style.dash === DashStyle.Draw) {
|
|
return (
|
|
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
|
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
|
<path
|
|
className={shouldFill || isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
|
d={pathTDSnapshot}
|
|
/>
|
|
{shouldFill && (
|
|
<path
|
|
d={polygonPathTDSnapshot}
|
|
stroke="none"
|
|
fill={fill}
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
pointerEvents="none"
|
|
/>
|
|
)}
|
|
<path
|
|
d={pathTDSnapshot}
|
|
fill={stroke}
|
|
stroke={stroke}
|
|
strokeWidth={strokeWidth / 2}
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
pointerEvents="none"
|
|
/>
|
|
</g>
|
|
</SVGContainer>
|
|
)
|
|
}
|
|
|
|
// For solid, dash and dotted lines, draw a regular stroke path
|
|
|
|
const strokeDasharray = {
|
|
[DashStyle.Draw]: 'none',
|
|
[DashStyle.Solid]: `none`,
|
|
[DashStyle.Dotted]: `0.1 ${strokeWidth * 4}`,
|
|
[DashStyle.Dashed]: `${strokeWidth * 4} ${strokeWidth * 4}`,
|
|
}[style.dash]
|
|
|
|
const strokeDashoffset = {
|
|
[DashStyle.Draw]: 'none',
|
|
[DashStyle.Solid]: `none`,
|
|
[DashStyle.Dotted]: `0`,
|
|
[DashStyle.Dashed]: `0`,
|
|
}[style.dash]
|
|
|
|
const sw = 1 + strokeWidth * 1.5
|
|
|
|
return (
|
|
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
|
|
<g opacity={isGhost ? GHOSTED_OPACITY : 1}>
|
|
<path
|
|
className={shouldFill && isSelected ? 'tl-fill-hitarea' : 'tl-stroke-hitarea'}
|
|
d={pathTDSnapshot}
|
|
/>
|
|
<path
|
|
d={pathTDSnapshot}
|
|
fill={shouldFill ? fill : 'none'}
|
|
stroke="none"
|
|
strokeWidth={Math.min(4, strokeWidth * 2)}
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
pointerEvents="none"
|
|
/>
|
|
<path
|
|
d={pathTDSnapshot}
|
|
fill="none"
|
|
stroke={stroke}
|
|
strokeWidth={sw}
|
|
strokeDasharray={strokeDasharray}
|
|
strokeDashoffset={strokeDashoffset}
|
|
strokeLinejoin="round"
|
|
strokeLinecap="round"
|
|
pointerEvents="none"
|
|
/>
|
|
</g>
|
|
</SVGContainer>
|
|
)
|
|
}
|
|
)
|
|
|
|
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
|
|
const { points } = shape
|
|
|
|
const pathTDSnapshot = React.useMemo(() => {
|
|
return getSolidStrokePathTDSnapshot(shape)
|
|
}, [points])
|
|
|
|
const bounds = this.getBounds(shape)
|
|
|
|
const verySmall = bounds.width < 4 && bounds.height < 4
|
|
|
|
if (verySmall) {
|
|
return <circle x={bounds.width / 2} y={bounds.height / 2} r={1} />
|
|
}
|
|
|
|
return <path d={pathTDSnapshot} />
|
|
})
|
|
|
|
transform = (
|
|
shape: T,
|
|
bounds: TLBounds,
|
|
{ initialShape, scaleX, scaleY }: TransformInfo<T>
|
|
): Partial<T> => {
|
|
const initialShapeBounds = Utils.getFromCache(this.boundsCache, initialShape, () =>
|
|
Utils.getBoundsFromPoints(initialShape.points)
|
|
)
|
|
|
|
const points = initialShape.points.map(([x, y, r]) => {
|
|
return [
|
|
bounds.width *
|
|
(scaleX < 0 // * sin?
|
|
? 1 - x / initialShapeBounds.width
|
|
: x / initialShapeBounds.width),
|
|
bounds.height *
|
|
(scaleY < 0 // * cos?
|
|
? 1 - y / initialShapeBounds.height
|
|
: y / initialShapeBounds.height),
|
|
r,
|
|
]
|
|
})
|
|
|
|
const newBounds = Utils.getBoundsFromPoints(shape.points)
|
|
|
|
const point = Vec.sub([bounds.minX, bounds.minY], [newBounds.minX, newBounds.minY])
|
|
|
|
return {
|
|
points,
|
|
point,
|
|
}
|
|
}
|
|
|
|
getBounds = (shape: T) => {
|
|
// The goal here is to avoid recalculating the bounds from the
|
|
// points array, which is expensive. However, we still need a
|
|
// new bounds if the point has changed, but we will reuse the
|
|
// previous bounds-from-points result if we can.
|
|
|
|
const pointsHaveChanged = !this.pointsBoundsCache.has(shape.points)
|
|
const pointHasChanged = !(this.pointCache[shape.id] === shape.point)
|
|
|
|
if (pointsHaveChanged) {
|
|
// If the points have changed, then bust the points cache
|
|
const bounds = Utils.getBoundsFromPoints(shape.points)
|
|
this.pointsBoundsCache.set(shape.points, bounds)
|
|
this.shapeBoundsCache.set(shape.id, Utils.translateBounds(bounds, shape.point))
|
|
this.pointCache[shape.id] = shape.point
|
|
} else if (pointHasChanged && !pointsHaveChanged) {
|
|
// If the point have has changed, then bust the point cache
|
|
this.pointCache[shape.id] = shape.point
|
|
this.shapeBoundsCache.set(
|
|
shape.id,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
Utils.translateBounds(this.pointsBoundsCache.get(shape.points)!, shape.point)
|
|
)
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return this.shapeBoundsCache.get(shape.id)!
|
|
}
|
|
|
|
shouldRender = (prev: T, next: T) => {
|
|
return (
|
|
next.points !== prev.points ||
|
|
next.style !== prev.style ||
|
|
next.isComplete !== prev.isComplete
|
|
)
|
|
}
|
|
|
|
hitTestPoint = (shape: T, point: number[]) => {
|
|
const ptA = Vec.sub(point, shape.point)
|
|
return Utils.pointInPolyline(ptA, shape.points)
|
|
}
|
|
|
|
hitTestLineSegment = (shape: T, A: number[], B: number[]): boolean => {
|
|
const { points, point } = shape
|
|
const ptA = Vec.sub(A, point)
|
|
const ptB = Vec.sub(B, point)
|
|
const bounds = this.getBounds(shape)
|
|
|
|
if (bounds.width < 8 && bounds.height < 8) {
|
|
return Vec.distanceToLineSegment(A, B, Utils.getBoundsCenter(bounds)) < 5 // divide by zoom
|
|
}
|
|
|
|
if (intersectLineSegmentBounds(ptA, ptB, bounds)) {
|
|
for (let i = 1; i < points.length; i++) {
|
|
if (intersectLineSegmentLineSegment(points[i - 1], points[i], ptA, ptB).didIntersect) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
hitTestBounds = (shape: T, bounds: TLBounds) => {
|
|
// Test axis-aligned shape
|
|
if (!shape.rotation) {
|
|
const shapeBounds = this.getBounds(shape)
|
|
|
|
return (
|
|
Utils.boundsContain(bounds, shapeBounds) ||
|
|
((Utils.boundsContain(shapeBounds, bounds) ||
|
|
intersectBoundsBounds(shapeBounds, bounds).length > 0) &&
|
|
intersectBoundsPolyline(Utils.translateBounds(bounds, Vec.neg(shape.point)), shape.points)
|
|
.length > 0)
|
|
)
|
|
}
|
|
|
|
// Test rotated shape
|
|
const rBounds = this.getRotatedBounds(shape)
|
|
|
|
const rotatedBounds = Utils.getFromCache(this.rotatedCache, shape, () => {
|
|
const c = Utils.getBoundsCenter(Utils.getBoundsFromPoints(shape.points))
|
|
return shape.points.map((pt) => Vec.rotWith(pt, c, shape.rotation || 0))
|
|
})
|
|
|
|
return (
|
|
Utils.boundsContain(bounds, rBounds) ||
|
|
intersectBoundsPolyline(Utils.translateBounds(bounds, Vec.neg(shape.point)), rotatedBounds)
|
|
.length > 0
|
|
)
|
|
}
|
|
}
|