kopia lustrzana https://github.com/Tldraw/Tldraw
381 wiersze
10 KiB
TypeScript
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
|
|
)
|
|
}
|