Tldraw/packages/tldraw/src/shape-utils/ellipse/ellipse.tsx

381 wiersze
10 KiB
TypeScript

import * as React from 'react'
import { Utils, SVGContainer, TLBounds } from '@tldraw/core'
import { Vec } from '@tldraw/vec'
import { getStrokeOutlinePoints, getStrokePoints } from 'perfect-freehand'
import { defaultStyle, getShapeStyle } from '../shape-styles'
import {
EllipseShape,
DashStyle,
TLDrawShapeType,
TLDrawShape,
TLDrawTransformInfo,
TLDrawMeta,
} from '~types'
import { EASINGS, BINDING_DISTANCE } from '~constants'
import { TLDrawShapeUtil } from '../TLDrawShapeUtil'
import { intersectLineSegmentEllipse, intersectRayEllipse } from '@tldraw/intersect'
type T = EllipseShape
type E = SVGSVGElement
export class EllipseUtil extends TLDrawShapeUtil<T, E> {
type = TLDrawShapeType.Ellipse as const
canBind = true
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: 'id',
type: TLDrawShapeType.Ellipse,
name: 'Ellipse',
parentId: 'page',
childIndex: 1,
point: [0, 0],
radius: [1, 1],
rotation: 0,
style: defaultStyle,
},
props
)
}
Component = TLDrawShapeUtil.Component<T, E, TLDrawMeta>(
({ shape, isBinding, meta, events }, ref) => {
const {
radius: [radiusX, radiusY],
style,
} = shape
const styles = getShapeStyle(style, meta.isDarkMode)
const strokeWidth = +styles.strokeWidth
const sw = 1 + strokeWidth * 1.618
const rx = Math.max(0, radiusX - sw / 2)
const ry = Math.max(0, radiusY - sw / 2)
if (style.dash === DashStyle.Draw) {
const path = getEllipsePath(shape, this.getCenter(shape))
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<ellipse
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
rx={rx + 2}
ry={ry + 2}
/>
)}
<path
d={getEllipseIndicatorPathData(shape, this.getCenter(shape))}
stroke="none"
fill={style.isFilled ? styles.fill : 'none'}
pointerEvents="all"
/>
<path
d={path}
fill={styles.stroke}
stroke={styles.stroke}
strokeWidth={styles.strokeWidth}
pointerEvents="all"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SVGContainer>
)
}
const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2)
const perimeter = Math.PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
const { strokeDasharray, strokeDashoffset } = Utils.getPerfectDashProps(
perimeter < 64 ? perimeter * 2 : perimeter,
strokeWidth * 1.618,
shape.style.dash,
4
)
return (
<SVGContainer ref={ref} id={shape.id + '_svg'} {...events}>
{isBinding && (
<ellipse
className="tl-binding-indicator"
cx={radiusX}
cy={radiusY}
rx={rx + 32}
ry={ry + 32}
/>
)}
<ellipse
cx={radiusX}
cy={radiusY}
rx={rx}
ry={ry}
fill={styles.fill}
stroke={styles.stroke}
strokeWidth={sw}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
pointerEvents="all"
strokeLinecap="round"
strokeLinejoin="round"
/>
</SVGContainer>
)
}
)
Indicator = TLDrawShapeUtil.Indicator<T>(({ shape }) => {
return <path d={getEllipseIndicatorPathData(shape, this.getCenter(shape))} />
})
getBounds = (shape: T) => {
return Utils.getFromCache(this.boundsCache, shape, () => {
return Utils.getRotatedEllipseBounds(
shape.point[0],
shape.point[1],
shape.radius[0],
shape.radius[1],
0
)
})
}
getRotatedBounds = (shape: T): TLBounds => {
return Utils.getRotatedEllipseBounds(
shape.point[0],
shape.point[1],
shape.radius[0],
shape.radius[1],
shape.rotation
)
}
shouldRender = (prev: T, next: T): boolean => {
return next.radius !== prev.radius || next.style !== prev.style
}
getCenter = (shape: T): number[] => {
return [shape.point[0] + shape.radius[0], shape.point[1] + shape.radius[1]]
}
getBindingPoint = <K extends TLDrawShape>(
shape: T,
fromShape: K,
point: number[],
origin: number[],
direction: number[],
padding: number,
bindAnywhere: boolean
) => {
{
const bounds = this.getBounds(shape)
const expandedBounds = Utils.expandBounds(bounds, padding)
const center = this.getCenter(shape)
let bindingPoint: number[]
let distance: number
if (
!Utils.pointInEllipse(
point,
center,
shape.radius[0] + BINDING_DISTANCE,
shape.radius[1] + BINDING_DISTANCE
)
)
return
if (bindAnywhere) {
if (Vec.dist(point, this.getCenter(shape)) < 12) {
bindingPoint = [0.5, 0.5]
} else {
bindingPoint = Vec.divV(Vec.sub(point, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
distance = 0
} else {
let intersection = intersectRayEllipse(
origin,
direction,
center,
shape.radius[0],
shape.radius[1],
shape.rotation || 0
).points.sort((a, b) => Vec.dist(a, origin) - Vec.dist(b, origin))[0]
if (!intersection) {
intersection = intersectLineSegmentEllipse(
point,
center,
center,
shape.radius[0],
shape.radius[1],
shape.rotation || 0
).points.sort((a, b) => Vec.dist(a, point) - Vec.dist(b, point))[0]
}
// The anchor is a point between the handle and the intersection
const anchor = Vec.med(point, intersection)
if (Vec.distanceToLineSegment(point, anchor, this.getCenter(shape)) < 12) {
// If we're close to the center, snap to the center
bindingPoint = [0.5, 0.5]
} else {
// Or else calculate a normalized point
bindingPoint = Vec.divV(Vec.sub(anchor, [expandedBounds.minX, expandedBounds.minY]), [
expandedBounds.width,
expandedBounds.height,
])
}
if (
Utils.pointInEllipse(point, center, shape.radius[0], shape.radius[1], shape.rotation || 0)
) {
// Pad the arrow out by 16 points
distance = BINDING_DISTANCE / 2
} else {
// Find the distance between the point and the ellipse
const innerIntersection = intersectLineSegmentEllipse(
point,
center,
center,
shape.radius[0],
shape.radius[1],
shape.rotation || 0
).points[0]
if (!innerIntersection) {
return undefined
}
distance = Math.max(BINDING_DISTANCE / 2, Vec.dist(point, innerIntersection))
}
}
return {
point: bindingPoint,
distance,
}
}
}
transform = (
shape: T,
bounds: TLBounds,
{ scaleX, scaleY, initialShape }: TLDrawTransformInfo<T>
): Partial<T> => {
const { rotation = 0 } = initialShape
return {
point: [bounds.minX, bounds.minY],
radius: [bounds.width / 2, bounds.height / 2],
rotation:
(scaleX < 0 && scaleY >= 0) || (scaleY < 0 && scaleX >= 0)
? -(rotation || 0)
: rotation || 0,
}
}
transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
return {
point: Vec.round([bounds.minX, bounds.minY]),
radius: Vec.div([bounds.width, bounds.height], 2),
}
}
}
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
function getEllipseStrokePoints(shape: EllipseShape, boundsCenter: number[]) {
const {
id,
radius: [radiusX, radiusY],
point,
style,
} = shape
const { strokeWidth } = getShapeStyle(style)
const getRandom = Utils.rng(id)
const center = Vec.sub(boundsCenter, point)
const rx = radiusX + getRandom() * strokeWidth * 2
const ry = radiusY + getRandom() * strokeWidth * 2
const perimeter = Utils.perimeterOfEllipse(rx, ry)
const points: number[][] = []
const start = Math.PI + Math.PI * getRandom()
const extra = Math.abs(getRandom())
const count = Math.max(16, perimeter / 10)
for (let i = 0; i < count; i++) {
const t = EASINGS.easeInOutSine(i / (count + 1))
const rads = start * 2 + Math.PI * (2 + extra) * t
const c = Math.cos(rads)
const s = Math.sin(rads)
points.push([rx * c + center[0], ry * s + center[1], t + 0.5 + getRandom() / 2])
}
return getStrokePoints(points, {
size: 1 + strokeWidth * 2,
thinning: 0.618,
end: { taper: perimeter / 8 },
start: { taper: perimeter / 12 },
streamline: 0,
simulatePressure: true,
})
}
function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
const {
id,
radius: [radiusX, radiusY],
style,
} = shape
const { strokeWidth } = getShapeStyle(style)
const getRandom = Utils.rng(id)
const rx = radiusX + getRandom() * strokeWidth * 2
const ry = radiusY + getRandom() * strokeWidth * 2
const perimeter = Utils.perimeterOfEllipse(rx, ry)
return Utils.getSvgPathFromStroke(
getStrokeOutlinePoints(getEllipseStrokePoints(shape, boundsCenter), {
size: 1 + strokeWidth * 2,
thinning: 0.618,
end: { taper: perimeter / 8 },
start: { taper: perimeter / 12 },
streamline: 0,
simulatePressure: true,
})
)
}
function getEllipseIndicatorPathData(shape: EllipseShape, boundsCenter: number[]) {
return Utils.getSvgPathFromStroke(
getEllipseStrokePoints(shape, boundsCenter).map((pt) => pt.point.slice(0, 2)),
false
)
}